summaryrefslogtreecommitdiff
path: root/src/host_io.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/host_io.zig')
-rw-r--r--src/host_io.zig320
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" },