mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 07:46:12 +03:00
1503 lines
56 KiB
Zig
1503 lines
56 KiB
Zig
//! Exec implements the logic for starting and stopping a subprocess with a
|
|
//! pty as well as spinning up the necessary read thread to read from the
|
|
//! pty and forward it to the Termio instance.
|
|
const Exec = @This();
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const assert = std.debug.assert;
|
|
const Allocator = std.mem.Allocator;
|
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
|
const posix = std.posix;
|
|
const xev = @import("xev");
|
|
const build_config = @import("../build_config.zig");
|
|
const configpkg = @import("../config.zig");
|
|
const crash = @import("../crash/main.zig");
|
|
const fastmem = @import("../fastmem.zig");
|
|
const internal_os = @import("../os/main.zig");
|
|
const renderer = @import("../renderer.zig");
|
|
const shell_integration = @import("shell_integration.zig");
|
|
const terminal = @import("../terminal/main.zig");
|
|
const termio = @import("../termio.zig");
|
|
const Command = @import("../Command.zig");
|
|
const SegmentedPool = @import("../datastruct/main.zig").SegmentedPool;
|
|
const ptypkg = @import("../pty.zig");
|
|
const Pty = ptypkg.Pty;
|
|
const EnvMap = std.process.EnvMap;
|
|
const windows = internal_os.windows;
|
|
|
|
const log = std.log.scoped(.io_exec);
|
|
|
|
/// The termios poll rate in milliseconds.
|
|
const TERMIOS_POLL_MS = 200;
|
|
|
|
/// The subprocess state for our exec backend.
|
|
subprocess: Subprocess,
|
|
|
|
/// Initialize the exec state. This will NOT start it, this only sets
|
|
/// up the internal state necessary to start it later.
|
|
pub fn init(
|
|
alloc: Allocator,
|
|
cfg: Config,
|
|
) !Exec {
|
|
var subprocess = try Subprocess.init(alloc, cfg);
|
|
errdefer subprocess.deinit();
|
|
|
|
return .{ .subprocess = subprocess };
|
|
}
|
|
|
|
pub fn deinit(self: *Exec) void {
|
|
self.subprocess.deinit();
|
|
}
|
|
|
|
/// Call to initialize the terminal state as necessary for this backend.
|
|
/// This is called before any termio begins. This should not be called
|
|
/// after termio begins because it may put the internal terminal state
|
|
/// into a bad state.
|
|
pub fn initTerminal(self: *Exec, term: *terminal.Terminal) void {
|
|
// If we have an initial pwd requested by the subprocess, then we
|
|
// set that on the terminal now. This allows rapidly initializing
|
|
// new surfaces to use the proper pwd.
|
|
if (self.subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| {
|
|
log.warn("error setting initial pwd err={}", .{err});
|
|
};
|
|
|
|
// Setup our initial grid/screen size from the terminal. This
|
|
// can't fail because the pty should not exist at this point.
|
|
self.resize(.{
|
|
.columns = term.cols,
|
|
.rows = term.rows,
|
|
}, .{
|
|
.width = term.width_px,
|
|
.height = term.height_px,
|
|
}) catch unreachable;
|
|
}
|
|
|
|
pub fn threadEnter(
|
|
self: *Exec,
|
|
alloc: Allocator,
|
|
io: *termio.Termio,
|
|
td: *termio.Termio.ThreadData,
|
|
) !void {
|
|
// Start our subprocess
|
|
const pty_fds = self.subprocess.start(alloc) catch |err| {
|
|
// If we specifically got this error then we are in the forked
|
|
// process and our child failed to execute. In that case
|
|
if (err != error.Termio) return err;
|
|
|
|
// Output an error message about the exec faililng and exit.
|
|
// This generally should NOT happen because we always wrap
|
|
// our command execution either in login (macOS) or /bin/sh
|
|
// (Linux) which are usually guaranteed to exist. Still, we
|
|
// want to handle this scenario.
|
|
execFailedInChild() catch {};
|
|
posix.exit(1);
|
|
};
|
|
errdefer self.subprocess.stop();
|
|
|
|
// Get the pid from the subprocess
|
|
const pid = pid: {
|
|
const command = self.subprocess.command orelse return error.ProcessNotStarted;
|
|
break :pid command.pid orelse return error.ProcessNoPid;
|
|
};
|
|
|
|
// Track our process start time for abnormal exits
|
|
const process_start = try std.time.Instant.now();
|
|
|
|
// Create our pipe that we'll use to kill our read thread.
|
|
// pipe[0] is the read end, pipe[1] is the write end.
|
|
const pipe = try internal_os.pipe();
|
|
errdefer posix.close(pipe[0]);
|
|
errdefer posix.close(pipe[1]);
|
|
|
|
// Setup our stream so that we can write.
|
|
var stream = xev.Stream.initFd(pty_fds.write);
|
|
errdefer stream.deinit();
|
|
|
|
// Watcher to detect subprocess exit
|
|
var process = try xev.Process.init(pid);
|
|
errdefer process.deinit();
|
|
|
|
// Start our timer to read termios state changes. This is used
|
|
// to detect things such as when password input is being done
|
|
// so we can render the terminal in a different way.
|
|
var termios_timer = try xev.Timer.init();
|
|
errdefer termios_timer.deinit();
|
|
|
|
// Start our read thread
|
|
const read_thread = try std.Thread.spawn(
|
|
.{},
|
|
if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix,
|
|
.{ pty_fds.read, io, pipe[0] },
|
|
);
|
|
read_thread.setName("io-reader") catch {};
|
|
|
|
// Setup our threadata backend state to be our own
|
|
td.backend = .{ .exec = .{
|
|
.start = process_start,
|
|
.abnormal_runtime_threshold_ms = io.config.abnormal_runtime_threshold_ms,
|
|
.wait_after_command = io.config.wait_after_command,
|
|
.write_stream = stream,
|
|
.process = process,
|
|
.read_thread = read_thread,
|
|
.read_thread_pipe = pipe[1],
|
|
.read_thread_fd = pty_fds.read,
|
|
.termios_timer = termios_timer,
|
|
} };
|
|
|
|
// Start our process watcher
|
|
process.wait(
|
|
td.loop,
|
|
&td.backend.exec.process_wait_c,
|
|
termio.Termio.ThreadData,
|
|
td,
|
|
processExit,
|
|
);
|
|
|
|
// Start our termios timer. We don't support this on Windows.
|
|
// Fundamentally, we could support this on Windows so we're just
|
|
// waiting for someone to implement it.
|
|
if (comptime builtin.os.tag != .windows) {
|
|
termios_timer.run(
|
|
td.loop,
|
|
&td.backend.exec.termios_timer_c,
|
|
TERMIOS_POLL_MS,
|
|
termio.Termio.ThreadData,
|
|
td,
|
|
termiosTimer,
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void {
|
|
assert(td.backend == .exec);
|
|
const exec = &td.backend.exec;
|
|
|
|
if (exec.exited) self.subprocess.externalExit();
|
|
self.subprocess.stop();
|
|
|
|
// Quit our read thread after exiting the subprocess so that
|
|
// we don't get stuck waiting for data to stop flowing if it is
|
|
// a particularly noisy process.
|
|
_ = posix.write(exec.read_thread_pipe, "x") catch |err|
|
|
log.warn("error writing to read thread quit pipe err={}", .{err});
|
|
|
|
if (comptime builtin.os.tag == .windows) {
|
|
// Interrupt the blocking read so the thread can see the quit message
|
|
if (windows.kernel32.CancelIoEx(exec.read_thread_fd, null) == 0) {
|
|
switch (windows.kernel32.GetLastError()) {
|
|
.NOT_FOUND => {},
|
|
else => |err| log.warn("error interrupting read thread err={}", .{err}),
|
|
}
|
|
}
|
|
}
|
|
|
|
exec.read_thread.join();
|
|
}
|
|
|
|
pub fn focusGained(
|
|
self: *Exec,
|
|
td: *termio.Termio.ThreadData,
|
|
focused: bool,
|
|
) !void {
|
|
_ = self;
|
|
|
|
assert(td.backend == .exec);
|
|
const execdata = &td.backend.exec;
|
|
|
|
if (!focused) {
|
|
// Flag the timer to end on the next iteration. This is
|
|
// a lot cheaper than doing full timer cancellation.
|
|
execdata.termios_timer_running = false;
|
|
} else {
|
|
// Always set this to true. There is a race condition if we lose
|
|
// focus and regain focus before the termios timer ticks where
|
|
// if we don't set this unconditionally the timer will end on
|
|
// the next iteration.
|
|
execdata.termios_timer_running = true;
|
|
|
|
// If we're focused, we want to start our termios timer. We
|
|
// only do this if it isn't already running. We use the termios
|
|
// callback because that'll trigger an immediate state check AND
|
|
// start the timer.
|
|
if (execdata.termios_timer_c.state() != .active) {
|
|
_ = termiosTimer(td, undefined, undefined, {});
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn resize(
|
|
self: *Exec,
|
|
grid_size: renderer.GridSize,
|
|
screen_size: renderer.ScreenSize,
|
|
) !void {
|
|
return try self.subprocess.resize(grid_size, screen_size);
|
|
}
|
|
|
|
/// Called when the child process exited abnormally but before the surface
|
|
/// is notified.
|
|
pub fn childExitedAbnormally(
|
|
self: *Exec,
|
|
gpa: Allocator,
|
|
t: *terminal.Terminal,
|
|
exit_code: u32,
|
|
runtime_ms: u64,
|
|
) !void {
|
|
var arena = ArenaAllocator.init(gpa);
|
|
defer arena.deinit();
|
|
const alloc = arena.allocator();
|
|
|
|
// Build up our command for the error message
|
|
const command = try std.mem.join(alloc, " ", self.subprocess.args);
|
|
const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms});
|
|
|
|
// No matter what move the cursor back to the column 0.
|
|
t.carriageReturn();
|
|
|
|
// Reset styles
|
|
try t.setAttribute(.{ .unset = {} });
|
|
|
|
// If there is data in the viewport, we want to scroll down
|
|
// a little bit and write a horizontal rule before writing
|
|
// our message. This lets the use see the error message the
|
|
// command may have output.
|
|
const viewport_str = try t.plainString(alloc);
|
|
if (viewport_str.len > 0) {
|
|
try t.linefeed();
|
|
for (0..t.cols) |_| try t.print(0x2501);
|
|
t.carriageReturn();
|
|
try t.linefeed();
|
|
try t.linefeed();
|
|
}
|
|
|
|
// Output our error message
|
|
try t.setAttribute(.{ .@"8_fg" = .bright_red });
|
|
try t.setAttribute(.{ .bold = {} });
|
|
try t.printString("Ghostty failed to launch the requested command:");
|
|
try t.setAttribute(.{ .unset = {} });
|
|
|
|
t.carriageReturn();
|
|
try t.linefeed();
|
|
try t.linefeed();
|
|
try t.printString(command);
|
|
try t.setAttribute(.{ .unset = {} });
|
|
|
|
t.carriageReturn();
|
|
try t.linefeed();
|
|
try t.linefeed();
|
|
try t.printString("Runtime: ");
|
|
try t.setAttribute(.{ .@"8_fg" = .red });
|
|
try t.printString(runtime_str);
|
|
try t.setAttribute(.{ .unset = {} });
|
|
|
|
// We don't print this on macOS because the exit code is always 0
|
|
// due to the way we launch the process.
|
|
if (comptime !builtin.target.isDarwin()) {
|
|
const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code});
|
|
t.carriageReturn();
|
|
try t.linefeed();
|
|
try t.printString("Exit Code: ");
|
|
try t.setAttribute(.{ .@"8_fg" = .red });
|
|
try t.printString(exit_code_str);
|
|
try t.setAttribute(.{ .unset = {} });
|
|
}
|
|
|
|
t.carriageReturn();
|
|
try t.linefeed();
|
|
try t.linefeed();
|
|
try t.printString("Press any key to close the window.");
|
|
|
|
// Hide the cursor
|
|
t.modes.set(.cursor_visible, false);
|
|
}
|
|
|
|
/// This outputs an error message when exec failed and we are the
|
|
/// child process. This returns so the caller should probably exit
|
|
/// after calling this.
|
|
///
|
|
/// Note that this usually is only called under very very rare
|
|
/// circumstances because we wrap our command execution in login
|
|
/// (macOS) or /bin/sh (Linux). So this output can be pretty crude
|
|
/// because it should never happen. Notably, this is not the error
|
|
/// users see when `command` is invalid.
|
|
fn execFailedInChild() !void {
|
|
const stderr = std.io.getStdErr().writer();
|
|
try stderr.writeAll("exec failed\n");
|
|
try stderr.writeAll("press any key to exit\n");
|
|
|
|
var buf: [1]u8 = undefined;
|
|
var reader = std.io.getStdIn().reader();
|
|
_ = try reader.read(&buf);
|
|
}
|
|
|
|
fn processExit(
|
|
td_: ?*termio.Termio.ThreadData,
|
|
_: *xev.Loop,
|
|
_: *xev.Completion,
|
|
r: xev.Process.WaitError!u32,
|
|
) xev.CallbackAction {
|
|
const exit_code = r catch unreachable;
|
|
|
|
const td = td_.?;
|
|
assert(td.backend == .exec);
|
|
const execdata = &td.backend.exec;
|
|
execdata.exited = true;
|
|
|
|
// Determine how long the process was running for.
|
|
const runtime_ms: ?u64 = runtime: {
|
|
const process_end = std.time.Instant.now() catch break :runtime null;
|
|
const runtime_ns = process_end.since(execdata.start);
|
|
const runtime_ms = runtime_ns / std.time.ns_per_ms;
|
|
break :runtime runtime_ms;
|
|
};
|
|
log.debug(
|
|
"child process exited status={} runtime={}ms",
|
|
.{ exit_code, runtime_ms orelse 0 },
|
|
);
|
|
|
|
// If our runtime was below some threshold then we assume that this
|
|
// was an abnormal exit and we show an error message.
|
|
if (runtime_ms) |runtime| runtime: {
|
|
// On macOS, our exit code detection doesn't work, possibly
|
|
// because of our `login` wrapper. More investigation required.
|
|
if (comptime !builtin.target.isDarwin()) {
|
|
// If our exit code is zero, then the command was successful
|
|
// and we don't ever consider it abnormal.
|
|
if (exit_code == 0) break :runtime;
|
|
}
|
|
|
|
// Our runtime always has to be under the threshold to be
|
|
// considered abnormal. This is because a user can always
|
|
// manually do something like `exit 1` in their shell to
|
|
// force the exit code to be non-zero. We only want to detect
|
|
// abnormal exits that happen so quickly the user can't react.
|
|
if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime;
|
|
log.warn("abnormal process exit detected, showing error message", .{});
|
|
|
|
// Notify our main writer thread which has access to more
|
|
// information so it can show a better error message.
|
|
td.mailbox.send(.{
|
|
.child_exited_abnormally = .{
|
|
.exit_code = exit_code,
|
|
.runtime_ms = runtime,
|
|
},
|
|
}, null);
|
|
td.mailbox.notify();
|
|
|
|
return .disarm;
|
|
}
|
|
|
|
// If we're purposely waiting then we just return since the process
|
|
// exited flag is set to true. This allows the terminal window to remain
|
|
// open.
|
|
if (execdata.wait_after_command) {
|
|
// We output a message so that the user knows whats going on and
|
|
// doesn't think their terminal just froze.
|
|
terminal: {
|
|
td.renderer_state.mutex.lock();
|
|
defer td.renderer_state.mutex.unlock();
|
|
const t = td.renderer_state.terminal;
|
|
t.carriageReturn();
|
|
t.linefeed() catch break :terminal;
|
|
t.printString("Process exited. Press any key to close the terminal.") catch
|
|
break :terminal;
|
|
t.modes.set(.cursor_visible, false);
|
|
}
|
|
|
|
return .disarm;
|
|
}
|
|
|
|
// Notify our surface we want to close
|
|
_ = td.surface_mailbox.push(.{
|
|
.child_exited = {},
|
|
}, .{ .forever = {} });
|
|
|
|
return .disarm;
|
|
}
|
|
|
|
fn termiosTimer(
|
|
td_: ?*termio.Termio.ThreadData,
|
|
_: *xev.Loop,
|
|
_: *xev.Completion,
|
|
r: xev.Timer.RunError!void,
|
|
) xev.CallbackAction {
|
|
// log.debug("termios timer fired", .{});
|
|
|
|
// This should never happen because we guard starting our
|
|
// timer on windows but we want this assertion to fire if
|
|
// we ever do start the timer on windows.
|
|
// TODO: support on windows
|
|
if (comptime builtin.os.tag == .windows) {
|
|
@panic("termios timer not implemented on Windows");
|
|
}
|
|
|
|
_ = r catch |err| switch (err) {
|
|
// This is sent when our timer is canceled. That's fine.
|
|
error.Canceled => return .disarm,
|
|
|
|
else => {
|
|
log.warn("error in termios timer callback err={}", .{err});
|
|
@panic("crash in termios timer callback");
|
|
},
|
|
};
|
|
|
|
const td = td_.?;
|
|
assert(td.backend == .exec);
|
|
const exec = &td.backend.exec;
|
|
|
|
// This is kind of hacky but we rebuild a Pty struct to get the
|
|
// termios data.
|
|
const mode: ptypkg.Mode = (Pty{
|
|
.master = exec.read_thread_fd,
|
|
.slave = undefined,
|
|
}).getMode() catch |err| err: {
|
|
log.warn("error getting termios mode err={}", .{err});
|
|
|
|
// If we have an error we return the default mode values
|
|
// which are the likely values.
|
|
break :err .{};
|
|
};
|
|
|
|
// If the mode changed, then we process it.
|
|
if (!std.meta.eql(mode, exec.termios_mode)) mode_change: {
|
|
log.debug("termios change mode={}", .{mode});
|
|
exec.termios_mode = mode;
|
|
|
|
// We assume we're in some sort of password input if we're
|
|
// in canonical mode and not echoing. This is a heuristic.
|
|
const password_input = mode.canonical and !mode.echo;
|
|
|
|
// If our password input state changed on the terminal then
|
|
// we notify the surface.
|
|
{
|
|
td.renderer_state.mutex.lock();
|
|
defer td.renderer_state.mutex.unlock();
|
|
const t = td.renderer_state.terminal;
|
|
if (t.flags.password_input == password_input) {
|
|
break :mode_change;
|
|
}
|
|
}
|
|
|
|
// We have to notify the surface that we're in password input.
|
|
// We must block on this because the balanced true/false state
|
|
// of this is critical to apprt behavior.
|
|
_ = td.surface_mailbox.push(.{
|
|
.password_input = password_input,
|
|
}, .{ .forever = {} });
|
|
}
|
|
|
|
// Repeat the timer
|
|
if (exec.termios_timer_running) {
|
|
exec.termios_timer.run(
|
|
td.loop,
|
|
&exec.termios_timer_c,
|
|
TERMIOS_POLL_MS,
|
|
termio.Termio.ThreadData,
|
|
td,
|
|
termiosTimer,
|
|
);
|
|
}
|
|
|
|
return .disarm;
|
|
}
|
|
|
|
pub fn queueWrite(
|
|
self: *Exec,
|
|
alloc: Allocator,
|
|
td: *termio.Termio.ThreadData,
|
|
data: []const u8,
|
|
linefeed: bool,
|
|
) !void {
|
|
_ = self;
|
|
const exec = &td.backend.exec;
|
|
|
|
// If our process is exited then we send our surface a message
|
|
// about it but we don't queue any more writes.
|
|
if (exec.exited) {
|
|
_ = td.surface_mailbox.push(.{
|
|
.child_exited = {},
|
|
}, .{ .forever = {} });
|
|
return;
|
|
}
|
|
|
|
// We go through and chunk the data if necessary to fit into
|
|
// our cached buffers that we can queue to the stream.
|
|
var i: usize = 0;
|
|
while (i < data.len) {
|
|
const req = try exec.write_req_pool.getGrow(alloc);
|
|
const buf = try exec.write_buf_pool.getGrow(alloc);
|
|
const slice = slice: {
|
|
// The maximum end index is either the end of our data or
|
|
// the end of our buffer, whichever is smaller.
|
|
const max = @min(data.len, i + buf.len);
|
|
|
|
// Fast
|
|
if (!linefeed) {
|
|
fastmem.copy(u8, buf, data[i..max]);
|
|
const len = max - i;
|
|
i = max;
|
|
break :slice buf[0..len];
|
|
}
|
|
|
|
// Slow, have to replace \r with \r\n
|
|
var buf_i: usize = 0;
|
|
while (i < data.len and buf_i < buf.len - 1) {
|
|
const ch = data[i];
|
|
i += 1;
|
|
|
|
if (ch != '\r') {
|
|
buf[buf_i] = ch;
|
|
buf_i += 1;
|
|
continue;
|
|
}
|
|
|
|
// CRLF
|
|
buf[buf_i] = '\r';
|
|
buf[buf_i + 1] = '\n';
|
|
buf_i += 2;
|
|
}
|
|
|
|
break :slice buf[0..buf_i];
|
|
};
|
|
|
|
//for (slice) |b| log.warn("write: {x}", .{b});
|
|
|
|
exec.write_stream.queueWrite(
|
|
td.loop,
|
|
&exec.write_queue,
|
|
req,
|
|
.{ .slice = slice },
|
|
termio.Exec.ThreadData,
|
|
exec,
|
|
ttyWrite,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn ttyWrite(
|
|
td_: ?*ThreadData,
|
|
_: *xev.Loop,
|
|
_: *xev.Completion,
|
|
_: xev.Stream,
|
|
_: xev.WriteBuffer,
|
|
r: xev.Stream.WriteError!usize,
|
|
) xev.CallbackAction {
|
|
const td = td_.?;
|
|
td.write_req_pool.put();
|
|
td.write_buf_pool.put();
|
|
|
|
const d = r catch |err| {
|
|
log.err("write error: {}", .{err});
|
|
return .disarm;
|
|
};
|
|
_ = d;
|
|
//log.info("WROTE: {d}", .{d});
|
|
|
|
return .disarm;
|
|
}
|
|
|
|
/// The thread local data for the exec implementation.
|
|
pub const ThreadData = struct {
|
|
// The preallocation size for the write request pool. This should be big
|
|
// enough to satisfy most write requests. It must be a power of 2.
|
|
const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5);
|
|
|
|
/// Process start time and boolean of whether its already exited.
|
|
start: std.time.Instant,
|
|
exited: bool = false,
|
|
|
|
/// The number of milliseconds below which we consider a process
|
|
/// exit to be abnormal. This is used to show an error message
|
|
/// when the process exits too quickly.
|
|
abnormal_runtime_threshold_ms: u32,
|
|
|
|
/// If true, do not immediately send a child exited message to the
|
|
/// surface to close the surface when the command exits. If this is
|
|
/// false we'll show a process exited message and wait for user input
|
|
/// to close the surface.
|
|
wait_after_command: bool,
|
|
|
|
/// The data stream is the main IO for the pty.
|
|
write_stream: xev.Stream,
|
|
|
|
/// The process watcher
|
|
process: xev.Process,
|
|
|
|
/// This is the pool of available (unused) write requests. If you grab
|
|
/// one from the pool, you must put it back when you're done!
|
|
write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{},
|
|
|
|
/// The pool of available buffers for writing to the pty.
|
|
write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{},
|
|
|
|
/// The write queue for the data stream.
|
|
write_queue: xev.Stream.WriteQueue = .{},
|
|
|
|
/// This is used for both waiting for the process to exit and then
|
|
/// subsequently to wait for the data_stream to close.
|
|
process_wait_c: xev.Completion = .{},
|
|
|
|
/// Reader thread state
|
|
read_thread: std.Thread,
|
|
read_thread_pipe: posix.fd_t,
|
|
read_thread_fd: posix.fd_t,
|
|
|
|
/// The timer to detect termios state changes.
|
|
termios_timer: xev.Timer,
|
|
termios_timer_c: xev.Completion = .{},
|
|
termios_timer_running: bool = true,
|
|
|
|
/// The last known termios mode. Used for change detection
|
|
/// to prevent unnecessary locking of expensive mutexes.
|
|
termios_mode: ptypkg.Mode = .{},
|
|
|
|
pub fn deinit(self: *ThreadData, alloc: Allocator) void {
|
|
posix.close(self.read_thread_pipe);
|
|
|
|
// Clear our write pools. We know we aren't ever going to do
|
|
// any more IO since we stop our data stream below so we can just
|
|
// drop this.
|
|
self.write_req_pool.deinit(alloc);
|
|
self.write_buf_pool.deinit(alloc);
|
|
|
|
// Stop our process watcher
|
|
self.process.deinit();
|
|
|
|
// Stop our write stream
|
|
self.write_stream.deinit();
|
|
|
|
// Stop our termios timer
|
|
self.termios_timer.deinit();
|
|
}
|
|
};
|
|
|
|
pub const Config = struct {
|
|
command: ?[]const u8 = null,
|
|
shell_integration: configpkg.Config.ShellIntegration = .detect,
|
|
shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{},
|
|
working_directory: ?[]const u8 = null,
|
|
resources_dir: ?[]const u8,
|
|
term: []const u8,
|
|
linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default,
|
|
};
|
|
|
|
const Subprocess = struct {
|
|
/// If we build with flatpak support then we have to keep track of
|
|
/// a potential execution on the host.
|
|
const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void;
|
|
|
|
const c = @cImport({
|
|
@cInclude("errno.h");
|
|
@cInclude("signal.h");
|
|
@cInclude("unistd.h");
|
|
});
|
|
|
|
arena: std.heap.ArenaAllocator,
|
|
cwd: ?[]const u8,
|
|
env: EnvMap,
|
|
args: [][]const u8,
|
|
grid_size: renderer.GridSize,
|
|
screen_size: renderer.ScreenSize,
|
|
pty: ?Pty = null,
|
|
command: ?Command = null,
|
|
flatpak_command: ?FlatpakHostCommand = null,
|
|
linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default,
|
|
|
|
/// Initialize the subprocess. This will NOT start it, this only sets
|
|
/// up the internal state necessary to start it later.
|
|
pub fn init(gpa: Allocator, cfg: Config) !Subprocess {
|
|
// We have a lot of maybe-allocations that all share the same lifetime
|
|
// so use an arena so we don't end up in an accounting nightmare.
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
errdefer arena.deinit();
|
|
const alloc = arena.allocator();
|
|
|
|
// Set our env vars. For Flatpak builds running in Flatpak we don't
|
|
// inherit our environment because the login shell on the host side
|
|
// will get it.
|
|
var env = env: {
|
|
if (comptime build_config.flatpak) {
|
|
if (internal_os.isFlatpak()) {
|
|
break :env std.process.EnvMap.init(alloc);
|
|
}
|
|
}
|
|
|
|
break :env try std.process.getEnvMap(alloc);
|
|
};
|
|
errdefer env.deinit();
|
|
|
|
// If we have a resources dir then set our env var
|
|
if (cfg.resources_dir) |dir| {
|
|
log.info("found Ghostty resources dir: {s}", .{dir});
|
|
try env.put("GHOSTTY_RESOURCES_DIR", dir);
|
|
}
|
|
|
|
// Set our TERM var. This is a bit complicated because we want to use
|
|
// the ghostty TERM value but we want to only do that if we have
|
|
// ghostty in the TERMINFO database.
|
|
//
|
|
// For now, we just look up a bundled dir but in the future we should
|
|
// also load the terminfo database and look for it.
|
|
if (cfg.resources_dir) |base| {
|
|
try env.put("TERM", cfg.term);
|
|
try env.put("COLORTERM", "truecolor");
|
|
|
|
// Assume that the resources directory is adjacent to the terminfo
|
|
// database
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{
|
|
std.fs.path.dirname(base) orelse unreachable,
|
|
});
|
|
try env.put("TERMINFO", dir);
|
|
} else {
|
|
if (comptime builtin.target.isDarwin()) {
|
|
log.warn("ghostty terminfo not found, using xterm-256color", .{});
|
|
log.warn("the terminfo SHOULD exist on macos, please ensure", .{});
|
|
log.warn("you're using a valid app bundle.", .{});
|
|
}
|
|
|
|
try env.put("TERM", "xterm-256color");
|
|
try env.put("COLORTERM", "truecolor");
|
|
}
|
|
|
|
// Add our binary to the path if we can find it.
|
|
ghostty_path: {
|
|
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| {
|
|
log.warn("failed to get ghostty exe path err={}", .{err});
|
|
break :ghostty_path;
|
|
};
|
|
const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path;
|
|
log.debug("appending ghostty bin to path dir={s}", .{exe_dir});
|
|
|
|
// We always set this so that if the shell overwrites the path
|
|
// scripts still have a way to find the Ghostty binary when
|
|
// running in Ghostty.
|
|
try env.put("GHOSTTY_BIN_DIR", exe_dir);
|
|
|
|
// Append if we have a path. We want to append so that ghostty is
|
|
// the last priority in the path. If we don't have a path set
|
|
// then we just set it to the directory of the binary.
|
|
if (env.get("PATH")) |path| {
|
|
// Verify that our path doesn't already contain this entry
|
|
var it = std.mem.tokenizeScalar(u8, path, std.fs.path.delimiter);
|
|
while (it.next()) |entry| {
|
|
if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path;
|
|
}
|
|
|
|
try env.put(
|
|
"PATH",
|
|
try internal_os.appendEnv(alloc, path, exe_dir),
|
|
);
|
|
} else {
|
|
try env.put("PATH", exe_dir);
|
|
}
|
|
}
|
|
|
|
// On macOS, export additional data directories from our
|
|
// application bundle.
|
|
if (comptime builtin.target.isDarwin()) darwin: {
|
|
const resources_dir = cfg.resources_dir orelse break :darwin;
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
|
|
const xdg_data_dir_key = "XDG_DATA_DIRS";
|
|
if (std.fmt.bufPrint(&buf, "{s}/..", .{resources_dir})) |data_dir| {
|
|
try env.put(
|
|
xdg_data_dir_key,
|
|
try internal_os.appendEnv(
|
|
alloc,
|
|
env.get(xdg_data_dir_key) orelse "/usr/local/share:/usr/share",
|
|
data_dir,
|
|
),
|
|
);
|
|
} else |err| {
|
|
log.warn("error building {s}; err={}", .{ xdg_data_dir_key, err });
|
|
}
|
|
|
|
const manpath_key = "MANPATH";
|
|
if (std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir})) |man_dir| {
|
|
// Always append with colon in front, as it mean that if
|
|
// `MANPATH` is empty, then it should be treated as an extra
|
|
// path instead of overriding all paths set by OS.
|
|
try env.put(
|
|
manpath_key,
|
|
try internal_os.appendEnvAlways(
|
|
alloc,
|
|
env.get(manpath_key) orelse "",
|
|
man_dir,
|
|
),
|
|
);
|
|
} else |err| {
|
|
log.warn("error building {s}; man pages may not be available; err={}", .{ manpath_key, err });
|
|
}
|
|
}
|
|
|
|
// Set environment variables used by some programs (such as neovim) to detect
|
|
// which terminal emulator and version they're running under.
|
|
try env.put("TERM_PROGRAM", "ghostty");
|
|
try env.put("TERM_PROGRAM_VERSION", build_config.version_string);
|
|
|
|
// When embedding in macOS and running via XCode, XCode injects
|
|
// a bunch of things that break our shell process. We remove those.
|
|
if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) {
|
|
if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) {
|
|
env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS");
|
|
env.remove("__XPC_DYLD_LIBRARY_PATH");
|
|
env.remove("DYLD_FRAMEWORK_PATH");
|
|
env.remove("DYLD_INSERT_LIBRARIES");
|
|
env.remove("DYLD_LIBRARY_PATH");
|
|
env.remove("LD_LIBRARY_PATH");
|
|
env.remove("SECURITYSESSIONID");
|
|
env.remove("XPC_SERVICE_NAME");
|
|
}
|
|
|
|
// Remove this so that running `ghostty` within Ghostty works.
|
|
env.remove("GHOSTTY_MAC_APP");
|
|
}
|
|
|
|
// VTE_VERSION is set by gnome-terminal and other VTE-based terminals.
|
|
// We don't want our child processes to think we're running under VTE.
|
|
env.remove("VTE_VERSION");
|
|
|
|
// Don't leak these GTK environment variables to child processes.
|
|
if (comptime build_config.app_runtime == .gtk) {
|
|
env.remove("GDK_DEBUG");
|
|
env.remove("GDK_DISABLE");
|
|
env.remove("GSK_RENDERER");
|
|
}
|
|
|
|
// Setup our shell integration, if we can.
|
|
const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: {
|
|
const default_shell_command = cfg.command orelse switch (builtin.os.tag) {
|
|
.windows => "cmd.exe",
|
|
else => "sh",
|
|
};
|
|
|
|
const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
|
|
.none => break :shell .{ null, default_shell_command },
|
|
.detect => null,
|
|
.bash => .bash,
|
|
.elvish => .elvish,
|
|
.fish => .fish,
|
|
.zsh => .zsh,
|
|
};
|
|
|
|
const dir = cfg.resources_dir orelse break :shell .{
|
|
null,
|
|
default_shell_command,
|
|
};
|
|
|
|
const integration = try shell_integration.setup(
|
|
alloc,
|
|
dir,
|
|
default_shell_command,
|
|
&env,
|
|
force,
|
|
cfg.shell_integration_features,
|
|
) orelse break :shell .{ null, default_shell_command };
|
|
|
|
break :shell .{ integration.shell, integration.command };
|
|
};
|
|
|
|
if (integrated_shell) |shell| {
|
|
log.info(
|
|
"shell integration automatically injected shell={}",
|
|
.{shell},
|
|
);
|
|
} else if (cfg.shell_integration != .none) {
|
|
log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
|
|
}
|
|
|
|
// Build our args list
|
|
const args = args: {
|
|
const cap = 9; // the most we'll ever use
|
|
var args = try std.ArrayList([]const u8).initCapacity(alloc, cap);
|
|
defer args.deinit();
|
|
|
|
// If we're on macOS, we have to use `login(1)` to get all of
|
|
// the proper environment variables set, a login shell, and proper
|
|
// hushlogin behavior.
|
|
if (comptime builtin.target.isDarwin()) darwin: {
|
|
const passwd = internal_os.passwd.get(alloc) catch |err| {
|
|
log.warn("failed to read passwd, not using a login shell err={}", .{err});
|
|
break :darwin;
|
|
};
|
|
|
|
const username = passwd.name orelse {
|
|
log.warn("failed to get username, not using a login shell", .{});
|
|
break :darwin;
|
|
};
|
|
|
|
const hush = if (passwd.home) |home| hush: {
|
|
var dir = std.fs.openDirAbsolute(home, .{}) catch |err| {
|
|
log.warn(
|
|
"failed to open home dir, not checking for hushlogin err={}",
|
|
.{err},
|
|
);
|
|
break :hush false;
|
|
};
|
|
defer dir.close();
|
|
|
|
break :hush if (dir.access(".hushlogin", .{})) true else |_| false;
|
|
} else false;
|
|
|
|
const cmd = try std.fmt.allocPrint(
|
|
alloc,
|
|
"exec -l {s}",
|
|
.{shell_command},
|
|
);
|
|
|
|
// The reason for executing login this way is unclear. This
|
|
// comment will attempt to explain but prepare for a truly
|
|
// unhinged reality.
|
|
//
|
|
// The first major issue is that on macOS, a lot of users
|
|
// put shell configurations in ~/.bash_profile instead of
|
|
// ~/.bashrc (or equivalent for another shell). This file is only
|
|
// loaded for a login shell so macOS users expect all their terminals
|
|
// to be login shells. No other platform behaves this way and its
|
|
// totally braindead but somehow the entire dev community on
|
|
// macOS has cargo culted their way to this reality so we have to
|
|
// do it...
|
|
//
|
|
// To get a login shell, you COULD just prepend argv0 with a `-`
|
|
// but that doesn't fully work because `getlogin()` C API will
|
|
// return the wrong value, SHELL won't be set, and various
|
|
// other login behaviors that macOS users expect.
|
|
//
|
|
// The proper way is to use `login(1)`. But login(1) forces
|
|
// the working directory to change to the home directory,
|
|
// which we may not want. If we specify "-l" then we can avoid
|
|
// this behavior but now the shell isn't a login shell.
|
|
//
|
|
// There is another issue: `login(1)` only checks for ".hushlogin"
|
|
// in the working directory. This means that if we specify "-l"
|
|
// then we won't get hushlogin honored if its in the home
|
|
// directory (which is standard). To get around this, we
|
|
// check for hushlogin ourselves and if present specify the
|
|
// "-q" flag to login(1).
|
|
//
|
|
// So to get all the behaviors we want, we specify "-l" but
|
|
// execute "bash" (which is built-in to macOS). We then use
|
|
// the bash builtin "exec" to replace the process with a login
|
|
// shell ("-l" on exec) with the command we really want.
|
|
//
|
|
// We use "bash" instead of other shells that ship with macOS
|
|
// because as of macOS Sonoma, we found with a microbenchmark
|
|
// that bash can `exec` into the desired command ~2x faster
|
|
// than zsh.
|
|
//
|
|
// To figure out a lot of this logic I read the login.c
|
|
// source code in the OSS distribution Apple provides for
|
|
// macOS.
|
|
//
|
|
// Awesome.
|
|
try args.append("/usr/bin/login");
|
|
if (hush) try args.append("-q");
|
|
try args.append("-flp");
|
|
|
|
// We execute bash with "--noprofile --norc" so that it doesn't
|
|
// load startup files so that (1) our shell integration doesn't
|
|
// break and (2) user configuration doesn't mess this process
|
|
// up.
|
|
try args.append(username);
|
|
try args.append("/bin/bash");
|
|
try args.append("--noprofile");
|
|
try args.append("--norc");
|
|
try args.append("-c");
|
|
try args.append(cmd);
|
|
break :args try args.toOwnedSlice();
|
|
}
|
|
|
|
if (comptime builtin.os.tag == .windows) {
|
|
// We run our shell wrapped in `cmd.exe` so that we don't have
|
|
// to parse the command line ourselves if it has arguments.
|
|
|
|
// Note we don't free any of the memory below since it is
|
|
// allocated in the arena.
|
|
const windir = try std.process.getEnvVarOwned(alloc, "WINDIR");
|
|
const cmd = try std.fs.path.join(alloc, &[_][]const u8{
|
|
windir,
|
|
"System32",
|
|
"cmd.exe",
|
|
});
|
|
|
|
try args.append(cmd);
|
|
try args.append("/C");
|
|
} else {
|
|
// We run our shell wrapped in `/bin/sh` so that we don't have
|
|
// to parse the command line ourselves if it has arguments.
|
|
// Additionally, some environments (NixOS, I found) use /bin/sh
|
|
// to setup some environment variables that are important to
|
|
// have set.
|
|
try args.append("/bin/sh");
|
|
if (internal_os.isFlatpak()) try args.append("-l");
|
|
try args.append("-c");
|
|
}
|
|
|
|
try args.append(shell_command);
|
|
break :args try args.toOwnedSlice();
|
|
};
|
|
|
|
// We have to copy the cwd because there is no guarantee that
|
|
// pointers in full_config remain valid.
|
|
const cwd: ?[]u8 = if (cfg.working_directory) |cwd|
|
|
try alloc.dupe(u8, cwd)
|
|
else
|
|
null;
|
|
|
|
// If we have a cgroup, then we copy that into our arena so the
|
|
// memory remains valid when we start.
|
|
const linux_cgroup: Command.LinuxCgroup = cgroup: {
|
|
const default = Command.linux_cgroup_default;
|
|
if (comptime builtin.os.tag != .linux) break :cgroup default;
|
|
const path = cfg.linux_cgroup orelse break :cgroup default;
|
|
break :cgroup try alloc.dupe(u8, path);
|
|
};
|
|
|
|
return .{
|
|
.arena = arena,
|
|
.env = env,
|
|
.cwd = cwd,
|
|
.args = args,
|
|
.linux_cgroup = linux_cgroup,
|
|
|
|
// Should be initialized with initTerminal call.
|
|
.grid_size = .{},
|
|
.screen_size = .{ .width = 1, .height = 1 },
|
|
};
|
|
}
|
|
|
|
/// Clean up the subprocess. This will stop the subprocess if it is started.
|
|
pub fn deinit(self: *Subprocess) void {
|
|
self.stop();
|
|
if (self.pty) |*pty| pty.deinit();
|
|
self.arena.deinit();
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Start the subprocess. If the subprocess is already started this
|
|
/// will crash.
|
|
pub fn start(self: *Subprocess, alloc: Allocator) !struct {
|
|
read: Pty.Fd,
|
|
write: Pty.Fd,
|
|
} {
|
|
assert(self.pty == null and self.command == null);
|
|
|
|
// Create our pty
|
|
var pty = try Pty.open(.{
|
|
.ws_row = @intCast(self.grid_size.rows),
|
|
.ws_col = @intCast(self.grid_size.columns),
|
|
.ws_xpixel = @intCast(self.screen_size.width),
|
|
.ws_ypixel = @intCast(self.screen_size.height),
|
|
});
|
|
self.pty = pty;
|
|
errdefer {
|
|
pty.deinit();
|
|
self.pty = null;
|
|
}
|
|
|
|
log.debug("starting command command={s}", .{self.args});
|
|
|
|
// In flatpak, we use the HostCommand to execute our shell.
|
|
if (internal_os.isFlatpak()) flatpak: {
|
|
if (comptime !build_config.flatpak) {
|
|
log.warn("flatpak detected, but flatpak support not built-in", .{});
|
|
break :flatpak;
|
|
}
|
|
|
|
// Flatpak command must have a stable pointer.
|
|
self.flatpak_command = .{
|
|
.argv = self.args,
|
|
.env = &self.env,
|
|
.stdin = pty.slave,
|
|
.stdout = pty.slave,
|
|
.stderr = pty.slave,
|
|
};
|
|
var cmd = &self.flatpak_command.?;
|
|
const pid = try cmd.spawn(alloc);
|
|
errdefer killCommandFlatpak(cmd);
|
|
|
|
log.info("started subcommand on host via flatpak API path={s} pid={?}", .{
|
|
self.args[0],
|
|
pid,
|
|
});
|
|
|
|
// Once started, we can close the pty child side. We do this after
|
|
// wait right now but that is fine too. This lets us read the
|
|
// parent and detect EOF.
|
|
_ = posix.close(pty.slave);
|
|
|
|
return .{
|
|
.read = pty.master,
|
|
.write = pty.master,
|
|
};
|
|
}
|
|
|
|
// If we can't access the cwd, then don't set any cwd and inherit.
|
|
// This is important because our cwd can be set by the shell (OSC 7)
|
|
// and we don't want to break new windows.
|
|
const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: {
|
|
if (std.fs.cwd().access(proposed, .{})) {
|
|
break :cwd proposed;
|
|
} else |err| {
|
|
log.warn("cannot access cwd, ignoring: {}", .{err});
|
|
break :cwd null;
|
|
}
|
|
} else null;
|
|
|
|
// Build our subcommand
|
|
var cmd: Command = .{
|
|
.path = self.args[0],
|
|
.args = self.args,
|
|
.env = &self.env,
|
|
.cwd = cwd,
|
|
.stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
|
|
.stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
|
|
.stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
|
|
.pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {},
|
|
.pre_exec = if (builtin.os.tag == .windows) null else (struct {
|
|
fn callback(cmd: *Command) void {
|
|
const sp = cmd.getData(Subprocess) orelse unreachable;
|
|
sp.childPreExec() catch |err| log.err(
|
|
"error initializing child: {}",
|
|
.{err},
|
|
);
|
|
}
|
|
}).callback,
|
|
.data = self,
|
|
.linux_cgroup = self.linux_cgroup,
|
|
};
|
|
try cmd.start(alloc);
|
|
errdefer killCommand(&cmd) catch |err| {
|
|
log.warn("error killing command during cleanup err={}", .{err});
|
|
};
|
|
log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid });
|
|
if (comptime builtin.os.tag == .linux) {
|
|
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
|
|
}
|
|
|
|
self.command = cmd;
|
|
return switch (builtin.os.tag) {
|
|
.windows => .{
|
|
.read = pty.out_pipe,
|
|
.write = pty.in_pipe,
|
|
},
|
|
|
|
else => .{
|
|
.read = pty.master,
|
|
.write = pty.master,
|
|
},
|
|
};
|
|
}
|
|
|
|
/// This should be called after fork but before exec in the child process.
|
|
/// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before
|
|
/// exec is called; it does NOT run in the main Ghostty process.
|
|
fn childPreExec(self: *Subprocess) !void {
|
|
// Setup our pty
|
|
try self.pty.?.childPreExec();
|
|
}
|
|
|
|
/// Called to notify that we exited externally so we can unset our
|
|
/// running state.
|
|
pub fn externalExit(self: *Subprocess) void {
|
|
self.command = null;
|
|
}
|
|
|
|
/// Stop the subprocess. This is safe to call anytime. This will wait
|
|
/// for the subprocess to register that it has been signalled, but not
|
|
/// for it to terminate, so it will not block.
|
|
/// This does not close the pty.
|
|
pub fn stop(self: *Subprocess) void {
|
|
// Kill our command
|
|
if (self.command) |*cmd| {
|
|
// Note: this will also wait for the command to exit, so
|
|
// DO NOT call cmd.wait
|
|
killCommand(cmd) catch |err|
|
|
log.err("error sending SIGHUP to command, may hang: {}", .{err});
|
|
self.command = null;
|
|
}
|
|
|
|
// Kill our Flatpak command
|
|
if (FlatpakHostCommand != void) {
|
|
if (self.flatpak_command) |*cmd| {
|
|
killCommandFlatpak(cmd) catch |err|
|
|
log.err("error sending SIGHUP to command, may hang: {}", .{err});
|
|
_ = cmd.wait() catch |err|
|
|
log.err("error waiting for command to exit: {}", .{err});
|
|
self.flatpak_command = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resize the pty subprocess. This is safe to call anytime.
|
|
pub fn resize(
|
|
self: *Subprocess,
|
|
grid_size: renderer.GridSize,
|
|
screen_size: renderer.ScreenSize,
|
|
) !void {
|
|
self.grid_size = grid_size;
|
|
self.screen_size = screen_size;
|
|
|
|
if (self.pty) |*pty| {
|
|
// It is theoretically possible for the grid or screen size to
|
|
// exceed u16, although the terminal in that case isn't very
|
|
// usable. This should be protected upstream but we still clamp
|
|
// in case there is a bad caller which has happened before.
|
|
try pty.setSize(.{
|
|
.ws_row = std.math.cast(u16, grid_size.rows) orelse std.math.maxInt(u16),
|
|
.ws_col = std.math.cast(u16, grid_size.columns) orelse std.math.maxInt(u16),
|
|
.ws_xpixel = std.math.cast(u16, screen_size.width) orelse std.math.maxInt(u16),
|
|
.ws_ypixel = std.math.cast(u16, screen_size.height) orelse std.math.maxInt(u16),
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Kill the underlying subprocess. This sends a SIGHUP to the child
|
|
/// process. This also waits for the command to exit and will return the
|
|
/// exit code.
|
|
fn killCommand(command: *Command) !void {
|
|
if (command.pid) |pid| {
|
|
switch (builtin.os.tag) {
|
|
.windows => {
|
|
if (windows.kernel32.TerminateProcess(pid, 0) == 0) {
|
|
return windows.unexpectedError(windows.kernel32.GetLastError());
|
|
}
|
|
|
|
_ = try command.wait(false);
|
|
},
|
|
|
|
else => if (getpgid(pid)) |pgid| {
|
|
// It is possible to send a killpg between the time that
|
|
// our child process calls setsid but before or simultaneous
|
|
// to calling execve. In this case, the direct child dies
|
|
// but grandchildren survive. To work around this, we loop
|
|
// and repeatedly kill the process group until all
|
|
// descendents are well and truly dead. We will not rest
|
|
// until the entire family tree is obliterated.
|
|
while (true) {
|
|
switch (posix.errno(c.killpg(pgid, c.SIGHUP))) {
|
|
.SUCCESS => log.debug("process group killed pgid={}", .{pgid}),
|
|
else => |err| killpg: {
|
|
if ((comptime builtin.target.isDarwin()) and
|
|
err == .PERM)
|
|
{
|
|
log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{});
|
|
break :killpg;
|
|
}
|
|
|
|
log.warn("error killing process group pgid={} err={}", .{ pgid, err });
|
|
return error.KillFailed;
|
|
},
|
|
}
|
|
|
|
// See Command.zig wait for why we specify WNOHANG.
|
|
// The gist is that it lets us detect when children
|
|
// are still alive without blocking so that we can
|
|
// kill them again.
|
|
const res = posix.waitpid(pid, std.c.W.NOHANG);
|
|
log.debug("waitpid result={}", .{res.pid});
|
|
if (res.pid != 0) break;
|
|
std.time.sleep(10 * std.time.ns_per_ms);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn getpgid(pid: c.pid_t) ?c.pid_t {
|
|
// Get our process group ID. Before the child pid calls setsid
|
|
// the pgid will be ours because we forked it. Its possible that
|
|
// we may be calling this before setsid if we are killing a surface
|
|
// VERY quickly after starting it.
|
|
const my_pgid = c.getpgid(0);
|
|
|
|
// We loop while pgid == my_pgid. The expectation if we have a valid
|
|
// pid is that setsid will eventually be called because it is the
|
|
// FIRST thing the child process does and as far as I can tell,
|
|
// setsid cannot fail. I'm sure that's not true, but I'd rather
|
|
// have a bug reported than defensively program against it now.
|
|
while (true) {
|
|
const pgid = c.getpgid(pid);
|
|
if (pgid == my_pgid) {
|
|
log.warn("pgid is our own, retrying", .{});
|
|
std.time.sleep(10 * std.time.ns_per_ms);
|
|
continue;
|
|
}
|
|
|
|
// Don't know why it would be zero but its not a valid pid
|
|
if (pgid == 0) return null;
|
|
|
|
// If the pid doesn't exist then... we're done!
|
|
if (pgid == c.ESRCH) return null;
|
|
|
|
// If we have an error we're done.
|
|
if (pgid < 0) {
|
|
log.warn("error getting pgid for kill", .{});
|
|
return null;
|
|
}
|
|
|
|
return pgid;
|
|
}
|
|
}
|
|
|
|
/// Kill the underlying process started via Flatpak host command.
|
|
/// This sends a signal via the Flatpak API.
|
|
fn killCommandFlatpak(command: *FlatpakHostCommand) !void {
|
|
try command.signal(c.SIGHUP, true);
|
|
}
|
|
};
|
|
|
|
/// The read thread sits in a loop doing the following pseudo code:
|
|
///
|
|
/// while (true) { blocking_read(); exit_if_eof(); process(); }
|
|
///
|
|
/// Almost all terminal-modifying activity is from the pty read, so
|
|
/// putting this on a dedicated thread keeps performance very predictable
|
|
/// while also almost optimal. "Locking is fast, lock contention is slow."
|
|
/// and since we rarely have contention, this is fast.
|
|
///
|
|
/// This is also empirically fast compared to putting the read into
|
|
/// an async mechanism like io_uring/epoll because the reads are generally
|
|
/// small.
|
|
///
|
|
/// We use a basic poll syscall here because we are only monitoring two
|
|
/// fds and this is still much faster and lower overhead than any async
|
|
/// mechanism.
|
|
pub const ReadThread = struct {
|
|
fn threadMainPosix(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void {
|
|
// Always close our end of the pipe when we exit.
|
|
defer posix.close(quit);
|
|
|
|
// Setup our crash metadata
|
|
crash.sentry.thread_state = .{
|
|
.type = .io,
|
|
.surface = io.surface_mailbox.surface,
|
|
};
|
|
defer crash.sentry.thread_state = null;
|
|
|
|
// First thing, we want to set the fd to non-blocking. We do this
|
|
// so that we can try to read from the fd in a tight loop and only
|
|
// check the quit fd occasionally.
|
|
if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| {
|
|
_ = posix.fcntl(
|
|
fd,
|
|
posix.F.SETFL,
|
|
flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })),
|
|
) catch |err| {
|
|
log.warn("read thread failed to set flags err={}", .{err});
|
|
log.warn("this isn't a fatal error, but may cause performance issues", .{});
|
|
};
|
|
} else |err| {
|
|
log.warn("read thread failed to get flags err={}", .{err});
|
|
log.warn("this isn't a fatal error, but may cause performance issues", .{});
|
|
}
|
|
|
|
// Build up the list of fds we're going to poll. We are looking
|
|
// for data on the pty and our quit notification.
|
|
var pollfds: [2]posix.pollfd = .{
|
|
.{ .fd = fd, .events = posix.POLL.IN, .revents = undefined },
|
|
.{ .fd = quit, .events = posix.POLL.IN, .revents = undefined },
|
|
};
|
|
|
|
var buf: [1024]u8 = undefined;
|
|
while (true) {
|
|
// We try to read from the file descriptor as long as possible
|
|
// to maximize performance. We only check the quit fd if the
|
|
// main fd blocks. This optimizes for the realistic scenario that
|
|
// the data will eventually stop while we're trying to quit. This
|
|
// is always true because we kill the process.
|
|
while (true) {
|
|
const n = posix.read(fd, &buf) catch |err| {
|
|
switch (err) {
|
|
// This means our pty is closed. We're probably
|
|
// gracefully shutting down.
|
|
error.NotOpenForReading,
|
|
error.InputOutput,
|
|
=> {
|
|
log.info("io reader exiting", .{});
|
|
return;
|
|
},
|
|
|
|
// No more data, fall back to poll and check for
|
|
// exit conditions.
|
|
error.WouldBlock => break,
|
|
|
|
else => {
|
|
log.err("io reader error err={}", .{err});
|
|
unreachable;
|
|
},
|
|
}
|
|
};
|
|
|
|
// This happens on macOS instead of WouldBlock when the
|
|
// child process dies. To be safe, we just break the loop
|
|
// and let our poll happen.
|
|
if (n == 0) break;
|
|
|
|
// log.info("DATA: {d}", .{n});
|
|
@call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] });
|
|
}
|
|
|
|
// Wait for data.
|
|
_ = posix.poll(&pollfds, -1) catch |err| {
|
|
log.warn("poll failed on read thread, exiting early err={}", .{err});
|
|
return;
|
|
};
|
|
|
|
// If our quit fd is set, we're done.
|
|
if (pollfds[1].revents & posix.POLL.IN != 0) {
|
|
log.info("read thread got quit signal", .{});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn threadMainWindows(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void {
|
|
// Always close our end of the pipe when we exit.
|
|
defer posix.close(quit);
|
|
|
|
// Setup our crash metadata
|
|
crash.sentry.thread_state = .{
|
|
.type = .io,
|
|
.surface = io.surface_mailbox.surface,
|
|
};
|
|
defer crash.sentry.thread_state = null;
|
|
|
|
var buf: [1024]u8 = undefined;
|
|
while (true) {
|
|
while (true) {
|
|
var n: windows.DWORD = 0;
|
|
if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) {
|
|
const err = windows.kernel32.GetLastError();
|
|
switch (err) {
|
|
// Check for a quit signal
|
|
.OPERATION_ABORTED => break,
|
|
|
|
else => {
|
|
log.err("io reader error err={}", .{err});
|
|
unreachable;
|
|
},
|
|
}
|
|
}
|
|
|
|
@call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] });
|
|
}
|
|
|
|
var quit_bytes: windows.DWORD = 0;
|
|
if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) {
|
|
const err = windows.kernel32.GetLastError();
|
|
log.err("quit pipe reader error err={}", .{err});
|
|
unreachable;
|
|
}
|
|
|
|
if (quit_bytes > 0) {
|
|
log.info("read thread got quit signal", .{});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|