summaryrefslogtreecommitdiff
path: root/src/host_io.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 14:55:53 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit44ce573e15c773cf5bb0d42941a143b63e44d900 (patch)
treec2c6ecd5b298a85044cb1b68e1db8f7b6657ead7 /src/host_io.zig
parentfd50bd971d6dea7eaaa4ee5436ba16b95fa25b30 (diff)
downloadpardes-44ce573e15c773cf5bb0d42941a143b63e44d900.tar.gz
pardes-44ce573e15c773cf5bb0d42941a143b63e44d900.zip
Make the macOS shell a first-class host
Pty children are exec'd with their own TERM/COLORTERM/TERM_PROGRAM instead of inheriting a .app launch's empty environment, and ttyTaken finally answers on darwin — libproc walks the tty's foreground process group — so Escape reaches the child and Exec stops believing every pane sits at its prompt. The occupancy suite runs on both platforms now. The workspace tag row moves into the native menu bar as a Builtins menu. -Dworkspace-tag (default off for -Dplatform=macos, on everywhere else) drives it, and Pardes.topBarHeight replaces the TOPBAR_H constant so the core stops reserving the row. The view pins every variable-font axis to the file's own default (Maple Mono came up Thin otherwise), shapes ligatures, carries per-shape pointer cursors, and draws the look-hover affordance as refracted glass. Tag rows fill edge to edge, with the anchor box painted back on top of that fill and its mode glyph centred on the same square. Theme accents re-saturated across the set. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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 212c23b4..55592957 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,
@@ -888,6 +983,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;
@@ -896,6 +996,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);
}
@@ -925,6 +1026,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(.{});
@@ -1031,6 +1168,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,
@@ -1085,10 +1230,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,
@@ -1329,23 +1558,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);
@@ -1374,7 +1613,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();
@@ -1390,7 +1629,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();
@@ -1403,7 +1642,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();
@@ -1424,7 +1663,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();
@@ -1436,23 +1675,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" },