diff options
Diffstat (limited to 'src/host_io.zig')
| -rw-r--r-- | src/host_io.zig | 320 |
1 files changed, 288 insertions, 32 deletions
diff --git a/src/host_io.zig b/src/host_io.zig index fb09fcc7..a7babc74 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -837,6 +837,7 @@ pub const Shell = struct { extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn execve(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; @@ -845,6 +846,100 @@ pub const Child = struct { pid: posix.pid_t, }; +/// The environment a pty child is handed: everything this process carries, +/// with the terminal's own identity written over whatever the launcher left +/// behind. The child is talking to the bundled VT, never to the terminal +/// pardes itself was started from, and a macOS `.app` launch supplies no +/// `TERM` at all — which is exactly why `clear` fails there, why nothing +/// paints a colour, and why every cursor-addressing program falls back to a +/// dumb line-at-a-time mode. +pub const ChildEnv = struct { + /// Entries pardes owns. The name of each is read back out of its own + /// spelling, so there is one place to edit and no second list to drift. + const own = [_][*:0]const u8{ + "TERM=" ++ pardes.config.child_term, + "COLORTERM=" ++ pardes.config.child_colorterm, + "TERM_PROGRAM=" ++ pardes.config.child_term_program, + }; + + /// Wide enough for any real environment; `build` answers null rather than + /// truncate, because a child missing half its variables is a worse bug + /// than one missing TERM, and the caller then execs with `environ` intact. + pub const Slots = [1024]?[*:0]const u8; + + fn name(entry: [*:0]const u8) []const u8 { + const line = std.mem.span(entry); + return line[0 .. std.mem.indexOfScalar(u8, line, '=') orelse line.len]; + } + + pub fn build(slots: *Slots) ?[*:null]const ?[*:0]const u8 { + var n: usize = 0; + var i: usize = 0; + while (libc.environ[i]) |entry| : (i += 1) { + const inherited = name(entry); + const shadowed = for (own) |mine| { + if (std.mem.eql(u8, inherited, name(mine))) break true; + } else false; + if (shadowed) continue; + if (n + own.len + 1 > slots.len) return null; + slots[n] = entry; + n += 1; + } + for (own) |mine| { + slots[n] = mine; + n += 1; + } + slots[n] = null; + return @ptrCast(slots); + } +}; + +test "the child environment replaces the launcher's terminal identity exactly once" { + const saved: ?[]const u8 = if (libc.getenv("TERM")) |t| std.mem.span(t) else null; + var saved_buf: [256]u8 = undefined; + const restore: ?[:0]const u8 = if (saved) |t| blk: { + if (t.len >= saved_buf.len) break :blk null; + @memcpy(saved_buf[0..t.len], t); + saved_buf[t.len] = 0; + break :blk saved_buf[0..t.len :0]; + } else null; + defer if (restore) |t| { + _ = Shell.setenv("TERM", t.ptr, 1); + } else { + _ = unsetenv("TERM"); + }; + + // A launcher TERM must be shadowed rather than duplicated, and the rest of + // the environment must arrive untouched. + try std.testing.expectEqual(@as(c_int, 0), Shell.setenv("TERM", "dumb", 1)); + try std.testing.expectEqual(@as(c_int, 0), Shell.setenv("PARDES_CHILD_ENV_PROBE", "1", 1)); + defer _ = unsetenv("PARDES_CHILD_ENV_PROBE"); + + var slots: ChildEnv.Slots = undefined; + const envp = ChildEnv.build(&slots) orelse return error.EnvironmentTooLarge; + var terms: usize = 0; + var colorterms: usize = 0; + var probes: usize = 0; + var dumb = false; + var i: usize = 0; + while (envp[i]) |entry| : (i += 1) { + const line = std.mem.span(entry); + if (std.mem.startsWith(u8, line, "TERM=")) terms += 1; + if (std.mem.startsWith(u8, line, "COLORTERM=")) colorterms += 1; + if (std.mem.eql(u8, line, "TERM=dumb")) dumb = true; + if (std.mem.eql(u8, line, "PARDES_CHILD_ENV_PROBE=1")) probes += 1; + } + try std.testing.expectEqual(@as(usize, 1), terms); + try std.testing.expectEqual(@as(usize, 1), colorterms); + try std.testing.expectEqual(@as(usize, 1), probes); + try std.testing.expect(!dumb); + // TERM_PROGRAM shares the prefix "TERM"; the match is on the name, so it + // is a separate entry and never a second TERM. + try std.testing.expectEqualStrings("TERM=" ++ pardes.config.child_term, std.mem.span(envp[i - ChildEnv.own.len].?)); +} + +extern "c" fn unsetenv(name: [*:0]const u8) c_int; + pub fn forkShell( core: ?*pardes.Pardes, pane: usize, @@ -891,6 +986,11 @@ pub fn forkShell( try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv); if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; } + // Built here and not after the fork: between fork and exec the child may + // not call setenv, whose malloc can deadlock against a pty reader thread + // that held the heap when the fork took its snapshot. + var env_slots: ChildEnv.Slots = undefined; + const envp = ChildEnv.build(&env_slots); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid < 0) return error.ForkFailed; @@ -899,6 +999,7 @@ pub fn forkShell( posix.sigaddset(&set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126); + if (envp) |env| _ = execve(spawn.path, &spawn.argv, env); _ = execv(spawn.path, &spawn.argv); _exit(127); } @@ -928,6 +1029,42 @@ test "shell spawn rejects invalid directories before creating a child" { try std.testing.expectError(error.FileNotFound, forkShell(null, 0, &rcs, "/bin/sh", missing, 24, 80, null)); } +test "a forked shell is told which terminal it is talking to" { + if (!haveFile("/bin/sh")) return error.SkipZigTest; + // Whatever launched the test is the wrong answer, so make it a loud one. + const saved = libc.getenv("TERM"); + var saved_buf: [256]u8 = undefined; + const restore: ?[:0]const u8 = if (saved) |t| blk: { + const span = std.mem.span(t); + if (span.len >= saved_buf.len) break :blk null; + @memcpy(saved_buf[0..span.len], span); + saved_buf[span.len] = 0; + break :blk saved_buf[0..span.len :0]; + } else null; + defer if (restore) |t| { + _ = Shell.setenv("TERM", t.ptr, 1); + } else { + _ = unsetenv("TERM"); + }; + _ = Shell.setenv("TERM", "dumb", 1); + + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + defer { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + _ = libc.close(child.file.handle); + } + var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; + // The echoed command carries the unexpanded `$TERM`, so only the shell's + // own expansion can match this. + try std.testing.expect(writeFd(child.file.handle, "printf '\nPARDES-TERM:%s:%s\n' \"$TERM\" \"$COLORTERM\"; exit\n")); + try std.testing.expect(sh.waitText( + "PARDES-TERM:" ++ pardes.config.child_term ++ ":" ++ pardes.config.child_colorterm, + 5_000, + )); +} + test "shell spawn uses an explicit OS directory longer than 256 bytes" { if (!haveFile("/bin/sh")) return error.SkipZigTest; var tmp = std.testing.tmpDir(.{}); @@ -1034,6 +1171,14 @@ extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; const occ_max_depth: u8 = 8; const occ_max_visited: usize = 32; +/// Where `ttyTaken` answers from evidence rather than from `false`. The two +/// implementations are different walks over the same question, so they share +/// one suite rather than each asserting half of it. +const tty_probe_platform = switch (builtin.os.tag) { + .linux, .macos => true, + else => false, +}; + const TtyProbe = struct { self_exe: [std.fs.max_path_bytes]u8 = undefined, exe: [std.fs.max_path_bytes]u8 = undefined, @@ -1088,10 +1233,94 @@ pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { } return !saw_fg; }, + .macos => { + const fg = tcgetpgrp(master_fd); + if (fg <= 0) return false; + if (fg == shell_pid) return false; + return darwin.taken(shell_pid, fg); + }, else => return false, } } +/// The same question the Linux walk asks, answered the way macOS can answer +/// it. There is no /proc to descend and no children list, but the tty's +/// foreground process group can be enumerated in one call — and "is anything +/// but the shell itself holding this terminal?" is exactly a statement about +/// the executables in that group. The test is the shell's own executable and +/// never its pid, which is what keeps a nested interactive shell a prompt, +/// and what still catches `bash -c 'sleep 30'`: the group holds `sleep` too. +/// +/// Until this existed, `ttyTaken` answered `false` on darwin for every pane, +/// so `Pardes.takesCommandLine` was permanently true and every bare Escape in +/// a raw terminal ran the `Last` builtin instead of reaching the child. +const darwin = struct { + const proc_pgrp_only: u32 = 2; + const proc_pidt_shortbsdinfo: c_int = 13; + const zombie_status: u32 = 5; // SZOMB + /// PROC_PIDPATHINFO_MAXSIZE. + const path_max = 4 * 1024; + /// A foreground group is a pipeline; anything larger is not a prompt. + const group_max = 64; + + const ShortBsdInfo = extern struct { + pid: u32, + ppid: u32, + pgid: u32, + status: u32, + comm: [16]u8, + flags: u32, + uid: u32, + gid: u32, + ruid: u32, + rgid: u32, + svuid: u32, + svgid: u32, + rfu: u32, + }; + + extern "c" fn proc_listpids(kind: u32, typeinfo: u32, buffer: ?*anyopaque, buffersize: c_int) c_int; + extern "c" fn proc_pidpath(pid: c_int, buffer: [*]u8, buffersize: u32) c_int; + + fn exe(pid: libc.pid_t, buf: *[path_max]u8) ?[]const u8 { + const got = proc_pidpath(pid, buf, buf.len); + if (got <= 0) return null; + return buf[0..@intCast(got)]; + } + + /// A pid with no readable path is either gone or not ours. Only a zombie + /// is provably harmless — it holds no tty — so everything else is taken. + fn zombie(pid: libc.pid_t) bool { + var info: ShortBsdInfo = undefined; + const got = proc_pidinfo(pid, proc_pidt_shortbsdinfo, 0, &info, @sizeOf(ShortBsdInfo)); + if (got != @sizeOf(ShortBsdInfo)) return false; + return info.status == zombie_status; + } + + fn taken(shell_pid: libc.pid_t, fg: libc.pid_t) bool { + var shell_buf: [path_max]u8 = undefined; + const shell_exe = exe(shell_pid, &shell_buf) orelse return false; + var group: [group_max]libc.pid_t = undefined; + const bytes = proc_listpids(proc_pgrp_only, @intCast(fg), &group, @sizeOf(@TypeOf(group))); + // An empty group is a group whose last member just exited: the shell + // is about to have its terminal back, and calling that taken would + // blink the prompt heuristic off for a frame every time a job ends. + if (bytes <= 0) return false; + const n = @as(usize, @intCast(bytes)) / @sizeOf(libc.pid_t); + if (n >= group.len) return true; + var exe_buf: [path_max]u8 = undefined; + for (group[0..n]) |pid| { + if (pid <= 0 or pid == shell_pid) continue; + const path = exe(pid, &exe_buf) orelse { + if (zombie(pid)) continue; + return true; + }; + if (!std.mem.eql(u8, path, shell_exe)) return true; + } + return false; + } +}; + pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { const sig = switch (which) { .int => libc.SIG.INT, @@ -1332,23 +1561,33 @@ const TestShell = struct { } fn stop(sh: *TestShell) void { - var probe: TtyProbe = undefined; - var pending: usize = 0; - var doomed: [occ_max_visited]libc.pid_t = undefined; - var n: usize = 0; - _ = pushChildren(&probe, &pending, sh.pid, 1); - while (pending > 0) { - pending -= 1; - const node = probe.pending[pending]; - if (n == doomed.len) break; - doomed[n] = node.pid; - n += 1; - if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); - } - _ = libc.kill(sh.pid, libc.SIG.KILL); - for (doomed[0..n]) |kid| { - _ = libc.kill(kid, libc.SIG.KILL); - _ = libc.kill(-kid, libc.SIG.KILL); + if (comptime builtin.os.tag == .linux) { + var probe: TtyProbe = undefined; + var pending: usize = 0; + var doomed: [occ_max_visited]libc.pid_t = undefined; + var n: usize = 0; + _ = pushChildren(&probe, &pending, sh.pid, 1); + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + if (n == doomed.len) break; + doomed[n] = node.pid; + n += 1; + if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); + } + _ = libc.kill(sh.pid, libc.SIG.KILL); + for (doomed[0..n]) |kid| { + _ = libc.kill(kid, libc.SIG.KILL); + _ = libc.kill(-kid, libc.SIG.KILL); + } + } else { + // No children list to descend. forkpty made the shell a session + // leader, so its own group plus whichever group currently holds + // the tty covers the job the test left running. + const fg = tcgetpgrp(sh.master); + if (fg > 0 and fg != sh.pid) _ = libc.kill(-fg, libc.SIG.KILL); + _ = libc.kill(-sh.pid, libc.SIG.KILL); + _ = libc.kill(sh.pid, libc.SIG.KILL); } _ = libc.waitpid(sh.pid, null, 0); _ = libc.close(sh.master); @@ -1377,7 +1616,7 @@ fn sleepMs(ms: i64) void { } test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); @@ -1393,7 +1632,7 @@ test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands i } test "a background job is not the tty's owner" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); @@ -1406,7 +1645,7 @@ test "a background job is not the tty's owner" { } test "a nested interactive shell is still a prompt" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); @@ -1427,7 +1666,7 @@ test "a nested interactive shell is still a prompt" { } test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); @@ -1439,23 +1678,40 @@ test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { sh.send("bash --norc -c 'sleep 30; :'\n"); try std.testing.expect(sh.waitTaken(true, 10_000)); - var probe: TtyProbe = undefined; - var pending: usize = 0; - try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); - try std.testing.expectEqual(@as(usize, 1), pending); - var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; - var shell_buf: [std.fs.max_path_bytes]u8 = undefined; - try std.testing.expectEqualStrings( - procExe(sh.pid, &shell_buf).?, - procExe(probe.pending[0].pid, &wrapper_buf).?, - ); + // Why the shallow answer is the wrong one, in each walk's own evidence: + // the wrapper between the shell and `sleep` wears the shell's executable, + // so a probe that stopped at it would report a prompt. + if (comptime builtin.os.tag == .linux) { + var probe: TtyProbe = undefined; + var pending: usize = 0; + try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); + try std.testing.expectEqual(@as(usize, 1), pending); + var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; + var shell_buf: [std.fs.max_path_bytes]u8 = undefined; + try std.testing.expectEqualStrings( + procExe(sh.pid, &shell_buf).?, + procExe(probe.pending[0].pid, &wrapper_buf).?, + ); + } else { + const fg = tcgetpgrp(sh.master); + try std.testing.expect(fg > 0 and fg != sh.pid); + var leader_buf: [darwin.path_max]u8 = undefined; + var shell_buf: [darwin.path_max]u8 = undefined; + // The group's leader IS that wrapper, and it is the shell's binary. + try std.testing.expectEqualStrings( + darwin.exe(sh.pid, &shell_buf).?, + darwin.exe(fg, &leader_buf).?, + ); + // Taken all the same, because the rest of the group is not. + try std.testing.expect(darwin.taken(sh.pid, fg)); + } sh.send("\x03"); try std.testing.expect(sh.waitTaken(false, 10_000)); } test "a full-screen program takes the tty until it quits" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime !tty_probe_platform) return error.SkipZigTest; const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, |
