a73x

a7a02f36

refactor: the pty forks, seals and resizes through server_os

a73x   2026-09-03 15:10

Commit message
refactor: the pty forks, seals and resizes through server_os

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

build.zig
Old New
@@ -130,7 +130,7 @@ const mod_table = [_]ModSpec{
130 // pty, and an app that links the engine and a client must not either. 130 // pty, and an app that links the engine and a client must not either.
131 .{ .name = "server_os", .path = "src/os/server_os.zig", .link_libc = true }, 131 .{ .name = "server_os", .path = "src/os/server_os.zig", .link_libc = true },
132 .{ .name = "client_os", .path = "src/os/client_os.zig", .link_libc = true }, 132 .{ .name = "client_os", .path = "src/os/client_os.zig", .link_libc = true },
133 .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true }, 133 .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true, .imports = &.{"server_os"} },
134 // The QUIC vocabulary both ends share: the one @cImport of the vendored 134 // The QUIC vocabulary both ends share: the one @cImport of the vendored
135 // stack, the key, the wire constants, the egress ring. It has to be ONE 135 // stack, the key, the wire constants, the egress ring. It has to be ONE
136 // module — two @cImport blocks over the same headers are two distinct 136 // module — two @cImport blocks over the same headers are two distinct
src/os/server_os.zig
Old New
@@ -21,10 +21,106 @@ pub fn getpid() std.posix.pid_t {
21 return impl.getpid(); 21 return impl.getpid();
22 } 22 }
23 23
24 pub const Winsize = std.posix.winsize;
25 pub const ForkedPty = struct { pid: std.posix.pid_t, master: std.posix.fd_t };
26
27 /// Fork with a fresh pty as the child's controlling terminal, sized before
28 /// the shell's first read so no program sees a 0x0 grid. Returns pid 0 in
29 /// the child, exactly as forkpty(3) does, so the child code that resets
30 /// signals and injects env stays where the fork is visible (pty.zig).
31 pub fn forkPty(ws: Winsize) error{ForkPtyFailed}!ForkedPty {
32 return impl.forkPty(ws);
33 }
34
35 /// A child's bail-out. Never `std.process.exit`: under link_libc that is
36 /// exit(3), which runs atexit and flushes stdio buffers the child inherited
37 /// from the parent — so the parent's pending bytes would be written twice.
38 pub fn exitNow(code: u8) noreturn {
39 impl.exitNow(code);
40 }
41
42 /// The fd barrier: every descriptor at or above `first` is closed in the
43 /// child before exec. CLOEXEC is set fd by fd, and an upgrade clears every
44 /// one and must seal them again — two hand-kept lists that would have to
45 /// agree, or the manifest carrier with the QUIC key bytes rides into the
46 /// shell. This needs no list.
47 pub fn closeFrom(first: std.posix.fd_t) void {
48 impl.closeFrom(first);
49 }
50
51 /// The two line-discipline bits that decide who echoes a keystroke, read
52 /// off the MASTER. Polled — the kernel notifies nobody when a mode changes.
53 pub const PtyMode = struct { icanon: bool, echo: bool };
54 pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!PtyMode {
55 return impl.ptyMode(master);
56 }
57
58 /// Foreground process group of the pty. Equal to the session's child pid
59 /// means no foreground job: the kernel's "command returned" with zero shell
60 /// cooperation, which is `mux a`'s `pgid` mechanism.
61 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
62 return impl.ptyFgPgid(master);
63 }
64
65 /// Resize the pty; the kernel raises SIGWINCH in the session.
66 pub fn setWinsize(master: std.posix.fd_t, ws: Winsize) error{IoctlFailed}!void {
67 return impl.setWinsize(master, ws);
68 }
69
24 test "server_os: the arm compiles and answers for the process it is in" { 70 test "server_os: the arm compiles and answers for the process it is in" {
25 try std.testing.expect(getpid() > 0); 71 try std.testing.expect(getpid() > 0);
26 } 72 }
27 73
74 test "server_os.closeFrom: a fd below the floor survives and one above does not" {
75 // pipe(2) sets no CLOEXEC, so a child that did not close would still
76 // hold pipe[1]. Asked through /dev/fd, which both OSes have.
77 const pipe = try std.posix.pipe();
78 defer std.posix.close(pipe[0]);
79 defer std.posix.close(pipe[1]);
80 var cmd_buf: [96]u8 = undefined;
81 const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /dev/fd/{d} && exit 3; exit 0", .{pipe[1]});
82 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr };
83 const ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
84 const f = try forkPty(ws);
85 if (f.pid == 0) {
86 closeFrom(3);
87 std.posix.execveZ(argv[0].?, &argv, std.c.environ) catch {};
88 exitNow(127);
89 }
90 defer std.posix.close(f.master);
91 const r = std.posix.waitpid(f.pid, 0);
92 try std.testing.expect(std.posix.W.IFEXITED(r.status));
93 try std.testing.expectEqual(@as(u32, 0), std.posix.W.EXITSTATUS(r.status));
94 }
95
96 test "server_os.setWinsize then ptyMode: the master answers about the line discipline" {
97 const ws: Winsize = .{ .row = 31, .col = 101, .xpixel = 0, .ypixel = 0 };
98 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "stty -echo; sleep 5" };
99 const f = try forkPty(ws);
100 if (f.pid == 0) {
101 std.posix.execveZ(argv[0].?, &argv, std.c.environ) catch {};
102 exitNow(127);
103 }
104 defer {
105 std.posix.kill(f.pid, std.posix.SIG.KILL) catch {};
106 _ = std.posix.waitpid(f.pid, 0);
107 std.posix.close(f.master);
108 }
109 // A fresh pty echoes; the shell turns it off. Polled, because nothing
110 // notifies a mode change.
111 var waited: usize = 0;
112 while (waited < 100) : (waited += 1) {
113 const m = try ptyMode(f.master);
114 if (!m.echo) break;
115 std.Thread.sleep(50 * std.time.ns_per_ms);
116 }
117 try std.testing.expect(!(try ptyMode(f.master)).echo);
118 // The foreground group is the shell itself while `sleep` is its child
119 // in the same group: fgPgid equals the pid forkPty returned.
120 try std.testing.expectEqual(f.pid, try ptyFgPgid(f.master));
121 try setWinsize(f.master, .{ .row = 10, .col = 40, .xpixel = 0, .ypixel = 0 });
122 }
123
28 // Forces semantic analysis of every pub decl under `zig build test`, so an 124 // Forces semantic analysis of every pub decl under `zig build test`, so an
29 // unreferenced operation must at least compile for this OS. 125 // unreferenced operation must at least compile for this OS.
30 test { 126 test {
src/os/server_os_linux.zig
Old New
@@ -1,6 +1,46 @@
1 //! Linux arm of `server_os`. Spellings only; the contract is in the root. 1 //! Linux arm of `server_os`. Spellings only; the contract is in the root.
2 const std = @import("std"); 2 const std = @import("std");
3 const root = @import("server_os.zig");
4 const c = @cImport({
5 @cInclude("pty.h");
6 @cInclude("sys/ioctl.h");
7 });
3 8
4 pub fn getpid() std.posix.pid_t { 9 pub fn getpid() std.posix.pid_t {
5 return std.os.linux.getpid(); 10 return std.os.linux.getpid();
6 } 11 }
12
13 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
14 var master: c_int = undefined;
15 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
16 const pid = c.forkpty(&master, null, null, &cws);
17 if (pid < 0) return error.ForkPtyFailed;
18 return .{ .pid = pid, .master = master };
19 }
20
21 pub fn exitNow(code: u8) noreturn {
22 std.os.linux.exit_group(code);
23 }
24
25 pub fn closeFrom(first: std.posix.fd_t) void {
26 // ENOSYS (pre-5.9 kernel) leaves the CLOEXEC flags to do the work alone.
27 _ = std.os.linux.syscall3(.close_range, @intCast(first), std.math.maxInt(u32), 0);
28 }
29
30 pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!root.PtyMode {
31 // On Linux the master shares one termios with the slave, so what the
32 // session did with tcsetattr is one syscall away.
33 const t = try std.posix.tcgetattr(master);
34 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
35 }
36
37 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
38 var pgid: c.pid_t = 0;
39 if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
40 return @intCast(pgid);
41 }
42
43 pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void {
44 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
45 if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed;
46 }
src/server/pty.zig
Old New
@@ -1,12 +1,17 @@
1 //! PTY lifecycle for a session: forkpty with the user's shell — or with any 1 //! PTY lifecycle for a session: a pty forked through `server_os.forkPty` with
2 //! argv, which is how the e2e fixture drives a real client — blocking master 2 //! the user's shell — or with any argv, which is how the e2e fixture drives a
3 //! fd (the daemon's poll loop drives readiness), exit detection. 3 //! real client — blocking master fd (the daemon's poll loop drives readiness),
4 //! exit detection.
4 const std = @import("std"); 5 const std = @import("std");
5 const c = @cImport({ 6 const server_os = @import("server_os");
6 @cInclude("pty.h"); 7
7 @cInclude("stdlib.h"); 8 // Declared rather than @cInclude'd, and not `server_os`'s business either:
8 @cInclude("sys/ioctl.h"); 9 // setenv(3) and unsetenv(3) are POSIX, spelled the same on every OS mux runs
9 }); 10 // on, so there is nothing for a platform arm to choose between. Two externs
11 // also keep this file's whole C surface visible on two lines, instead of a
12 // header's entire namespace.
13 extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
14 extern "c" fn unsetenv(name: [*:0]const u8) c_int;
10 15
11 pub const Pty = struct { 16 pub const Pty = struct {
12 master: std.posix.fd_t, 17 master: std.posix.fd_t,
@@ -38,34 +43,29 @@ pub const Pty = struct {
38 /// on the far side of a pty is done here, so `spawn` and the e2e fixture 43 /// on the far side of a pty is done here, so `spawn` and the e2e fixture
39 /// cannot drift apart in what they hand the child. 44 /// cannot drift apart in what they hand the child.
40 pub fn spawnArgv(opts: SpawnArgvOptions) !Pty { 45 pub fn spawnArgv(opts: SpawnArgvOptions) !Pty {
41 var master: c_int = undefined; 46 const ws: server_os.Winsize = .{ .row = opts.rows, .col = opts.cols, .xpixel = 0, .ypixel = 0 };
42 var ws: c.struct_winsize = .{
43 .ws_row = opts.rows,
44 .ws_col = opts.cols,
45 .ws_xpixel = 0,
46 .ws_ypixel = 0,
47 };
48 47
49 // Diagnosed in the parent, where it can still be an error: an empty 48 // Diagnosed in the parent, where it can still be an error: an empty
50 // argv exec'd in the child is indistinguishable from a real exec 49 // argv exec'd in the child is indistinguishable from a real exec
51 // failure, and costs a fork to say so. 50 // failure, and costs a fork to say so.
52 if (opts.argv[0] == null) return error.EmptyArgv; 51 if (opts.argv[0] == null) return error.EmptyArgv;
53 52
54 const pid = c.forkpty(&master, null, null, &ws); 53 const f = try server_os.forkPty(ws);
55 if (pid < 0) return error.ForkPtyFailed; 54 const pid = f.pid;
55 const master = f.master;
56 56
57 if (pid == 0) { 57 if (pid == 0) {
58 // Child. xterm-256color: ghostty-vt understands more, but this 58 // Child. xterm-256color: ghostty-vt understands more, but this
59 // terminfo exists everywhere the shell will look. 59 // terminfo exists everywhere the shell will look.
60 _ = c.setenv("TERM", "xterm-256color", 1); 60 _ = setenv("TERM", "xterm-256color", 1);
61 // Overwrite (1), and a CONTRACT rather than a detail: this is a loop 61 // Overwrite (1), and a CONTRACT rather than a detail: this is a loop
62 // over an ordered slice, so a LATER pair beats an earlier one for the 62 // over an ordered slice, so a LATER pair beats an earlier one for the
63 // same key. That is what lets `extra_env` override a variable the 63 // same key. That is what lets `extra_env` override a variable the
64 // shell-integration injection set, and a reorder would invert it. 64 // shell-integration injection set, and a reorder would invert it.
65 for (opts.env) |kv| _ = if (kv.value) |v| 65 for (opts.env) |kv| _ = if (kv.value) |v|
66 c.setenv(kv.key.ptr, v.ptr, 1) 66 setenv(kv.key.ptr, v.ptr, 1)
67 else 67 else
68 c.unsetenv(kv.key.ptr); 68 unsetenv(kv.key.ptr);
69 69
70 // Ctrl-C must work in the session, and without this it does not: a 70 // Ctrl-C must work in the session, and without this it does not: a
71 // non-interactive shell sets SIGINT to SIG_IGN for anything it 71 // non-interactive shell sets SIGINT to SIG_IGN for anything it
@@ -85,24 +85,19 @@ pub const Pty = struct {
85 // session shell. Resetting here makes it order-independent. 85 // session shell. Resetting here makes it order-independent.
86 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); 86 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null);
87 87
88 // exit_group, never std.process.exit — see spawn.zig's fork child 88 // `exitNow`, never `std.process.exit` — see `server_os.exitNow`
89 // for the full reason: under link_libc that is exit(3), which 89 // for why.
90 // flushes stdio buffers inherited from the parent.
91 if (opts.stderr_fd) |fd| { 90 if (opts.stderr_fd) |fd| {
92 std.posix.dup2(fd, 2) catch std.os.linux.exit_group(126); 91 std.posix.dup2(fd, 2) catch server_os.exitNow(126);
93 // The dup left a spare copy at the caller's fd number and 92 // The dup left a spare copy at the caller's fd number and
94 // `pipe()` sets no CLOEXEC, so it would ride through exec into 93 // `pipe()` sets no CLOEXEC, so it would ride through exec into
95 // everything the client spawns. One handle, so the write end dies 94 // everything the client spawns. One handle, so the write end dies
96 // with the child's stderr and not later. 95 // with the child's stderr and not later.
97 if (fd > 2) std.posix.close(fd); 96 if (fd > 2) std.posix.close(fd);
98 } 97 }
99 // The barrier that needs no list: CLOEXEC is set fd by fd, and an 98 server_os.closeFrom(3);
100 // upgrade clears every one and must seal them again — two hand-kept
101 // lists that have to agree, or a key-carrying memfd rides into the
102 // shell. ENOSYS leaves the flags to do the work alone.
103 _ = std.os.linux.syscall3(.close_range, 3, std.math.maxInt(u32), 0);
104 std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {}; 99 std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {};
105 std.os.linux.exit_group(127); 100 server_os.exitNow(127);
106 } 101 }
107 102
108 // Parent. The master is THIS session's private handle and must never 103 // Parent. The master is THIS session's private handle and must never
@@ -127,34 +122,21 @@ pub const Pty = struct {
127 return .{ .master = master, .child = pid }; 122 return .{ .master = master, .child = pid };
128 } 123 }
129 124
130 /// The two line-discipline bits that decide who echoes a keystroke. Read off 125 pub const Mode = server_os.PtyMode;
131 /// the MASTER, which on Linux shares one termios with the slave, so what the
132 /// session did with tcsetattr is one syscall away. Polled, which is the only
133 /// option — the kernel notifies nobody when a mode changes.
134 pub const Mode = struct { icanon: bool, echo: bool };
135 126
136 pub fn mode(self: *const Pty) !Mode { 127 pub fn mode(self: *const Pty) !Mode {
137 const t = try std.posix.tcgetattr(self.master); 128 return server_os.ptyMode(self.master);
138 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
139 } 129 }
140 130
141 /// Equal to `child` means no foreground job: the kernel's "command 131 /// Equal to `child` means no foreground job: the kernel's "command
142 /// returned", with zero shell cooperation. No exit code and no output 132 /// returned", with zero shell cooperation. No exit code and no output
143 /// span; marks are for that. 133 /// span; marks are for that.
144 pub fn fgPgid(self: *const Pty) !std.posix.pid_t { 134 pub fn fgPgid(self: *const Pty) !std.posix.pid_t {
145 var pgid: c.pid_t = 0; 135 return server_os.ptyFgPgid(self.master);
146 if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
147 return @intCast(pgid);
148 } 136 }
149 137
150 pub fn resize(self: *Pty, cols: u16, rows: u16) !void { 138 pub fn resize(self: *Pty, cols: u16, rows: u16) !void {
151 var ws: c.struct_winsize = .{ 139 return server_os.setWinsize(self.master, .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 });
152 .ws_row = rows,
153 .ws_col = cols,
154 .ws_xpixel = 0,
155 .ws_ypixel = 0,
156 };
157 if (c.ioctl(self.master, c.TIOCSWINSZ, &ws) < 0) return error.IoctlFailed;
158 } 140 }
159 141
160 // Build a Pty from an fd and pid that already belong to this process. The 142 // Build a Pty from an fd and pid that already belong to this process. The
@@ -367,13 +349,16 @@ test "Pty: resize is visible via TIOCGWINSZ" {
367 349
368 try pty.resize(120, 40); 350 try pty.resize(120, 40);
369 351
370 var ws: c.struct_winsize = undefined; 352 // Asked of the kernel, not of `server_os`: the ioctl that reads the size
353 // back has to be a different call from the one that set it, or the test
354 // grades the platform arm against itself.
355 var ws: std.posix.winsize = undefined;
371 try std.testing.expectEqual( 356 try std.testing.expectEqual(
372 @as(c_int, 0), 357 @as(c_int, 0),
373 c.ioctl(pty.master, c.TIOCGWINSZ, &ws), 358 std.c.ioctl(pty.master, @intCast(std.c.T.IOCGWINSZ), &ws),
374 ); 359 );
375 try std.testing.expectEqual(@as(c_ushort, 120), ws.ws_col); 360 try std.testing.expectEqual(@as(u16, 120), ws.col);
376 try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row); 361 try std.testing.expectEqual(@as(u16, 40), ws.row);
377 } 362 }
378 363
379 test "Pty: mode reads the line discipline off the master" { 364 test "Pty: mode reads the line discipline off the master" {
@@ -482,12 +467,13 @@ test "Pty: spawnArgv runs an argv and propagates exit status" {
482 467
483 test "Pty: a daemon fd without CLOEXEC still does not reach the shell" { 468 test "Pty: a daemon fd without CLOEXEC still does not reach the shell" {
484 // pipe(2) sets no CLOEXEC — exactly the state an upgrade exec leaves 469 // pipe(2) sets no CLOEXEC — exactly the state an upgrade exec leaves
485 // the adopted fds in. The child looks for its own copy. 470 // the adopted fds in. The child looks for its own copy: through /dev/fd,
471 // which every OS mux runs on has.
486 const pipe = try std.posix.pipe(); 472 const pipe = try std.posix.pipe();
487 defer std.posix.close(pipe[0]); 473 defer std.posix.close(pipe[0]);
488 defer std.posix.close(pipe[1]); 474 defer std.posix.close(pipe[1]);
489 var cmd_buf: [96]u8 = undefined; 475 var cmd_buf: [96]u8 = undefined;
490 const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /proc/self/fd/{d} && exit 3; exit 0", .{pipe[1]}); 476 const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /dev/fd/{d} && exit 3; exit 0", .{pipe[1]});
491 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr }; 477 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr };
492 var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv }); 478 var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv });
493 defer pty.deinit(); 479 defer pty.deinit();