aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/termio/Exec.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/termio/Exec.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/termio/Exec.zig')
-rw-r--r--vendor/libghostty-vt/src/termio/Exec.zig1761
1 files changed, 1761 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig
new file mode 100644
index 00000000..0f35b578
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/Exec.zig
@@ -0,0 +1,1761 @@
+//! 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 = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const posix = std.posix;
+const xev = @import("../global.zig").xev;
+const apprt = @import("../apprt.zig");
+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 PasswdEntry = internal_os.passwd.Entry;
+const windows = internal_os.windows;
+const ProcessInfo = @import("../pty.zig").ProcessInfo;
+
+const log = std.log.scoped(.io_exec);
+
+/// The termios poll rate in milliseconds.
+const TERMIOS_POLL_MS = 200;
+
+/// 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) struct {
+ pub const Completion = struct {};
+} else internal_os.FlatpakHostCommand;
+
+/// 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. If we DIDN'T
+ // get this specific error then we're in the parent and
+ // we need to bubble it up.
+ if (err != error.ExecFailedInChild) return err;
+
+ // We're in the child. Nothing more we can do but abnormal exit.
+ // The Command will output some additional information.
+ posix.exit(1);
+ };
+ errdefer self.subprocess.stop();
+
+ // Watcher to detect subprocess exit
+ var process: ?xev.Process = if (self.subprocess.process) |v| switch (v) {
+ .fork_exec => |cmd| try xev.Process.init(
+ cmd.pid orelse return error.ProcessNoPid,
+ ),
+
+ // If we're executing via Flatpak then we can't do
+ // traditional process watching (its implemented
+ // as a special case in os/flatpak.zig) since the
+ // command is on the host.
+ .flatpak => null,
+ } else return error.ProcessNotStarted;
+ errdefer if (process) |*p| p.deinit();
+
+ // 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();
+
+ // 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,
+ .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. If we have an xev.Process use it.
+ if (process) |*p| p.wait(
+ td.loop,
+ &td.backend.exec.process_wait_c,
+ termio.Termio.ThreadData,
+ td,
+ processExit,
+ ) else if (comptime build_config.flatpak) flatpak: {
+ switch (self.subprocess.process orelse break :flatpak) {
+ // If we're in flatpak and we have a flatpak command
+ // then we can run the special flatpak logic for watching.
+ .flatpak => |*c| c.waitXev(
+ td.loop,
+ &td.backend.exec.flatpak_wait_c,
+ termio.Termio.ThreadData,
+ td,
+ flatpakExit,
+ ),
+
+ .fork_exec => {},
+ }
+ }
+
+ // 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| switch (err) {
+ // BrokenPipe means that our read thread is closed already,
+ // which is completely fine since that is what we were trying
+ // to achieve.
+ error.BrokenPipe => {},
+
+ else => 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);
+}
+
+fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void {
+ 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 },
+ );
+
+ // We always notify the surface immediately that the child has
+ // exited and some metadata about the exit.
+ _ = td.surface_mailbox.push(.{
+ .child_exited = .{
+ .exit_code = exit_code,
+ .runtime_ms = runtime_ms orelse 0,
+ },
+ }, .{ .forever = {} });
+}
+
+fn processExit(
+ td_: ?*termio.Termio.ThreadData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Process.WaitError!u32,
+) xev.CallbackAction {
+ const exit_code = r catch unreachable;
+ processExitCommon(td_.?, exit_code);
+ return .disarm;
+}
+
+fn flatpakExit(
+ td_: ?*termio.Termio.ThreadData,
+ _: *xev.Loop,
+ _: *FlatpakHostCommand.Completion,
+ r: FlatpakHostCommand.WaitError!u8,
+) void {
+ const exit_code = r catch unreachable;
+ processExitCommon(td_.?, exit_code);
+}
+
+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 don't send any more writes.
+ if (exec.exited) 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.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 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.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.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 = .{},
+
+ // The completion specific to Flatpak process waiting. If
+ // we aren't compiling with Flatpak support this is zero-sized.
+ flatpak_wait_c: FlatpakHostCommand.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
+ if (self.process) |*p| p.deinit();
+
+ // Stop our write stream
+ self.write_stream.deinit();
+
+ // Stop our termios timer
+ self.termios_timer.deinit();
+ }
+};
+
+pub const Config = struct {
+ command: ?configpkg.Command = null,
+ env: EnvMap,
+ env_override: configpkg.RepeatableStringMap = .{},
+ shell_integration: configpkg.Config.ShellIntegration = .detect,
+ shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{},
+ cursor_blink: ?bool = null,
+ working_directory: ?[]const u8 = null,
+ resources_dir: ?[]const u8,
+ term: []const u8,
+
+ rt_pre_exec_info: Command.RtPreExecInfo,
+ rt_post_fork_info: Command.RtPostForkInfo,
+};
+
+const Subprocess = struct {
+ const c = @cImport({
+ @cInclude("errno.h");
+ @cInclude("signal.h");
+ @cInclude("unistd.h");
+ });
+
+ arena: std.heap.ArenaAllocator,
+ cwd: ?[:0]const u8,
+ env: ?EnvMap,
+ args: []const [:0]const u8,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+ pty: ?Pty = null,
+ process: ?Process = null,
+
+ rt_pre_exec_info: Command.RtPreExecInfo,
+ rt_post_fork_info: Command.RtPostForkInfo,
+
+ /// Union that represents the running process type.
+ const Process = union(enum) {
+ /// Standard POSIX fork/exec
+ fork_exec: Command,
+
+ /// Flatpak DBus command
+ flatpak: FlatpakHostCommand,
+ };
+
+ const ArgsFormatter = struct {
+ args: []const [:0]const u8,
+
+ pub fn format(this: @This(), writer: *std.Io.Writer) std.Io.Writer.Error!void {
+ for (this.args, 0..) |a, i| {
+ if (i > 0) try writer.writeAll(", ");
+ try writer.print("`{s}`", .{a});
+ }
+ }
+ };
+
+ /// 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();
+
+ // Get our env. If a default env isn't provided by the caller
+ // then we get it ourselves.
+ var env = cfg.env;
+
+ // 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.os.tag.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: {
+ // Skip this for flatpak since host cannot reach them
+ if ((comptime build_config.flatpak) and
+ internal_os.isFlatpak())
+ {
+ break :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.os.tag.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);
+
+ // 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.
+ // This is not apprt-specific, so we do it here.
+ env.remove("VTE_VERSION");
+
+ // Setup our shell integration, if we can.
+ const shell_command: configpkg.Command = shell: {
+ const default_shell_command: configpkg.Command =
+ cfg.command orelse .{ .shell = switch (builtin.os.tag) {
+ .windows => "cmd.exe",
+ else => "sh",
+ } };
+
+ // Always set up shell features (GHOSTTY_SHELL_FEATURES). These are
+ // used by both automatic and manual shell integrations.
+ try shell_integration.setupFeatures(
+ &env,
+ cfg.shell_integration_features,
+ cfg.cursor_blink orelse true,
+ );
+
+ const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
+ .none => {
+ // This is a source of confusion for users despite being
+ // opt-in since it results in some Ghostty features not
+ // working. We always want to log it.
+ log.info("shell integration disabled by configuration", .{});
+ break :shell default_shell_command;
+ },
+
+ .detect => null,
+ .bash => .bash,
+ .elvish => .elvish,
+ .fish => .fish,
+ .nushell => .nushell,
+ .zsh => .zsh,
+ };
+
+ const dir = cfg.resources_dir orelse {
+ log.warn("no resources dir set, shell integration disabled", .{});
+ break :shell default_shell_command;
+ };
+
+ const integration = try shell_integration.setup(
+ alloc,
+ dir,
+ default_shell_command,
+ &env,
+ force,
+ ) orelse {
+ log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
+ break :shell default_shell_command;
+ };
+
+ log.info(
+ "shell integration automatically injected shell={}",
+ .{integration.shell},
+ );
+
+ break :shell integration.command;
+ };
+
+ // Add the environment variables that override any others.
+ {
+ var it = cfg.env_override.iterator();
+ while (it.next()) |entry| try env.put(
+ entry.key_ptr.*,
+ entry.value_ptr.*,
+ );
+ }
+
+ // Build our args list
+ const args: []const [:0]const u8 = execCommand(
+ alloc,
+ shell_command,
+ internal_os.passwd,
+ ) catch |err| switch (err) {
+ // If we fail to allocate space for the command we want to
+ // execute, we'd still like to try to run something so
+ // Ghostty can launch (and maybe the user can debug this further).
+ // Realistically, if you're getting OOM, I think other stuff is
+ // about to crash, but we can try.
+ error.OutOfMemory => oom: {
+ log.warn("failed to allocate space for command args, falling back to basic shell", .{});
+
+ // The comptime here is important to ensure the full slice
+ // is put into the binary data and not the stack.
+ break :oom comptime switch (builtin.os.tag) {
+ .windows => &.{"cmd.exe"},
+ else => &.{"/bin/sh"},
+ };
+ },
+
+ // This logs on its own, this is a bad error.
+ error.SystemError => return err,
+ };
+
+ // We have to copy the cwd because there is no guarantee that
+ // pointers in full_config remain valid.
+ const cwd: ?[:0]u8 = if (cfg.working_directory) |cwd|
+ try alloc.dupeZ(u8, cwd)
+ else
+ null;
+
+ // Propagate the current working directory (CWD) to the shell, enabling
+ // the shell to display the current directory name rather than the
+ // resolved path for symbolic links. This is important and based
+ // on the same behavior in Konsole and Kitty (see the linked issues):
+ // https://bugs.kde.org/show_bug.cgi?id=242114
+ // https://github.com/kovidgoyal/kitty/issues/1595
+ // https://github.com/ghostty-org/ghostty/discussions/7769
+ if (cwd) |pwd| try env.put("PWD", pwd);
+
+ return .{
+ .arena = arena,
+ .env = env,
+ .cwd = cwd,
+ .args = args,
+
+ .rt_pre_exec_info = cfg.rt_pre_exec_info,
+ .rt_post_fork_info = cfg.rt_post_fork_info,
+
+ // 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();
+ if (self.env) |*env| env.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.process == null);
+
+ // This function is funny because on POSIX systems it can
+ // fail in the forked process. This is flipped to true if
+ // we're in an error state in the forked process (child
+ // process).
+ var in_child: bool = false;
+
+ // 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 if (!in_child) {
+ if (comptime builtin.os.tag != .windows) {
+ _ = posix.close(pty.slave);
+ }
+
+ pty.deinit();
+ self.pty = null;
+ };
+
+ // Cleanup we only run in our parent when we successfully start
+ // the process.
+ defer if (!in_child and self.process != null) {
+ if (comptime builtin.os.tag != .windows) {
+ // Once our subcommand is started we can close the slave
+ // side. This prevents the slave fd from being leaked to
+ // future children.
+ _ = posix.close(pty.slave);
+ }
+
+ // Successful start we can clear out some memory.
+ if (self.env) |*env| {
+ env.deinit();
+ self.env = null;
+ }
+ };
+
+ log.debug("starting command command={f}", .{ArgsFormatter{ .args = self.args }});
+
+ // 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: ?[:0]const u8 = if (self.cwd) |proposed| cwd: {
+ if ((comptime build_config.flatpak) and internal_os.isFlatpak()) {
+ // Flatpak sandboxing prevents access to certain reserved paths
+ // regardless of configured permissions. Perform a test spawn
+ // to get around this problem
+ //
+ // https://docs.flatpak.org/en/latest/sandbox-permissions.html#reserved-paths
+ log.info("flatpak detected, will use host command to verify cwd access", .{});
+ const dev_null = try std.fs.cwd().openFile("/dev/null", .{ .mode = .read_write });
+ defer dev_null.close();
+ var cmd: internal_os.FlatpakHostCommand = .{
+ .argv = &[_][]const u8{
+ "/bin/sh",
+ "-c",
+ ":",
+ },
+ .cwd = proposed,
+ .stdin = dev_null.handle,
+ .stdout = dev_null.handle,
+ .stderr = dev_null.handle,
+ };
+ _ = cmd.spawn(alloc) catch |err| {
+ log.warn("cannot spawn command at cwd, ignoring: {}", .{err});
+ break :cwd null;
+ };
+ _ = try cmd.wait();
+
+ break :cwd proposed;
+ }
+
+ if (std.fs.cwd().access(proposed, .{})) {
+ break :cwd proposed;
+ } else |err| {
+ log.warn("cannot access cwd, ignoring: {}", .{err});
+ break :cwd null;
+ }
+ } else null;
+
+ // 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.process = .{ .flatpak = .{
+ .argv = self.args,
+ .cwd = cwd,
+ .env = if (self.env) |*env| env else null,
+ .stdin = pty.slave,
+ .stdout = pty.slave,
+ .stderr = pty.slave,
+ } };
+ var cmd = &self.process.?.flatpak;
+ 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,
+ });
+
+ return .{
+ .read = pty.master,
+ .write = pty.master,
+ };
+ }
+
+ // Build our subcommand
+ var cmd: Command = .{
+ .path = self.args[0],
+ .args = self.args,
+ .env = if (self.env) |*env| env else null,
+ .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 {},
+ .os_pre_exec = switch (comptime builtin.os.tag) {
+ .windows => null,
+ else => f: {
+ const f = struct {
+ fn callback(cmd: *Command) ?u8 {
+ const sp = cmd.getData(Subprocess) orelse unreachable;
+ sp.childPreExec() catch |err| log.err(
+ "error initializing child: {}",
+ .{err},
+ );
+ return null;
+ }
+ };
+ break :f f.callback;
+ },
+ },
+ .rt_pre_exec = if (comptime @hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.preExec else null,
+ .rt_pre_exec_info = self.rt_pre_exec_info,
+ .rt_post_fork = if (comptime @hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.postFork else null,
+ .rt_post_fork_info = self.rt_post_fork_info,
+ .data = self,
+ };
+
+ cmd.start(alloc) catch |err| {
+ // We have to do this because start on Windows can't
+ // ever return ExecFailedInChild
+ const StartError = error{ExecFailedInChild} || @TypeOf(err);
+ switch (@as(StartError, err)) {
+ // If we fail in our child we need to flag it so our
+ // errdefers don't run.
+ error.ExecFailedInChild => {
+ in_child = true;
+ return err;
+ },
+
+ else => return err,
+ }
+ };
+ 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 });
+
+ self.process = .{ .fork_exec = 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.process = 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 {
+ switch (self.process orelse return) {
+ .fork_exec => |*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});
+ },
+
+ .flatpak => |*cmd| if (comptime build_config.flatpak) {
+ 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.process = 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 => try killPid(pid),
+ }
+ }
+ }
+
+ fn killPid(pid: c.pid_t) !void {
+ const pgid = getpgid(pid) orelse return;
+
+ // 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.os.tag.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.Thread.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.Thread.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);
+ }
+
+ /// Get information about the process(es) running within the subprocess.
+ /// Returns `null` if there was an error getting the information or the
+ /// information is not available on a particular platform.
+ pub fn getProcessInfo(self: *Subprocess, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
+ const pty = &(self.pty orelse return null);
+ return pty.getProcessInfo(info);
+ }
+};
+
+/// 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);
+
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"io-reader".*);
+ }
+
+ // 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;
+ }
+
+ // If our pty fd is closed, then we're also done with our
+ // read thread.
+ if (pollfds[0].revents & posix.POLL.HUP != 0) {
+ log.info("pty fd closed, read thread exiting", .{});
+ 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;
+ }
+ }
+ }
+};
+
+/// Builds the argv array for the process we should exec for the
+/// configured command. This isn't as straightforward as it seems since
+/// we deal with shell-wrapping, macOS login shells, etc.
+///
+/// The passwdpkg comptime argument is expected to have a single function
+/// `get(Allocator)` that returns a passwd entry. This is used by macOS
+/// to determine the username and home directory for the login shell.
+/// It is unused on other platforms.
+///
+/// Memory ownership:
+///
+/// The allocator should be an arena, since the returned value may or
+/// may not be allocated and args may or may not be allocated (or copied).
+/// Pointers in the return value may point to pointers in the command
+/// struct.
+fn execCommand(
+ alloc: Allocator,
+ command: configpkg.Command,
+ comptime passwdpkg: type,
+) (Allocator.Error || error{SystemError})![]const [:0]const u8 {
+ // 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.os.tag.isDarwin()) darwin: {
+ const passwd = passwdpkg.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;
+
+ // If we made it this far we're going to start building
+ // the actual command.
+ var args: std.ArrayList([:0]const u8) = try .initCapacity(
+ alloc,
+
+ // This capacity is chosen based on what we'd need to
+ // execute a shell command (very common). We can/will
+ // grow if necessary for a longer command (uncommon).
+ 9,
+ );
+ defer args.deinit(alloc);
+
+ // 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)` on macOS 14.3 and earlier
+ // checked 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(alloc, "/usr/bin/login");
+ if (hush) try args.append(alloc, "-q");
+ try args.append(alloc, "-flp");
+ try args.append(alloc, username);
+
+ switch (command) {
+ // Direct args can be passed directly to login, since
+ // login uses execvp we don't need to worry about PATH
+ // searching.
+ .direct => |v| try args.appendSlice(alloc, v),
+
+ .shell => |v| {
+ // Use "exec" to replace the bash process with
+ // our intended command so we don't have a parent
+ // process hanging around.
+ const cmd = try std.fmt.allocPrintSentinel(
+ alloc,
+ "exec -l {s}",
+ .{v},
+ 0,
+ );
+
+ // 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(alloc, "/bin/bash");
+ try args.append(alloc, "--noprofile");
+ try args.append(alloc, "--norc");
+ try args.append(alloc, "-c");
+ try args.append(alloc, cmd);
+ },
+ }
+
+ return try args.toOwnedSlice(alloc);
+ }
+
+ return switch (command) {
+ // We need to clone the command since there's no guarantee the config remains valid.
+ .direct => |_| (try command.clone(alloc)).direct,
+
+ .shell => |v| shell: {
+ var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 4);
+ defer args.deinit(alloc);
+
+ 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 = std.process.getEnvVarOwned(
+ alloc,
+ "WINDIR",
+ ) catch |err| {
+ log.warn("failed to get WINDIR, cannot run shell command err={}", .{err});
+ return error.SystemError;
+ };
+ const cmd = try std.fs.path.joinZ(alloc, &[_][]const u8{
+ windir,
+ "System32",
+ "cmd.exe",
+ });
+
+ try args.append(alloc, cmd);
+ try args.append(alloc, "/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(alloc, "/bin/sh");
+ if (internal_os.isFlatpak()) try args.append(alloc, "-l");
+ try args.append(alloc, "-c");
+ }
+
+ try args.append(alloc, v);
+ break :shell try args.toOwnedSlice(alloc);
+ },
+ };
+}
+
+/// Get information about the process(es) running within the backend. Returns
+/// `null` if there was an error getting the information or the information is
+/// not available on a particular platform.
+pub fn getProcessInfo(self: *Exec, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
+ return self.subprocess.getProcessInfo(info);
+}
+
+test "execCommand darwin: shell command" {
+ if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .shell = "foo bar baz" }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{
+ .name = "testuser",
+ };
+ }
+ });
+
+ try testing.expectEqual(8, result.len);
+ try testing.expectEqualStrings(result[0], "/usr/bin/login");
+ try testing.expectEqualStrings(result[1], "-flp");
+ try testing.expectEqualStrings(result[2], "testuser");
+ try testing.expectEqualStrings(result[3], "/bin/bash");
+ try testing.expectEqualStrings(result[4], "--noprofile");
+ try testing.expectEqualStrings(result[5], "--norc");
+ try testing.expectEqualStrings(result[6], "-c");
+ try testing.expectEqualStrings(result[7], "exec -l foo bar baz");
+}
+
+test "execCommand darwin: direct command" {
+ if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .direct = &.{
+ "foo",
+ "bar baz",
+ } }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{
+ .name = "testuser",
+ };
+ }
+ });
+
+ try testing.expectEqual(5, result.len);
+ try testing.expectEqualStrings(result[0], "/usr/bin/login");
+ try testing.expectEqualStrings(result[1], "-flp");
+ try testing.expectEqualStrings(result[2], "testuser");
+ try testing.expectEqualStrings(result[3], "foo");
+ try testing.expectEqualStrings(result[4], "bar baz");
+}
+
+test "execCommand: shell command, empty passwd" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(
+ alloc,
+ .{ .shell = "foo bar baz" },
+ struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Empty passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return .{};
+ }
+ },
+ );
+
+ try testing.expectEqual(3, result.len);
+ try testing.expectEqualStrings(result[0], "/bin/sh");
+ try testing.expectEqualStrings(result[1], "-c");
+ try testing.expectEqualStrings(result[2], "foo bar baz");
+}
+
+test "execCommand: shell command, error passwd" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(
+ alloc,
+ .{ .shell = "foo bar baz" },
+ struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Failed passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return error.Fail;
+ }
+ },
+ );
+
+ try testing.expectEqual(3, result.len);
+ try testing.expectEqualStrings(result[0], "/bin/sh");
+ try testing.expectEqualStrings(result[1], "-c");
+ try testing.expectEqualStrings(result[2], "foo bar baz");
+}
+
+test "execCommand: direct command, error passwd" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{
+ .direct = &.{
+ "foo",
+ "bar baz",
+ },
+ }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Failed passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return error.Fail;
+ }
+ });
+
+ try testing.expectEqual(2, result.len);
+ try testing.expectEqualStrings(result[0], "foo");
+ try testing.expectEqualStrings(result[1], "bar baz");
+}
+
+test "execCommand: direct command, config freed" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var command_arena = ArenaAllocator.init(testing.allocator);
+ const command_alloc = command_arena.allocator();
+ const command = try (configpkg.Command{
+ .direct = &.{
+ "foo",
+ "bar baz",
+ },
+ }).clone(command_alloc);
+
+ const result = try execCommand(alloc, command, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Failed passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return error.Fail;
+ }
+ });
+
+ command_arena.deinit();
+
+ try testing.expectEqual(2, result.len);
+ try testing.expectEqualStrings(result[0], "foo");
+ try testing.expectEqualStrings(result[1], "bar baz");
+}