diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/termio/Exec.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (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.zig | 1761 |
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"); +} |
