df389073
refactor: small-items batch — detach_key, one SIGPIPE owner, dead Pty.read, ensurePtyModeSent, deps caching, writeFrame golden pin
a73x 2026-08-12 14:20
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -23,6 +23,16 @@ fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct { | |||
| 23 | run.setName(b.fmt("build QUIC deps ({s})", .{name})); | 23 | run.setName(b.fmt("build QUIC deps ({s})", .{name})); |
| 24 | // Never cached by the build graph: the script's own marker file is the | 24 | // Never cached by the build graph: the script's own marker file is the |
| 25 | // cache, and it is the only thing that knows whether the libs are there. | 25 | // cache, and it is the only thing that knows whether the libs are there. |
| 26 | // | ||
| 27 | // Not an oversight, and the alternative was measured. Step.Run only | ||
| 28 | // treats a command as cacheable once it has an output arg, and | ||
| 29 | // addOutputFileArg insists on choosing the path — while this script's | ||
| 30 | // fixed `deps/quic/out/<target>` is a contract shared with `make deps`, | ||
| 31 | // `make clean-deps`, wan.sh's musl cross-build and linkQuic below. Under | ||
| 32 | // a build-chosen path the marker would live in a hash-named directory, | ||
| 33 | // so any argv change would re-download ~30MB rather than skip. The cost | ||
| 34 | // of leaving it uncached is one fork+exec that stats the marker and | ||
| 35 | // exits: ~1ms per build. | ||
| 26 | run.has_side_effects = true; | 36 | run.has_side_effects = true; |
| 27 | return .{ | 37 | return .{ |
| 28 | .step = &run.step, | 38 | .step = &run.step, |
| @@ -216,6 +226,12 @@ pub fn build(b: *std.Build) void { | |||
| 216 | }); | 226 | }); |
| 217 | proxy_mod.addImport("testtmp", testtmp_mod); | 227 | proxy_mod.addImport("testtmp", testtmp_mod); |
| 218 | 228 | ||
| 229 | // The client borrows ignoreSigpipe, which proxy owns. Declared here | ||
| 230 | // rather than beside the client's other imports because proxy_mod does | ||
| 231 | // not exist yet up there; proxy is a leaf, so this adds no cycle and | ||
| 232 | // teaches the proxy nothing. | ||
| 233 | client_mod.addImport("proxy", proxy_mod); | ||
| 234 | |||
| 219 | // Test helpers, built as real binaries because that is how the suite | 235 | // Test helpers, built as real binaries because that is how the suite |
| 220 | // uses them: rawmode is a deterministic stand-in for an editor (nvim's | 236 | // uses them: rawmode is a deterministic stand-in for an editor (nvim's |
| 221 | // redraw timing is its own business and it is not installed everywhere), | 237 | // redraw timing is its own business and it is not installed everywhere), |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,11 @@ const TmpDir = @import("testtmp").TmpDir; | |||
| 12 | const quic_client = @import("quic_client"); | 12 | const quic_client = @import("quic_client"); |
| 13 | const predict = @import("predict"); | 13 | const predict = @import("predict"); |
| 14 | const handoff = @import("handoff"); | 14 | const handoff = @import("handoff"); |
| 15 | // For ignoreSigpipe only, which proxy.zig owns; see its docstring for why | ||
| 16 | // the client is one of its callers and why the call site's order matters. | ||
| 17 | const proxy = @import("proxy"); | ||
| 18 | |||
| 19 | const detach_key: u8 = 0x1c; // Ctrl-\, the detach chord (see module doc). | ||
| 15 | 20 | ||
| 16 | var winch_flag = std.atomic.Value(bool).init(false); | 21 | var winch_flag = std.atomic.Value(bool).init(false); |
| 17 | 22 | ||
| @@ -306,13 +311,6 @@ const Transport = struct { | |||
| 306 | }; | 311 | }; |
| 307 | } | 312 | } |
| 308 | 313 | ||
| 309 | /// Idempotent, and it has to be: reconnect() releases the dead transport | ||
| 310 | /// on entry, and if the user then aborts, attach()'s `defer | ||
| 311 | /// transport.close()` closes the very same value again. A second | ||
| 312 | /// close(2) on a stale fd is EBADF, which std.posix maps to | ||
| 313 | /// `unreachable` — a panic, not an error. `--via` hides that (killing a | ||
| 314 | /// reaped child is harmless), `--sock` does not, and `--sock` is exactly | ||
| 315 | /// the locally-killed-daemon case. | ||
| 316 | /// The descriptor to wait on for readability. | 314 | /// The descriptor to wait on for readability. |
| 317 | /// | 315 | /// |
| 318 | /// For a socket and for `--via` this is also where frames are read | 316 | /// For a socket and for `--via` this is also where frames are read |
| @@ -407,6 +405,13 @@ const Transport = struct { | |||
| 407 | return .{ .frame = frame }; | 405 | return .{ .frame = frame }; |
| 408 | } | 406 | } |
| 409 | 407 | ||
| 408 | /// Idempotent, and it has to be: reconnect() releases the dead transport | ||
| 409 | /// on entry, and if the user then aborts, attach()'s `defer | ||
| 410 | /// transport.close()` closes the very same value again. A second | ||
| 411 | /// close(2) on a stale fd is EBADF, which std.posix maps to | ||
| 412 | /// `unreachable` — a panic, not an error. `--via` hides that (killing a | ||
| 413 | /// reaped child is harmless), `--sock` does not, and `--sock` is exactly | ||
| 414 | /// the locally-killed-daemon case. | ||
| 410 | fn close(self: *Transport) void { | 415 | fn close(self: *Transport) void { |
| 411 | if (self.conn.r == -1) return; // already released | 416 | if (self.conn.r == -1) return; // already released |
| 412 | defer self.conn = .{ .r = -1, .w = -1 }; | 417 | defer self.conn = .{ .r = -1, .w = -1 }; |
| @@ -483,7 +488,7 @@ fn waitReady( | |||
| 483 | const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch 0; | 488 | const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch 0; |
| 484 | if (n == 0) watch_stdin = false; | 489 | if (n == 0) watch_stdin = false; |
| 485 | if (n > 0) { | 490 | if (n > 0) { |
| 486 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) return error.UserAbort; | 491 | if (std.mem.indexOfScalar(u8, buf[0..n], detach_key) != null) return error.UserAbort; |
| 487 | // Not the abort key. Whether these bytes are kept or dropped | 492 | // Not the abort key. Whether these bytes are kept or dropped |
| 488 | // is the caller's policy, not this function's: on a first | 493 | // is the caller's policy, not this function's: on a first |
| 489 | // attach they are the user's first keystrokes and are owed to | 494 | // attach they are the user's first keystrokes and are owed to |
| @@ -565,7 +570,7 @@ fn readAnnounceAbortable( | |||
| 565 | const got = std.posix.read(std.posix.STDIN_FILENO, &in) catch 0; | 570 | const got = std.posix.read(std.posix.STDIN_FILENO, &in) catch 0; |
| 566 | if (got == 0) watch_stdin = false; | 571 | if (got == 0) watch_stdin = false; |
| 567 | if (got > 0) { | 572 | if (got > 0) { |
| 568 | if (std.mem.indexOfScalar(u8, in[0..got], 0x1c) != null) | 573 | if (std.mem.indexOfScalar(u8, in[0..got], detach_key) != null) |
| 569 | return error.UserAbort; | 574 | return error.UserAbort; |
| 570 | // waitReady's contract, and for waitReady's reason: on a | 575 | // waitReady's contract, and for waitReady's reason: on a |
| 571 | // first attach these are the user's first keystrokes and are | 576 | // first attach these are the user's first keystrokes and are |
| @@ -776,18 +781,11 @@ fn session( | |||
| 776 | carry: *std.ArrayList(u8), | 781 | carry: *std.ArrayList(u8), |
| 777 | ) !u8 { | 782 | ) !u8 { |
| 778 | // A daemon that dies mid-write must surface as an error return from | 783 | // A daemon that dies mid-write must surface as an error return from |
| 779 | // write(), not a fatal SIGPIPE. Zig's start.zig already installs a noop | 784 | // write(), not a fatal SIGPIPE. SIG_IGN survives exec while a handler |
| 780 | // SIGPIPE handler, so this is defence in depth rather than the thing that | 785 | // does not, so this must stay *after* the `--via` child is spawned (it |
| 781 | // makes EPIPE reachable. The one difference that matters: SIG_IGN survives | 786 | // is — attach() spawns, then calls us), or ssh and the remote proxy |
| 782 | // exec while a handler does not, so this must stay *after* the `--via` | 787 | // would inherit an ignored SIGPIPE they never asked for. |
| 783 | // child is spawned (it is — attach() spawns, then calls us), or ssh and | 788 | proxy.ignoreSigpipe(); |
| 784 | // the remote proxy would inherit an ignored SIGPIPE they never asked for. | ||
| 785 | var ign: std.posix.Sigaction = .{ | ||
| 786 | .handler = .{ .handler = std.posix.SIG.IGN }, | ||
| 787 | .mask = std.posix.sigemptyset(), | ||
| 788 | .flags = 0, | ||
| 789 | }; | ||
| 790 | std.posix.sigaction(std.posix.SIG.PIPE, &ign, null); | ||
| 791 | 789 | ||
| 792 | const stdin_fd = std.posix.STDIN_FILENO; | 790 | const stdin_fd = std.posix.STDIN_FILENO; |
| 793 | const stdout_fd = std.posix.STDOUT_FILENO; | 791 | const stdout_fd = std.posix.STDOUT_FILENO; |
| @@ -1190,7 +1188,7 @@ fn session( | |||
| 1190 | if (n == 0) { | 1188 | if (n == 0) { |
| 1191 | stdin_open = false; | 1189 | stdin_open = false; |
| 1192 | } else { | 1190 | } else { |
| 1193 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) { | 1191 | if (std.mem.indexOfScalar(u8, buf[0..n], detach_key) != null) { |
| 1194 | // Ctrl-\: detach and leave the session running. | 1192 | // Ctrl-\: detach and leave the session running. |
| 1195 | transport.writeFrame(.detach, "") catch {}; | 1193 | transport.writeFrame(.detach, "") catch {}; |
| 1196 | exit_msg = "mux: detached (session still running; run mux to reattach)"; | 1194 | exit_msg = "mux: detached (session still running; run mux to reattach)"; |
| @@ -1526,7 +1524,7 @@ fn drainStdinForQuit(stdin_fd: std.posix.fd_t, timeout_ms: u64) bool { | |||
| 1526 | std.Thread.sleep((timeout_ms - elapsed) * std.time.ns_per_ms); | 1524 | std.Thread.sleep((timeout_ms - elapsed) * std.time.ns_per_ms); |
| 1527 | return false; | 1525 | return false; |
| 1528 | } | 1526 | } |
| 1529 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) return true; | 1527 | if (std.mem.indexOfScalar(u8, buf[0..n], detach_key) != null) return true; |
| 1530 | // Anything else is dropped, and we keep waiting out the backoff — | 1528 | // Anything else is dropped, and we keep waiting out the backoff — |
| 1531 | // returning early here would collapse the pacing the moment the | 1529 | // returning early here would collapse the pacing the moment the |
| 1532 | // user touched a key. | 1530 | // user touched a key. |
src/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -360,10 +360,24 @@ pub fn composeDelta(alloc: std.mem.Allocator, payload: []const u8) !ComposedDelt | |||
| 360 | 360 | ||
| 361 | test "appendFrame encodes the same bytes writeFrame sends" { | 361 | test "appendFrame encodes the same bytes writeFrame sends" { |
| 362 | const alloc = std.testing.allocator; | 362 | const alloc = std.testing.allocator; |
| 363 | const golden = [_]u8{ 0x02, 3, 0, 0, 0, 'a', 'b', 'c' }; | ||
| 364 | |||
| 363 | var list: std.ArrayList(u8) = .empty; | 365 | var list: std.ArrayList(u8) = .empty; |
| 364 | defer list.deinit(alloc); | 366 | defer list.deinit(alloc); |
| 365 | try appendFrame(&list, alloc, .input, "abc"); | 367 | try appendFrame(&list, alloc, .input, "abc"); |
| 366 | try std.testing.expectEqualSlices(u8, &[_]u8{ 0x02, 3, 0, 0, 0, 'a', 'b', 'c' }, list.items); | 368 | try std.testing.expectEqualSlices(u8, &golden, list.items); |
| 369 | |||
| 370 | // writeFrame is driven for real rather than round-tripped: a round trip | ||
| 371 | // through readFrame passes even when writer and reader drift together, | ||
| 372 | // which is exactly the drift between the two encoders this test names. | ||
| 373 | const p = try std.posix.pipe(); | ||
| 374 | defer std.posix.close(p[0]); | ||
| 375 | try writeFrame(p[1], .input, "abc"); | ||
| 376 | std.posix.close(p[1]); | ||
| 377 | var sent: [golden.len]u8 = undefined; | ||
| 378 | try readExact(p[0], &sent); | ||
| 379 | try std.testing.expectEqualSlices(u8, &golden, &sent); | ||
| 380 | try std.testing.expectEqualSlices(u8, list.items, &sent); | ||
| 367 | } | 381 | } |
| 368 | 382 | ||
| 369 | test "appendFrame concatenates frames the way a queue would" { | 383 | test "appendFrame concatenates frames the way a queue would" { |
src/proxy.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,10 +15,12 @@ const TmpDir = @import("testtmp").TmpDir; | |||
| 15 | /// of to a std default (`std.options.keep_sigpipe`) another module could | 15 | /// of to a std default (`std.options.keep_sigpipe`) another module could |
| 16 | /// flip. | 16 | /// flip. |
| 17 | /// | 17 | /// |
| 18 | /// Exported because `muxd endpoint` writes its announce to the same stdout | 18 | /// Exported because the other two callers need the identical install: |
| 19 | /// this pump is about to use, before calling `run` — one installer rather | 19 | /// `muxd endpoint` writes its announce to the same stdout this pump is about |
| 20 | /// than a copy, so the two cannot drift. No protocol knowledge crosses the | 20 | /// to use, before calling `run`, and the client's session loop needs a dying |
| 21 | /// boundary, which is the only thing this file's import list forbids. | 21 | /// daemon to surface as EPIPE. One installer rather than three copies, so |
| 22 | /// they cannot drift. No protocol knowledge crosses the boundary, which is | ||
| 23 | /// the only thing this file's import list forbids. | ||
| 22 | /// | 24 | /// |
| 23 | /// The one real difference from the std default: SIG_IGN survives exec, a | 25 | /// The one real difference from the std default: SIG_IGN survives exec, a |
| 24 | /// handler does not. So a caller that SPAWNS must install this after the | 26 | /// handler does not. So a caller that SPAWNS must install this after the |
src/pty.zig
| Old | New | ||
|---|---|---|---|
| @@ -105,10 +105,6 @@ pub const Pty = struct { | |||
| 105 | return .{ .master = master, .child = pid }; | 105 | return .{ .master = master, .child = pid }; |
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | pub fn read(self: *Pty, buf: []u8) !usize { | ||
| 109 | return std.posix.read(self.master, buf); | ||
| 110 | } | ||
| 111 | |||
| 112 | pub fn write(self: *Pty, data: []const u8) !usize { | 108 | pub fn write(self: *Pty, data: []const u8) !usize { |
| 113 | return std.posix.write(self.master, data); | 109 | return std.posix.write(self.master, data); |
| 114 | } | 110 | } |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1108,7 +1108,7 @@ pub const Server = struct { | |||
| 1108 | /// fires on a change — so without this, a client joining a session that | 1108 | /// fires on a change — so without this, a client joining a session that |
| 1109 | /// is sitting quietly at a prompt would wait for the mode to move before | 1109 | /// is sitting quietly at a prompt would wait for the mode to move before |
| 1110 | /// it learned anything about it, which is to say forever. | 1110 | /// it learned anything about it, which is to say forever. |
| 1111 | fn sendPtyModeTo(self: *Server, i: usize) void { | 1111 | fn ensurePtyModeSent(self: *Server, i: usize) void { |
| 1112 | const flags = self.mode_sent orelse blk: { | 1112 | const flags = self.mode_sent orelse blk: { |
| 1113 | // Reached whenever an attach lands before the mode has ever | 1113 | // Reached whenever an attach lands before the mode has ever |
| 1114 | // been polled — which is not only the pre-first-pump case a | 1114 | // been polled — which is not only the pre-first-pump case a |
| @@ -1257,7 +1257,7 @@ pub const Server = struct { | |||
| 1257 | if (applied) self.recordSize(i); | 1257 | if (applied) self.recordSize(i); |
| 1258 | // Ahead of the state it describes, so a client can never be | 1258 | // Ahead of the state it describes, so a client can never be |
| 1259 | // holding grid content it has no mode for. | 1259 | // holding grid content it has no mode for. |
| 1260 | self.sendPtyModeTo(i); | 1260 | self.ensurePtyModeSent(i); |
| 1261 | self.sendResync(i, req.have_seq, req.have_epoch, size_changed and applied); | 1261 | self.sendResync(i, req.have_seq, req.have_epoch, size_changed and applied); |
| 1262 | }, | 1262 | }, |
| 1263 | .resize => { | 1263 | .resize => { |
| @@ -1376,7 +1376,7 @@ pub const Server = struct { | |||
| 1376 | // .resize/.attach arms — but only when the grid really went | 1376 | // .resize/.attach arms — but only when the grid really went |
| 1377 | // there. A refused attach must leave the slot at 0x0. | 1377 | // there. A refused attach must leave the slot at 0x0. |
| 1378 | if (applied) self.recordSize(slot); | 1378 | if (applied) self.recordSize(slot); |
| 1379 | self.sendPtyModeTo(slot); | 1379 | self.ensurePtyModeSent(slot); |
| 1380 | // Latest wins: a size change broadcasts, repainting every | 1380 | // Latest wins: a size change broadcasts, repainting every |
| 1381 | // client at the new attacher's size. A same-size join is | 1381 | // client at the new attacher's size. A same-size join is |
| 1382 | // the joiner's business alone — see sendResync. So is a | 1382 | // the joiner's business alone — see sendResync. So is a |