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/Command.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/Command.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/Command.zig | 903 |
1 files changed, 903 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/Command.zig b/vendor/libghostty-vt/src/Command.zig new file mode 100644 index 00000000..2b381912 --- /dev/null +++ b/vendor/libghostty-vt/src/Command.zig @@ -0,0 +1,903 @@ +//! Command launches sub-processes. This is an alternate implementation to the +//! Zig std.process.Child since at the time of authoring this, std.process.Child +//! didn't support the options necessary to spawn a shell attached to a pty. +//! +//! Consequently, I didn't implement a lot of features that std.process.Child +//! supports because we didn't need them. Cross-platform subprocessing is not +//! a trivial thing to implement (I've done it in three separate languages now) +//! so if we want to replatform onto std.process.Child I'd love to do that. +//! This was just the fastest way to get something built. +//! +//! Issues with std.process.Child: +//! +//! * No pre_exec callback for logic after fork but before exec. +//! * posix_spawn is used for Mac, but doesn't support the necessary +//! features for tty setup. +//! +const Command = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const configpkg = @import("config.zig"); +const global_state = &@import("global.zig").state; +const internal_os = @import("os/main.zig"); +const windows = internal_os.windows; +const TempDir = internal_os.TempDir; +const mem = std.mem; +const linux = std.os.linux; +const posix = std.posix; +const debug = std.debug; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const File = std.fs.File; +const EnvMap = std.process.EnvMap; +const apprt = @import("apprt.zig"); + +/// Function prototype for a function executed /in the child process/ after the +/// fork, but before exec'ing the command. If the function returns a u8, the +/// child process will be exited with that error code. +const PreExecFn = fn (*Command) ?u8; + +/// Allowable set of errors that can be returned by a post fork function. Any +/// errors will result in the failure to create the surface. +pub const PostForkError = error{PostForkError}; + +/// Function prototype for a function executed /in the parent process/ +/// after the fork. +const PostForkFn = fn (*Command) PostForkError!void; + +/// Path to the command to run. This doesn't have to be an absolute path, +/// because use exec functions that search the PATH, if necessary. +/// +/// This field is null-terminated to avoid a copy for the sake of +/// adding a null terminator since POSIX systems are so common. +path: [:0]const u8, + +/// Command-line arguments. It is the responsibility of the caller to set +/// args[0] to the command. If args is empty then args[0] will automatically +/// be set to equal path. +args: []const [:0]const u8, + +/// Environment variables for the child process. If this is null, inherits +/// the environment variables from this process. These are the exact +/// environment variables to set; these are /not/ merged. +env: ?*const EnvMap = null, + +/// Working directory to change to in the child process. If not set, the +/// working directory of the calling process is preserved. +cwd: ?[]const u8 = null, + +/// The file handle to set for stdin/out/err. If this isn't set, we do +/// nothing explicitly so it is up to the behavior of the operating system. +stdin: ?File = null, +stdout: ?File = null, +stderr: ?File = null, + +/// If set, this will be executed /in the child process/ after fork but +/// before exec. This is useful to setup some state in the child before the +/// exec process takes over, such as signal handlers, setsid, setuid, etc. +os_pre_exec: ?*const PreExecFn, + +/// If set, this will be executed /in the child process/ after fork but +/// before exec. This is useful to setup some state in the child before the +/// exec process takes over, such as signal handlers, setsid, setuid, etc. +rt_pre_exec: ?*const PreExecFn, + +/// Configuration information needed by the apprt pre exec function. Note +/// that this should be a trivially copyable struct and not require any +/// allocation/deallocation. +rt_pre_exec_info: RtPreExecInfo, + +/// If set, this will be executed in the /in the parent process/ after the fork. +rt_post_fork: ?*const PostForkFn, + +/// Configuration information needed by the apprt post fork function. Note +/// that this should be a trivially copyable struct and not require any +/// allocation/deallocation. +rt_post_fork_info: RtPostForkInfo, + +/// If set, then the process will be created attached to this pseudo console. +/// `stdin`, `stdout`, and `stderr` will be ignored if set. +pseudo_console: if (builtin.os.tag == .windows) ?windows.exp.HPCON else void = + if (builtin.os.tag == .windows) null else {}, + +/// User data that is sent to the callback. Set with setData and getData +/// for a more user-friendly API. +data: ?*anyopaque = null, + +/// Process ID is set after start is called. +pid: ?posix.pid_t = null, + +/// The various methods a process may exit. +pub const Exit = if (builtin.os.tag == .windows) union(enum) { + Exited: u32, +} else union(enum) { + /// Exited by normal exit call, value is exit status + Exited: u8, + + /// Exited by a signal, value is the signal + Signal: u32, + + /// Exited by a stop signal, value is signal + Stopped: u32, + + /// Unknown exit reason, value is the status from waitpid + Unknown: u32, + + pub fn init(status: u32) Exit { + return if (posix.W.IFEXITED(status)) + Exit{ .Exited = posix.W.EXITSTATUS(status) } + else if (posix.W.IFSIGNALED(status)) + Exit{ .Signal = posix.W.TERMSIG(status) } + else if (posix.W.IFSTOPPED(status)) + Exit{ .Stopped = posix.W.STOPSIG(status) } + else + Exit{ .Unknown = status }; + } +}; + +/// Configuration information needed by the apprt pre exec function. Note +/// that this should be a trivially copyable struct and not require any +/// allocation/deallocation. +pub const RtPreExecInfo = if (@hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.PreExecInfo else struct { + pub inline fn init(_: *const configpkg.Config) @This() { + return .{}; + } +}; + +/// Configuration information needed by the apprt post fork function. Note +/// that this should be a trivially copyable struct and not require any +/// allocation/deallocation. +pub const RtPostForkInfo = if (@hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.PostForkInfo else struct { + pub inline fn init(_: *const configpkg.Config) @This() { + return .{}; + } +}; + +/// Start the subprocess. This returns immediately once the child is started. +/// +/// After this is successful, self.pid is available. +pub fn start(self: *Command, alloc: Allocator) !void { + // Use an arena allocator for the temporary allocations we need in this func. + // IMPORTANT: do all allocation prior to the fork(). I believe it is undefined + // behavior if you malloc between fork and exec. The source of the Zig + // stdlib seems to verify this as well as Go. + var arena_allocator = std.heap.ArenaAllocator.init(alloc); + defer arena_allocator.deinit(); + const arena = arena_allocator.allocator(); + + switch (builtin.os.tag) { + .windows => try self.startWindows(arena), + else => try self.startPosix(arena), + } +} + +fn startPosix(self: *Command, arena: Allocator) !void { + // Null-terminate all our arguments + const argsZ = try arena.allocSentinel(?[*:0]const u8, self.args.len, null); + for (self.args, 0..) |arg, i| argsZ[i] = arg.ptr; + + // Determine our env vars + const envp = if (self.env) |env_map| + (try createNullDelimitedEnvMap(arena, env_map)).ptr + else if (builtin.link_libc) + std.c.environ + else + @compileError("missing env vars"); + + // Fork. + const pid = try posix.fork(); + + if (pid != 0) { + // Parent, return immediately. + self.pid = @intCast(pid); + if (self.rt_post_fork) |f| try f(self); + return; + } + + // We are the child. + + // Setup our file descriptors for std streams. + if (self.stdin) |f| setupFd(f.handle, posix.STDIN_FILENO) catch + return error.ExecFailedInChild; + if (self.stdout) |f| setupFd(f.handle, posix.STDOUT_FILENO) catch + return error.ExecFailedInChild; + if (self.stderr) |f| setupFd(f.handle, posix.STDERR_FILENO) catch + return error.ExecFailedInChild; + + // Setup our working directory + if (self.cwd) |cwd| posix.chdir(cwd) catch { + // This can fail if we don't have permission to go to + // this directory or if due to race conditions it doesn't + // exist or any various other reasons. We don't want to + // crash the entire process if this fails so we ignore it. + // We don't log because that'll show up in the output. + }; + + // Restore any rlimits that were set by Ghostty. This might fail but + // any failures are ignored (its best effort). + global_state.rlimits.restore(); + + // If there are pre exec callbacks, call them now. + if (self.os_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode); + if (self.rt_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode); + + // Finally, replace our process. + // Note: we must use the "p"-variant of exec here because we + // do not guarantee our command is looked up already in the path. + const err = posix.execvpeZ(self.path, argsZ, envp); + + // If we are executing this code, the exec failed. We're in the + // child process so there isn't much we can do. We try to output + // something reasonable. Its important to note we MUST NOT return + // any other error condition from here on out. + var stderr_buf: [1024]u8 = undefined; + var stderr_writer = std.fs.File.stderr().writer(&stderr_buf); + const stderr = &stderr_writer.interface; + switch (err) { + error.FileNotFound => stderr.print( + \\Requested executable not found. Please verify the command is on + \\the PATH and try again. + \\ + , + .{}, + ) catch {}, + + else => stderr.print( + \\exec syscall failed with unexpected error: {} + \\ + , + .{err}, + ) catch {}, + } + stderr.flush() catch {}; + + // We return a very specific error that can be detected to determine + // we're in the child. + return error.ExecFailedInChild; +} + +fn startWindows(self: *Command, arena: Allocator) !void { + const application_w = try std.unicode.utf8ToUtf16LeAllocZ(arena, self.path); + const cwd_w = if (self.cwd) |cwd| try std.unicode.utf8ToUtf16LeAllocZ(arena, cwd) else null; + const command_line_w = if (self.args.len > 0) b: { + const command_line = try windowsCreateCommandLine(arena, self.args); + break :b try std.unicode.utf8ToUtf16LeAllocZ(arena, command_line); + } else null; + const env_w = if (self.env) |env_map| try createWindowsEnvBlock(arena, env_map) else null; + + const any_null_fd = self.stdin == null or self.stdout == null or self.stderr == null; + const null_fd = if (any_null_fd) try windows.OpenFile( + &[_]u16{ '\\', 'D', 'e', 'v', 'i', 'c', 'e', '\\', 'N', 'u', 'l', 'l' }, + .{ + .access_mask = windows.GENERIC_READ | windows.SYNCHRONIZE, + .share_access = windows.FILE_SHARE_READ, + .creation = windows.OPEN_EXISTING, + }, + ) else null; + defer if (null_fd) |fd| posix.close(fd); + + // TODO: In the case of having FDs instead of pty, need to set up + // attributes such that the child process only inherits these handles, + // then set bInheritsHandles below. + + const attribute_list, const stdin, const stdout, const stderr = if (self.pseudo_console) |pseudo_console| b: { + var attribute_list_size: usize = undefined; + _ = windows.exp.kernel32.InitializeProcThreadAttributeList( + null, + 1, + 0, + &attribute_list_size, + ); + + const attribute_list_buf = try arena.alloc(u8, attribute_list_size); + if (windows.exp.kernel32.InitializeProcThreadAttributeList( + attribute_list_buf.ptr, + 1, + 0, + &attribute_list_size, + ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError()); + + if (windows.exp.kernel32.UpdateProcThreadAttribute( + attribute_list_buf.ptr, + 0, + windows.exp.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + pseudo_console, + @sizeOf(windows.exp.HPCON), + null, + null, + ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError()); + + break :b .{ attribute_list_buf.ptr, null, null, null }; + } else b: { + const stdin = if (self.stdin) |f| f.handle else null_fd.?; + const stdout = if (self.stdout) |f| f.handle else null_fd.?; + const stderr = if (self.stderr) |f| f.handle else null_fd.?; + break :b .{ null, stdin, stdout, stderr }; + }; + + var startup_info_ex = windows.exp.STARTUPINFOEX{ + .StartupInfo = .{ + .cb = if (attribute_list != null) @sizeOf(windows.exp.STARTUPINFOEX) else @sizeOf(windows.STARTUPINFOW), + .hStdError = stderr, + .hStdOutput = stdout, + .hStdInput = stdin, + .dwFlags = windows.STARTF_USESTDHANDLES, + .lpReserved = null, + .lpDesktop = null, + .lpTitle = null, + .dwX = 0, + .dwY = 0, + .dwXSize = 0, + .dwYSize = 0, + .dwXCountChars = 0, + .dwYCountChars = 0, + .dwFillAttribute = 0, + .wShowWindow = 0, + .cbReserved2 = 0, + .lpReserved2 = null, + }, + .lpAttributeList = attribute_list, + }; + + var flags: windows.DWORD = windows.exp.CREATE_UNICODE_ENVIRONMENT; + if (attribute_list != null) flags |= windows.exp.EXTENDED_STARTUPINFO_PRESENT; + + var process_information: windows.PROCESS_INFORMATION = undefined; + if (windows.exp.kernel32.CreateProcessW( + application_w.ptr, + if (command_line_w) |w| w.ptr else null, + null, + null, + windows.TRUE, + flags, + if (env_w) |w| w.ptr else null, + if (cwd_w) |w| w.ptr else null, + @ptrCast(&startup_info_ex.StartupInfo), + &process_information, + ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError()); + + self.pid = process_information.hProcess; +} + +fn setupFd(src: File.Handle, target: i32) !void { + switch (builtin.os.tag) { + .linux => { + // We use dup3 so that we can clear CLO_ON_EXEC. We do NOT want this + // file descriptor to be closed on exec since we're exactly exec-ing after + // this. + while (true) { + const rc = linux.dup3(src, target, 0); + switch (posix.errno(rc)) { + .SUCCESS => break, + .INTR => continue, + .AGAIN, .ACCES => return error.Locked, + .BADF => unreachable, + .BUSY => return error.FileBusy, + .INVAL => unreachable, // invalid parameters + .PERM => return error.PermissionDenied, + .MFILE => return error.ProcessFdQuotaExceeded, + .NOTDIR => unreachable, // invalid parameter + .DEADLK => return error.DeadLock, + .NOLCK => return error.LockedRegionLimitExceeded, + else => |err| return posix.unexpectedErrno(err), + } + } + }, + .freebsd, .ios, .macos => { + // Mac doesn't support dup3 so we use dup2. We purposely clear + // CLO_ON_EXEC for this fd. + const flags = try posix.fcntl(src, posix.F.GETFD, 0); + if (flags & posix.FD_CLOEXEC != 0) { + _ = try posix.fcntl(src, posix.F.SETFD, flags & ~@as(u32, posix.FD_CLOEXEC)); + } + + try posix.dup2(src, target); + }, + else => @compileError("unsupported platform"), + } +} + +/// Wait for the command to exit and return information about how it exited. +pub fn wait(self: Command, block: bool) !Exit { + if (comptime builtin.os.tag == .windows) { + // Block until the process exits. This returns immediately if the + // process already exited. + const result = windows.kernel32.WaitForSingleObject(self.pid.?, windows.INFINITE); + if (result == windows.WAIT_FAILED) { + return windows.unexpectedError(windows.kernel32.GetLastError()); + } + + var exit_code: windows.DWORD = undefined; + const has_code = windows.kernel32.GetExitCodeProcess(self.pid.?, &exit_code) != 0; + if (!has_code) { + return windows.unexpectedError(windows.kernel32.GetLastError()); + } + + return .{ .Exited = exit_code }; + } + + const res = if (block) posix.waitpid(self.pid.?, 0) else res: { + // We specify NOHANG because its not our fault if the process we launch + // for the tty doesn't properly waitpid its children. We don't want + // to hang the terminal over it. + // When NOHANG is specified, waitpid will return a pid of 0 if the process + // doesn't have a status to report. When that happens, it is as though the + // wait call has not been performed, so we need to keep trying until we get + // a non-zero pid back, otherwise we end up with zombie processes. + while (true) { + const res = posix.waitpid(self.pid.?, std.c.W.NOHANG); + if (res.pid != 0) break :res res; + } + }; + + return .init(res.status); +} + +/// Sets command->data to data. +pub fn setData(self: *Command, pointer: ?*anyopaque) void { + self.data = pointer; +} + +/// Returns command->data. +pub fn getData(self: Command, comptime DT: type) ?*DT { + return if (self.data) |ptr| @ptrCast(@alignCast(ptr)) else null; +} + +// Copied from Zig. This is a publicly exported function but there is no +// way to get it from the std package. +fn createNullDelimitedEnvMap(arena: mem.Allocator, env_map: *const EnvMap) ![:null]?[*:0]u8 { + const envp_count = env_map.count(); + const envp_buf = try arena.allocSentinel(?[*:0]u8, envp_count, null); + + var it = env_map.iterator(); + var i: usize = 0; + while (it.next()) |pair| : (i += 1) { + const env_buf = try arena.allocSentinel(u8, pair.key_ptr.len + pair.value_ptr.len + 1, 0); + @memcpy(env_buf[0..pair.key_ptr.len], pair.key_ptr.*); + env_buf[pair.key_ptr.len] = '='; + @memcpy(env_buf[pair.key_ptr.len + 1 ..], pair.value_ptr.*); + envp_buf[i] = env_buf.ptr; + } + std.debug.assert(i == envp_count); + + return envp_buf; +} + +// Copied from Zig. This is a publicly exported function but there is no +// way to get it from the std package. +fn createWindowsEnvBlock(allocator: mem.Allocator, env_map: *const EnvMap) ![]u16 { + // count bytes needed + const max_chars_needed = x: { + var max_chars_needed: usize = 4; // 4 for the final 4 null bytes + var it = env_map.iterator(); + while (it.next()) |pair| { + // +1 for '=' + // +1 for null byte + max_chars_needed += pair.key_ptr.len + pair.value_ptr.len + 2; + } + break :x max_chars_needed; + }; + const result = try allocator.alloc(u16, max_chars_needed); + errdefer allocator.free(result); + + var it = env_map.iterator(); + var i: usize = 0; + while (it.next()) |pair| { + i += try std.unicode.utf8ToUtf16Le(result[i..], pair.key_ptr.*); + result[i] = '='; + i += 1; + i += try std.unicode.utf8ToUtf16Le(result[i..], pair.value_ptr.*); + result[i] = 0; + i += 1; + } + result[i] = 0; + i += 1; + result[i] = 0; + i += 1; + result[i] = 0; + i += 1; + result[i] = 0; + i += 1; + return try allocator.realloc(result, i); +} + +/// Copied from Zig. This function could be made public in child_process.zig instead. +fn windowsCreateCommandLine(allocator: mem.Allocator, argv: []const []const u8) ![:0]u8 { + var buf: std.Io.Writer.Allocating = .init(allocator); + defer buf.deinit(); + const writer = &buf.writer; + + for (argv, 0..) |arg, arg_i| { + if (arg_i != 0) try writer.writeByte(' '); + if (mem.indexOfAny(u8, arg, " \t\n\"") == null) { + try writer.writeAll(arg); + continue; + } + try writer.writeByte('"'); + var backslash_count: usize = 0; + for (arg) |byte| { + switch (byte) { + '\\' => backslash_count += 1, + '"' => { + try writer.splatByteAll('\\', backslash_count * 2 + 1); + try writer.writeByte('"'); + backslash_count = 0; + }, + else => { + try writer.splatByteAll('\\', backslash_count); + try writer.writeByte(byte); + backslash_count = 0; + }, + } + } + try writer.splatByteAll('\\', backslash_count * 2); + try writer.writeByte('"'); + } + + return buf.toOwnedSliceSentinel(0); +} + +test "createNullDelimitedEnvMap" { + const allocator = testing.allocator; + var envmap = EnvMap.init(allocator); + defer envmap.deinit(); + + try envmap.put("HOME", "/home/ifreund"); + try envmap.put("WAYLAND_DISPLAY", "wayland-1"); + try envmap.put("DISPLAY", ":1"); + try envmap.put("DEBUGINFOD_URLS", " "); + try envmap.put("XCURSOR_SIZE", "24"); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const environ = try createNullDelimitedEnvMap(arena.allocator(), &envmap); + + try testing.expectEqual(@as(usize, 5), environ.len); + + inline for (.{ + "HOME=/home/ifreund", + "WAYLAND_DISPLAY=wayland-1", + "DISPLAY=:1", + "DEBUGINFOD_URLS= ", + "XCURSOR_SIZE=24", + }) |target| { + for (environ) |variable| { + if (mem.eql(u8, mem.span(variable orelse continue), target)) break; + } else { + try testing.expect(false); // Environment variable not found + } + } +} + +test "Command: os pre exec 1" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var cmd: Command = .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-v" }, + .os_pre_exec = (struct { + fn do(_: *Command) ?u8 { + // This runs in the child, so we can exit and it won't + // kill the test runner. + posix.exit(42); + } + }).do, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 42); +} + +test "Command: os pre exec 2" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var cmd: Command = .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-v" }, + .os_pre_exec = (struct { + fn do(_: *Command) ?u8 { + // This runs in the child, so we can exit and it won't + // kill the test runner. + return 42; + } + }).do, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 42); +} + +test "Command: rt pre exec 1" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var cmd: Command = .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-v" }, + .os_pre_exec = null, + .rt_pre_exec = (struct { + fn do(_: *Command) ?u8 { + // This runs in the child, so we can exit and it won't + // kill the test runner. + posix.exit(42); + } + }).do, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 42); +} + +test "Command: rt pre exec 2" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var cmd: Command = .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-v" }, + .os_pre_exec = null, + .rt_pre_exec = (struct { + fn do(_: *Command) ?u8 { + // This runs in the child, so we can exit and it won't + // kill the test runner. + return 42; + } + }).do, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 42); +} + +test "Command: rt post fork 1" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var cmd: Command = .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-c", "sleep 1" }, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = (struct { + fn do(_: *Command) PostForkError!void { + return error.PostForkError; + } + }).do, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try testing.expectError(error.PostForkError, cmd.testingStart()); +} + +fn createTestStdout(dir: std.fs.Dir) !File { + const file = try dir.createFile("stdout.txt", .{ .read = true }); + if (builtin.os.tag == .windows) { + try windows.SetHandleInformation( + file.handle, + windows.HANDLE_FLAG_INHERIT, + windows.HANDLE_FLAG_INHERIT, + ); + } + + return file; +} + +fn createTestStderr(dir: std.fs.Dir) !File { + const file = try dir.createFile("stderr.txt", .{ .read = true }); + if (builtin.os.tag == .windows) { + try windows.SetHandleInformation( + file.handle, + windows.HANDLE_FLAG_INHERIT, + windows.HANDLE_FLAG_INHERIT, + ); + } + + return file; +} + +test "Command: redirect stdout to file" { + var td = try TempDir.init(); + defer td.deinit(); + var stdout = try createTestStdout(td.dir); + defer stdout.close(); + + var cmd: Command = if (builtin.os.tag == .windows) .{ + .path = "C:\\Windows\\System32\\whoami.exe", + .args = &.{"C:\\Windows\\System32\\whoami.exe"}, + .stdout = stdout, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + } else .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-c", "echo hello" }, + .stdout = stdout, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expectEqual(@as(u32, 0), @as(u32, exit.Exited)); + + // Read our stdout + try stdout.seekTo(0); + const contents = try stdout.readToEndAlloc(testing.allocator, 1024 * 128); + defer testing.allocator.free(contents); + try testing.expect(contents.len > 0); +} + +test "Command: custom env vars" { + var td = try TempDir.init(); + defer td.deinit(); + var stdout = try createTestStdout(td.dir); + defer stdout.close(); + + var env = EnvMap.init(testing.allocator); + defer env.deinit(); + try env.put("VALUE", "hello"); + + var cmd: Command = if (builtin.os.tag == .windows) .{ + .path = "C:\\Windows\\System32\\cmd.exe", + .args = &.{ "C:\\Windows\\System32\\cmd.exe", "/C", "echo %VALUE%" }, + .stdout = stdout, + .env = &env, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + } else .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-c", "echo $VALUE" }, + .stdout = stdout, + .env = &env, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 0); + + // Read our stdout + try stdout.seekTo(0); + const contents = try stdout.readToEndAlloc(testing.allocator, 4096); + defer testing.allocator.free(contents); + + if (builtin.os.tag == .windows) { + try testing.expectEqualStrings("hello\r\n", contents); + } else { + try testing.expectEqualStrings("hello\n", contents); + } +} + +test "Command: custom working directory" { + var td = try TempDir.init(); + defer td.deinit(); + var stdout = try createTestStdout(td.dir); + defer stdout.close(); + + var cmd: Command = if (builtin.os.tag == .windows) .{ + .path = "C:\\Windows\\System32\\cmd.exe", + .args = &.{ "C:\\Windows\\System32\\cmd.exe", "/C", "cd" }, + .stdout = stdout, + .cwd = "C:\\Windows\\System32", + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + } else .{ + .path = "/bin/sh", + .args = &.{ "/bin/sh", "-c", "pwd" }, + .stdout = stdout, + .cwd = "/tmp", + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 0); + + // Read our stdout + try stdout.seekTo(0); + const contents = try stdout.readToEndAlloc(testing.allocator, 4096); + defer testing.allocator.free(contents); + + if (builtin.os.tag == .windows) { + try testing.expectEqualStrings("C:\\Windows\\System32\r\n", contents); + } else if (builtin.os.tag == .macos) { + try testing.expectEqualStrings("/private/tmp\n", contents); + } else { + try testing.expectEqualStrings("/tmp\n", contents); + } +} + +// Test validate an execveZ failure correctly terminates when error.ExecFailedInChild is correctly handled +// +// Incorrectly handling an error.ExecFailedInChild results in a second copy of the test process running. +// Duplicating the test process leads to weird behavior +// zig build test will hang +// test binary created via -Demit-test-exe will run 2 copies of the test suite +test "Command: posix fork handles execveZ failure" { + if (builtin.os.tag == .windows) { + return error.SkipZigTest; + } + var td = try TempDir.init(); + defer td.deinit(); + var stdout = try createTestStdout(td.dir); + defer stdout.close(); + var stderr = try createTestStderr(td.dir); + defer stderr.close(); + + var cmd: Command = .{ + .path = "/not/a/binary", + .args = &.{ "/not/a/binary", "" }, + .stdout = stdout, + .stderr = stderr, + .cwd = "/bin", + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, + }; + + try cmd.testingStart(); + try testing.expect(cmd.pid != null); + const exit = try cmd.wait(true); + try testing.expect(exit == .Exited); + try testing.expect(exit.Exited == 1); +} + +// If cmd.start fails with error.ExecFailedInChild it's the _child_ process that is running. If it does not +// terminate in response to that error both the parent and child will continue as if they _are_ the test suite +// process. +fn testingStart(self: *Command) !void { + self.start(testing.allocator) catch |err| { + if (err == error.ExecFailedInChild) { + // I am a child process, I must not get confused and continue running the rest of the test suite. + posix.exit(1); + } + return err; + }; +} |
