a73x

092b7229

docs: the tree stops describing the world before the convergence

a73x   2026-08-20 08:24

Commit message
docs: the tree stops describing the world before the convergence

Comments across src/ still described a two-driver world: a CLI session
loop that no longer exists, a wall tile that could only watch, chords
whose meanings had swapped, and seams that were "arriving" in work that
has since shipped. Each is now what the code does.

The largest were load-bearing wrong, not merely stale. server.zig's
pushInbound claimed to re-implement framing "rather than reuse
protocol.zig" — it calls proto.delimitFrame, and the paragraph argued a
duplication that is not there. interact.zig's teardown doc described the
e2e control mechanism that was MEASURED AND REJECTED as the current one.
wallview.zig said the wall has no SIGWINCH handler; it has one, and only
the promoted tile may answer it.

Two doc blocks had drifted off their functions (publishStats, ttySize)
and were sitting on the next declaration; both moved back. Three
restatements went (drainWake, replyStatsObserver, one duplicated test
banner) and rationale went in where a reader would have had to guess:
the scrollback header's 6 bytes, the u16-cast clamps that are not wall
limits, the notice buffer, ISIG/IXON, taken_over's cross-version
handler, and why a DA1 callback that returns the default must exist.

Also: project codenames ("phase 3c", "M18", "Task 2c", "M-web Task 5")
are meaningless without this repo's private history, so comments now
name the event instead — "before named sessions", "when QUIC landed" —
or drop the reference where the sentence stands without it. The
codenames stay where they belong: decisions.md, roadmap, handoff, and
commit messages like this one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/client.zig
Old New
@@ -23,8 +23,8 @@
23 //! interact.zig's and still is. 23 //! interact.zig's and still is.
24 const std = @import("std"); 24 const std = @import("std");
25 const Replica = @import("replica").Replica; 25 const Replica = @import("replica").Replica;
26 // For `interact.detach_key` and `interact.ttySize` on the dialling paths. 26 // For `interact.detach_key` only — the abort byte both opening waits watch
27 // Everything ELSE this module used to reach into interact for went with 27 // for. Everything ELSE this module used to reach into interact for went with
28 // the session loop. 28 // the session loop.
29 const interact = @import("interact"); 29 const interact = @import("interact");
30 const proto = @import("protocol"); 30 const proto = @import("protocol");
@@ -153,8 +153,6 @@ pub fn ringNeighbour(list: []const u8, current: []const u8, dir: Ring) ?[]const
153 /// Which chord is waiting on a `sessions_reply`. One field rather than a 153 /// Which chord is waiting on a `sessions_reply`. One field rather than a
154 /// flag per chord: a client can only have one question outstanding, so the 154 /// flag per chord: a client can only have one question outstanding, so the
155 /// enum makes that a fact instead of a rule every new chord must obey. 155 /// enum makes that a fact instead of a rule every new chord must obey.
156 /// `.wall` is not a switch and still belongs here — it asks the daemon the
157 /// same question, and only the answer's use differs.
158 pub const SwitchIntent = enum { none, new, next, prev }; 156 pub const SwitchIntent = enum { none, new, next, prev };
159 157
160 /// A chord that has asked the daemon for its session list, and the moment 158 /// A chord that has asked the daemon for its session list, and the moment
@@ -164,7 +162,7 @@ pub const SwitchIntent = enum { none, new, next, prev };
164 /// enum. A daemon older than `sessions_req` (0x0c) drops frames it does not 162 /// enum. A daemon older than `sessions_req` (0x0c) drops frames it does not
165 /// recognise — `else => {}` in its dispatch — so the question is not 163 /// recognise — `else => {}` in its dispatch — so the question is not
166 /// refused, it is never heard. An intent with no expiry then stays armed 164 /// refused, it is never heard. An intent with no expiry then stays armed
167 /// for the rest of the session: every `Ctrl-\ c/n/p/w` after it is a silent 165 /// for the rest of the session: every `Ctrl-\ c/n/p` after it is a silent
168 /// no-op, and the user is given no reason to suspect the daemon rather than 166 /// no-op, and the user is given no reason to suspect the daemon rather than
169 /// their keyboard. 167 /// their keyboard.
170 pub const PendingSwitch = struct { 168 pub const PendingSwitch = struct {
@@ -206,7 +204,7 @@ pub const PendingSwitch = struct {
206 /// The client's transport: a read fd and a write fd. For a unix socket they 204 /// The client's transport: a read fd and a write fd. For a unix socket they
207 /// are one and the same; under `--via` they are the child command's stdout 205 /// are one and the same; under `--via` they are the child command's stdout
208 /// and stdin. Nothing below the transport setup knows which it is — that 206 /// and stdin. Nothing below the transport setup knows which it is — that
209 /// blindness is the point of the M6 spike. 207 /// blindness is the point: transport is a swap, not a redesign.
210 const Conn = struct { r: std.posix.fd_t, w: std.posix.fd_t }; 208 const Conn = struct { r: std.posix.fd_t, w: std.posix.fd_t };
211 209
212 /// What came off the transport. Three outcomes, not two, and the third is 210 /// What came off the transport. Three outcomes, not two, and the third is
@@ -754,7 +752,7 @@ fn waitReady(
754 // Ctrl-C is just a byte and Ctrl-\ is the only way out. Watching only 752 // Ctrl-C is just a byte and Ctrl-\ is the only way out. Watching only
755 // the socket left the user with no way to stop for as long as the 753 // the socket left the user with no way to stop for as long as the
756 // handshake bound allows: measured at 14.6s on the default idle 754 // handshake bound allows: measured at 14.6s on the default idle
757 // timeout, against an endpoint that was never going to answer. M7's 755 // timeout, against an endpoint that was never going to answer. The
758 // uncapped retry loop is justified by the user having an abort key, 756 // uncapped retry loop is justified by the user having an abort key,
759 // so an abort key that stops working mid-handshake takes the 757 // so an abort key that stops working mid-handshake takes the
760 // justification with it. 758 // justification with it.
@@ -896,7 +894,8 @@ fn announceFailed(err: anyerror) bool {
896 return false; 894 return false;
897 } 895 }
898 896
899 /// What attach says when the open fails, and what it exits with. A pair 897 /// What `wallview.runAttach` says when the open fails, and what it exits
898 /// with. A pair
900 /// rather than a bare message because the abort paths are not silent: they 899 /// rather than a bare message because the abort paths are not silent: they
901 /// print a line AND exit 0, and splitting those two facts across two 900 /// print a line AND exit 0, and splitting those two facts across two
902 /// functions would let one drift from the other. 901 /// functions would let one drift from the other.
@@ -913,7 +912,7 @@ pub const OpenFailure = struct {
913 exit: u8, 912 exit: u8,
914 }; 913 };
915 914
916 /// The buffer `attach` hands `openFailure`. 915 /// The buffer `wallview.runAttach` hands `openFailure`.
917 /// 916 ///
918 /// Not derived, and it cannot be: PATH_MAX would bound the key and socket 917 /// Not derived, and it cannot be: PATH_MAX would bound the key and socket
919 /// paths, but the longest operands are argv strings — a `--via` command 918 /// paths, but the longest operands are argv strings — a `--via` command
@@ -943,9 +942,9 @@ fn failedMsg(buf: []u8, comptime fmt: []const u8, args: anytype) OpenFailure {
943 /// reported as a failure — and the exit says so too. 942 /// reported as a failure — and the exit says so too.
944 const open_aborted: OpenFailure = .{ .msg = "mux: aborted before attaching\n", .exit = 0 }; 943 const open_aborted: OpenFailure = .{ .msg = "mux: aborted before attaching\n", .exit = 0 };
945 944
946 /// What attach prints and exits with when `Transport.open` fails. Pure so 945 /// What the entry dial prints and exits with when `Transport.open` fails.
947 /// the whole error policy can be pinned the way `lostMsg` is; attach owns 946 /// Pure so the whole error policy can be pinned the way `lostMsg` is; the
948 /// only the printing. 947 /// caller owns only the printing.
949 /// 948 ///
950 /// `err` is `anyerror` by design, not by laziness: open unions error sets 949 /// `err` is `anyerror` by design, not by laziness: open unions error sets
951 /// from the key loader, the resolver, the QUIC client, ssh's spawn and 950 /// from the key loader, the resolver, the QUIC client, ssh's spawn and
@@ -1043,7 +1042,8 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure {
1043 }; 1042 };
1044 } 1043 }
1045 1044
1046 /// What `Ctrl-\ w` can fail with before a wall ever starts. `NoSpelling` 1045 /// What turning a `Target` back into a wall line can fail with — the
1046 /// wall-file record and a tile's label bar. `NoSpelling`
1047 /// is not a defect: the wall grammar (wall.zig) has no form for `--via`, 1047 /// is not a defect: the wall grammar (wall.zig) has no form for `--via`,
1048 /// so for that transport there is nothing truthful to write down. 1048 /// so for that transport there is nothing truthful to write down.
1049 pub const SpellingError = error{ NoSpelling, NoSpace }; 1049 pub const SpellingError = error{ NoSpelling, NoSpace };
@@ -1092,7 +1092,7 @@ pub fn warnWall(warned: *bool, what: []const u8, err: anyerror) void {
1092 } 1092 }
1093 1093
1094 /// The wall file is attach HISTORY: an attach that claims the grid writes 1094 /// The wall file is attach HISTORY: an attach that claims the grid writes
1095 /// its tile there (the wall-home-screen spec, phase 2). 1095 /// its tile there (the wall-home-screen spec).
1096 /// 1096 ///
1097 /// The rule the spec states is mechanical — "attaches at nonzero size", 1097 /// The rule the spec states is mechanical — "attaches at nonzero size",
1098 /// not "is a human" — and the CALLER is the enforcement, by which tiles ask 1098 /// not "is a human" — and the CALLER is the enforcement, by which tiles ask
@@ -1118,13 +1118,14 @@ pub fn warnWall(warned: *bool, what: []const u8, err: anyerror) void {
1118 /// KNOWN GAP, deliberate: a `mux wall` or muxweb that is ALREADY RUNNING 1118 /// KNOWN GAP, deliberate: a `mux wall` or muxweb that is ALREADY RUNNING
1119 /// reads the file once at startup, so it does not see a line added here 1119 /// reads the file once at startup, so it does not see a line added here
1120 /// until it restarts. The file is state, not a channel; a change feed is 1120 /// until it restarts. The file is state, not a channel; a change feed is
1121 /// out of scope for phase 2 (decisions.md). 1121 /// out of scope for now (decisions.md).
1122 pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, warned: *bool) void { 1122 pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, warned: *bool) void {
1123 const buf = alloc.alloc(u8, spellingCap(target)) catch return; 1123 const buf = alloc.alloc(u8, spellingCap(target)) catch return;
1124 defer alloc.free(buf); 1124 defer alloc.free(buf);
1125 // `.via` has no spelling in the wall grammar — an arbitrary command is 1125 // `.via` has no spelling in the wall grammar — an arbitrary command is
1126 // not an address — so that transport records nothing, silently. Same 1126 // not an address — so that transport records nothing, silently. Same
1127 // refusal `Ctrl-\ w` already makes, for the same reason. 1127 // refusal `wallview.tileLabel` works around for the label bar, for the
1128 // same reason.
1128 const spelling = wallSpelling(buf, target, proto.resolveName(name)) catch return; 1129 const spelling = wallSpelling(buf, target, proto.resolveName(name)) catch return;
1129 const path = wall.statePath(alloc) catch |err| { 1130 const path = wall.statePath(alloc) catch |err| {
1130 warnWall(warned, "no state directory", err); 1131 warnWall(warned, "no state directory", err);
@@ -1135,7 +1136,7 @@ pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, wa
1135 } 1136 }
1136 1137
1137 /// WHEN an attach becomes history: the first moment state arrives under 1138 /// WHEN an attach becomes history: the first moment state arrives under
1138 /// it, and exactly once per `session()` run. 1139 /// it, and exactly once per tile-pump run (`wallview.pumpTile`).
1139 /// 1140 ///
1140 /// The seam was "the dial succeeded" until review reproduced what that 1141 /// The seam was "the dial succeeded" until review reproduced what that
1141 /// costs. A dial that succeeds is not an attach that landed: the daemon 1142 /// costs. A dial that succeeds is not an attach that landed: the daemon
@@ -1143,7 +1144,7 @@ pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, wa
1143 /// SWITCH's refusal had somewhere to fall back to and so a place to undo 1144 /// SWITCH's refusal had somewhere to fall back to and so a place to undo
1144 /// the write. A FIRST attach refused the same way exited 1 with the line 1145 /// the write. A FIRST attach refused the same way exited 1 with the line
1145 /// stranded — a tile naming a session that never existed. So did an error 1146 /// stranded — a tile naming a session that never existed. So did an error
1146 /// out of `session()` before any state. 1147 /// out of the pump before any state.
1147 /// 1148 ///
1148 /// `state_since_attach` is the daemon's own answer to "did this attach 1149 /// `state_since_attach` is the daemon's own answer to "did this attach
1149 /// land", and it is the very flag both refusal paths read to decide there 1150 /// land", and it is the very flag both refusal paths read to decide there
@@ -1170,11 +1171,11 @@ pub fn recordOnState(
1170 recordTile(alloc, target, name, warned); 1171 recordTile(alloc, target, name, warned);
1171 } 1172 }
1172 1173
1173 /// The re-dial pacing, M7's numbers: iteration zero waits not at all, then 1174 /// The re-dial pacing, measured: iteration zero waits not at all, then
1174 /// 200ms doubling to a 2s cap — pacing a flapping link without ever giving 1175 /// 200ms doubling to a 2s cap — pacing a flapping link without ever giving
1175 /// up. Zero first because a transport that died a moment ago is 1176 /// up. Zero first because a transport that died a moment ago is
1176 /// overwhelmingly likely to accept a new connection right now (daemon 1177 /// overwhelmingly likely to accept a new connection right now (daemon
1177 /// restarts, link blips, killed proxies), and M7 measured a WAN resume at 1178 /// restarts, link blips, killed proxies), and a WAN resume measured at
1178 /// ~255ms of which ~200ms was this backoff and only ~54ms was the link: 1179 /// ~255ms of which ~200ms was this backoff and only ~54ms was the link:
1179 /// the wait was nearly the whole cost and bought nothing. 1180 /// the wait was nearly the whole cost and bought nothing.
1180 /// 1181 ///
@@ -1336,12 +1337,12 @@ test "handoff: endpoint-none rides the open pipe with no deadline paid" {
1336 1337
1337 test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { 1338 test "handoff: dead coordinates are a fast no, and the pipe is the fallback" {
1338 // A well-formed announce naming 127.0.0.1:1, where nothing listens. 1339 // A well-formed announce naming 127.0.0.1:1, where nothing listens.
1339 // The refusal is REAL — an ICMP unreachable comes back — and since M15 1340 // The refusal is REAL — an ICMP unreachable comes back — and both
1340 // Task 4 both socket paths in quic_client act on it, so this dial dies 1341 // socket paths in quic_client act on it, so this dial dies
1341 // in about one loopback round trip instead of running deadline_ms out. 1342 // in about one loopback round trip instead of running deadline_ms out.
1342 // The upper bound below is what proves that: it sits far under the 1343 // The upper bound below is what proves that: it sits far under the
1343 // budget, so an implementation that swallowed the refusal again (the 1344 // budget, so an implementation that swallowed the refusal again (the
1344 // pre-M15 bug: drain's `catch return` discarding ECONNREFUSED) would 1345 // bug this pins: drain's `catch return` discarding ECONNREFUSED) would
1345 // spend the whole 300ms and fail here. No lower bound — this measures 1346 // spend the whole 300ms and fail here. No lower bound — this measures
1346 // 2ms, so any floor would be pinning scheduler noise. 1347 // 2ms, so any floor would be pinning scheduler noise.
1347 // 1348 //
@@ -1393,7 +1394,7 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" {
1393 /// 1394 ///
1394 /// The CLI passes STDIN_FILENO as the abort fd — that is where the abort 1395 /// The CLI passes STDIN_FILENO as the abort fd — that is where the abort
1395 /// key arrives in production — so testing that spelling means briefly 1396 /// key arrives in production — so testing that spelling means briefly
1396 /// owning fd 0. (The fd is injectable since M-web Task 2; the -1 test 1397 /// owning fd 0. (The fd is injectable; the -1 test
1397 /// leans on this same harness to prove fd 0 is NOT read in that case.) Zig runs a file's tests one at a time in one 1398 /// leans on this same harness to prove fd 0 is NOT read in that case.) Zig runs a file's tests one at a time in one
1398 /// process, so this is safe as long as every caller restores; `deinit` 1399 /// process, so this is safe as long as every caller restores; `deinit`
1399 /// exists to make that a defer rather than a discipline. 1400 /// exists to make that a defer rather than a discipline.
@@ -1423,7 +1424,7 @@ const FakeStdin = struct {
1423 }; 1424 };
1424 1425
1425 test "handoff: the announce wait still answers the abort key" { 1426 test "handoff: the announce wait still answers the abort key" {
1426 // The property M7 spent a whole comment on, applied to the ssh half: an 1427 // The property the QUIC handshake wait keeps, applied to the ssh half: an
1427 // announce that never comes must not cost the user their way out. A 1428 // announce that never comes must not cost the user their way out. A
1428 // blocking read here would sit in read(2) with nothing watching stdin, 1429 // blocking read here would sit in read(2) with nothing watching stdin,
1429 // and on the reconnect path — raw mode, ISIG off — Ctrl-\ is the only 1430 // and on the reconnect path — raw mode, ISIG off — Ctrl-\ is the only
@@ -1527,8 +1528,8 @@ test "lostMsg: only a --via transport that never connected gets the new wording"
1527 try std.testing.expectEqualStrings("mux: connection to muxd lost", lostMsg(.{ .sock = "/run/muxd.sock" }, 0)); 1528 try std.testing.expectEqualStrings("mux: connection to muxd lost", lostMsg(.{ .sock = "/run/muxd.sock" }, 0));
1528 } 1529 }
1529 1530
1530 // The three tests below pin every line `attach` can print when the open 1531 // The three tests below pin every line the entry dial can print when the
1531 // fails — a policy that until now was reachable only by making a real dial 1532 // open fails — a policy that until now was reachable only by making a real dial
1532 // fail in a real terminal, which is why none of it was pinned at all. 1533 // fail in a real terminal, which is why none of it was pinned at all.
1533 // `openFailure` is pure, so each class is one call with a literal answer. 1534 // `openFailure` is pure, so each class is one call with a literal answer.
1534 1535
src/engine.zig
Old New
@@ -1,5 +1,5 @@
1 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal 1 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal
2 //! and TerminalStream behind the small surface muxd needs. 2 //! and TerminalStream behind the small surface mux needs.
3 const std = @import("std"); 3 const std = @import("std");
4 const vt = @import("ghostty-vt"); 4 const vt = @import("ghostty-vt");
5 5
@@ -326,8 +326,8 @@ pub const Engine = struct {
326 /// Visible screen (viewport only) with SGR/style sequences preserved — 326 /// Visible screen (viewport only) with SGR/style sequences preserved —
327 /// content only, no palette/mode side effects, so it is safe to paint 327 /// content only, no palette/mode side effects, so it is safe to paint
328 /// onto a host terminal (the client renderer) without clobbering its 328 /// onto a host terminal (the client renderer) without clobbering its
329 /// theme. History is never included; it stays daemon-side until the 329 /// theme. History is never included; it stays daemon-side and is
330 /// lazy-scrollback protocol lands (M3/M4). 330 /// fetched on demand instead (`dumpScrollback`).
331 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { 331 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
332 return self.formatSelection(alloc, "", self.viewportSelection()); 332 return self.formatSelection(alloc, "", self.viewportSelection());
333 } 333 }
@@ -444,8 +444,8 @@ pub const Engine = struct {
444 /// The window title the session set (OSC 0/2), or empty if it never 444 /// The window title the session set (OSC 0/2), or empty if it never
445 /// did. Sampled state, like `bracketedPaste`: the client mirrors it onto 445 /// did. Sampled state, like `bracketedPaste`: the client mirrors it onto
446 /// the host terminal, which is the only thing with a title bar. Nothing 446 /// the host terminal, which is the only thing with a title bar. Nothing
447 /// carried it before, which is why your terminal's title has been wrong 447 /// carried it before, which is why your terminal's title used to stay
448 /// under mux since M2. 448 /// wrong under mux.
449 /// 449 ///
450 /// Empty and "never set" are the same answer here, and callers treat 450 /// Empty and "never set" are the same answer here, and callers treat
451 /// them the same: see `sampleTermTitle` in server.zig for why mux 451 /// them the same: see `sampleTermTitle` in server.zig for why mux
@@ -570,7 +570,7 @@ pub const Engine = struct {
570 return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; 570 return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
571 } 571 }
572 572
573 /// Full reset (RIS): grid, modes, cursor, styles. Used by the client 573 /// Full reset (RIS): grid, modes, cursor, styles. Used by `replica.zig`
574 /// before applying each snapshot. 574 /// before applying each snapshot.
575 /// 575 ///
576 /// Also discards any queued side_events — data loss, not terminal 576 /// Also discards any queued side_events — data loss, not terminal
@@ -603,6 +603,11 @@ pub const Engine = struct {
603 /// Library defaults: VT220 conformance + ANSI color (`CSI ? 62;22 c`), 603 /// Library defaults: VT220 conformance + ANSI color (`CSI ? 62;22 c`),
604 /// the same modest identity xterm ships. Nothing here claims sixel or 604 /// the same modest identity xterm ships. Nothing here claims sixel or
605 /// windowing the replica cannot honor. 605 /// windowing the replica cannot honor.
606 ///
607 /// It has to be wired even though it returns the default: ghostty-vt's
608 /// stock handler answers only when the embedder supplies one, and an
609 /// unanswered DA1 is the barrier TUIs block on — a flat second off
610 /// every nvim start and quit before this existed.
606 fn onDeviceAttributes(_: *vt.TerminalStream.Handler) DeviceAttributes { 611 fn onDeviceAttributes(_: *vt.TerminalStream.Handler) DeviceAttributes {
607 return .{}; 612 return .{};
608 } 613 }
@@ -977,7 +982,7 @@ test "Engine: alt-screen state survives snapshot into fresh engine" {
977 try std.testing.expect(std.mem.indexOf(u8, plain_b, "alt screen text") != null); 982 try std.testing.expect(std.mem.indexOf(u8, plain_b, "alt screen text") != null);
978 983
979 // Leaving the alt screen on the replica reveals the primary content — 984 // Leaving the alt screen on the replica reveals the primary content —
980 // dumpState carries both screens (M3). 985 // dumpState carries both screens.
981 b.feed("\x1b[?1049l"); 986 b.feed("\x1b[?1049l");
982 const primary_b = try b.dumpPlain(alloc); 987 const primary_b = try b.dumpPlain(alloc);
983 defer alloc.free(primary_b); 988 defer alloc.free(primary_b);
src/interact.zig
Old New
@@ -11,12 +11,15 @@
11 //! What deliberately does NOT live here is how a transport is BUILT or what 11 //! What deliberately does NOT live here is how a transport is BUILT or what
12 //! a chord MEANS. Targets, dialling, reconnect backoff and the handoff are 12 //! a chord MEANS. Targets, dialling, reconnect backoff and the handoff are
13 //! client.zig's; `PrefixFilter` answers "which action was typed" and every 13 //! client.zig's; `PrefixFilter` answers "which action was typed" and every
14 //! driver decides for itself what that action does to its own world — a 14 //! driver decides what that action does to its own world: in a zoomed
15 //! plain client's `.detach` ends its session, a wall tile's unzooms it. 15 //! tile `.detach` detaches and ends the run, `.wall` unzooms
16 //! (`wallview.zoomChord`).
16 //! 17 //!
17 //! Drivers are the CLI client (client.zig) and, from the wall's phase 3 18 //! There is one driver: a wall tile's pump (`wallview.pumpTile`). Since the
18 //! convergence on, a wall tile. Both hold their own transport and their own 19 //! convergence, `mux [TARGET]` is a wall of one tile entered zoomed, so a
19 //! replica; nothing here is a singleton and nothing here dials. 20 //! plain client and a tile are the same code path. Every tile holds its own
21 //! transport and its own replica; nothing here is a singleton and nothing
22 //! here dials.
20 //! 23 //!
21 //! The transport is taken as `anytype` throughout rather than by name. That 24 //! The transport is taken as `anytype` throughout rather than by name. That
22 //! is a layering fact, not a generality wish: `Transport` is built out of 25 //! is a layering fact, not a generality wish: `Transport` is built out of
@@ -47,7 +50,7 @@ pub const detach_key: u8 = 0x1c;
47 /// The attached client's keybinding layer: Ctrl-\ selects a command rather 50 /// The attached client's keybinding layer: Ctrl-\ selects a command rather
48 /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new 51 /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new
49 /// session, `n` and `p` step to the next and previous one, `l` skips to the 52 /// session, `n` and `p` step to the next and previous one, `l` skips to the
50 /// last one visited, `w` shows the wall of this daemon's sessions; any other 53 /// last one visited, `w` unzooms to the wall of tiles; any other
51 /// key is dropped along with the prefix. Dropping is not a loss — a literal 54 /// key is dropped along with the prefix. Dropping is not a loss — a literal
52 /// 0x1c never reached the pty before this layer existed either. 55 /// 0x1c never reached the pty before this layer existed either.
53 /// 56 ///
@@ -59,9 +62,10 @@ pub const detach_key: u8 = 0x1c;
59 /// Public because the CLI wall's ZOOMED tile needs the same layer over the 62 /// Public because the CLI wall's ZOOMED tile needs the same layer over the
60 /// same keys (wallview.zig): a zoomed tile is a session on this terminal, 63 /// same keys (wallview.zig): a zoomed tile is a session on this terminal,
61 /// and a second copy of this table would be a twin that drifts. What each 64 /// and a second copy of this table would be a twin that drifts. What each
62 /// action MEANS is the caller's — `.detach` leaves a client's session and 65 /// action MEANS is the caller's — in a zoomed tile `.detach` detaches and
63 /// unzooms the wall's tile, `.next_session` steps the client's session ring 66 /// ends the run while `.wall` unzooms, and `.next_session` moves the zoom
64 /// and moves the wall's zoom — but which byte spells it is one table. 67 /// to the next session (`wallview.zoomChord`) — but which byte spells it
68 /// is one table.
65 pub const PrefixFilter = struct { 69 pub const PrefixFilter = struct {
66 /// Callers switch on it, so a new variant is additive. 70 /// Callers switch on it, so a new variant is additive.
67 pub const Action = enum { none, detach, new_session, next_session, prev_session, last_session, wall }; 71 pub const Action = enum { none, detach, new_session, next_session, prev_session, last_session, wall };
@@ -103,11 +107,10 @@ pub const PrefixFilter = struct {
103 // command key drops itself and lets the REST of the 107 // command key drops itself and lets the REST of the
104 // read through, while a chord ends the chunk and drops 108 // read through, while a chord ends the chunk and drops
105 // whatever was typed behind it. `l` is a chord now, so 109 // whatever was typed behind it. `l` is a chord now, so
106 // it behaves like `n` and `p` and not like `z`. The 110 // it behaves like `n` and `p` and not like `z`, and the
107 // client has no meaning for the action and swallows it 111 // bytes behind it are gone either way — which is the
108 // (see the session loop), but the bytes behind it are 112 // rule every chord already keeps, for the reason argued
109 // gone either way — which is the rule every chord 113 // above.
110 // already keeps, for the reason argued above.
111 'l' => return .{ .forward = buf[0..kept], .action = .last_session }, 114 'l' => return .{ .forward = buf[0..kept], .action = .last_session },
112 'w' => return .{ .forward = buf[0..kept], .action = .wall }, 115 'w' => return .{ .forward = buf[0..kept], .action = .wall },
113 else => {}, 116 else => {},
@@ -240,12 +243,6 @@ fn onWinch(_: c_int) callconv(.c) void {
240 winch_flag.store(true, .release); 243 winch_flag.store(true, .release);
241 } 244 }
242 245
243 /// This terminal's size, or null when there is no terminal to measure.
244 ///
245 /// Public because a driver that lays a screen out BEFORE it has a Core to
246 /// measure it needs the same answer: the CLI wall cuts its stripes from the
247 /// terminal at startup, and a second copy of this would be a twin that
248 /// drifts on exactly the 0x0 case below.
249 /// Arm SIGWINCH, so `Core.winch` has something to answer. 246 /// Arm SIGWINCH, so `Core.winch` has something to answer.
250 /// 247 ///
251 /// Public because the driver that owns the terminal is not always a Core. 248 /// Public because the driver that owns the terminal is not always a Core.
@@ -262,12 +259,23 @@ pub fn watchWinch() void {
262 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); 259 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
263 } 260 }
264 261
262 /// This terminal's size, or null when there is no terminal to measure.
263 ///
264 /// Public because a driver that lays a screen out BEFORE it has a Core to
265 /// measure it needs the same answer: the CLI wall cuts its stripes from the
266 /// terminal at startup, and a second copy of this would be a twin that
267 /// drifts on exactly the 0x0 case below.
265 pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { 268 pub fn ttySize(fd: std.posix.fd_t) ?proto.Size {
266 if (!std.posix.isatty(fd)) return null; 269 if (!std.posix.isatty(fd)) return null;
267 var ws: std.posix.winsize = undefined; 270 var ws: std.posix.winsize = undefined;
268 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; 271 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
269 // A pty can report 0x0 (e.g. `script` with piped stdin); a zero-sized 272 // A pty can report 0x0 (e.g. `script` with piped stdin); a zero-sized
270 // grid is invalid for the engine. Treat it as "unknown". 273 // grid is invalid for the engine. Treat it as "unknown".
274 //
275 // The floor is 2, not 1, because that is the size the daemon will
276 // actually run a session at (`min_session_cols` in server.zig): a
277 // terminal we measured at 1x1 and quoted would be refused there, so
278 // "too small to use" and "unknown" get the same answer here.
271 if (ws.col < 2 or ws.row < 2) return null; 279 if (ws.col < 2 or ws.row < 2) return null;
272 return .{ .cols = ws.col, .rows = ws.row }; 280 return .{ .cols = ws.col, .rows = ws.row };
273 } 281 }
@@ -306,8 +314,9 @@ const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
306 314
307 /// The half that is about holding a SESSION on somebody's terminal: the 315 /// The half that is about holding a SESSION on somebody's terminal: the
308 /// mouse modes its wheel is read out of, and nothing else. A wall tile's 316 /// mouse modes its wheel is read out of, and nothing else. A wall tile's
309 /// promote writes exactly this (`Core.claimTerminal`) — the wall owns the 317 /// promote writes this first (`Core.claimTerminal`), then the session's own
310 /// screen for its whole life and a tile may not touch it. 318 /// modes on top — the wall owns the screen for its whole life and a tile
319 /// may not touch it.
311 /// 320 ///
312 /// Spelled as a shared constant rather than repeated, so the pairing is 321 /// Spelled as a shared constant rather than repeated, so the pairing is
313 /// structural: `terminal_setup` and `wall_setup` are both built from these 322 /// structural: `terminal_setup` and `wall_setup` are both built from these
@@ -366,11 +375,13 @@ fn inClientCapture(comptime dec: u16) bool {
366 /// It sits SECOND TO LAST, and that placement is load-bearing even though 375 /// It sits SECOND TO LAST, and that placement is load-bearing even though
367 /// the title stack and the alternate screen have nothing to do with each 376 /// the title stack and the alternate screen have nothing to do with each
368 /// other. `?1049l` must remain the final bytes a tty client writes: the 377 /// other. `?1049l` must remain the final bytes a tty client writes: the
369 /// e2e doctored control for the pty capture (test/e2e.sh, tp1) appends 378 /// e2e doctored control for the pty capture (test/e2e.sh, tp1) asserts the
370 /// bytes after the capture's trailing alt-screen exit, and `render` replays 379 /// capture's last 8 bytes ARE that exit, then drops them so its appended
371 /// only up to the LAST one — so a teardown that stops ending there turns 380 /// row lands INSIDE the alt screen — `render` replays only up to the last
372 /// that control into a no-op that can never fail. Measured, not guessed: 381 /// alt-screen exit, so an append after one changes nothing. Measured, not
373 /// appending the pop after `?1049l` is what made that check fire. 382 /// guessed: the plain append rendered byte-identical, i.e. a control that
383 /// could never fire. A teardown that stops ending in `?1049l` now fails
384 /// that tail assertion instead of silently going back to a no-op.
374 /// 385 ///
375 /// It is here because the question was ANSWERED, not assumed: the operator 386 /// It is here because the question was ANSWERED, not assumed: the operator
376 /// ran the push/set/pop probe in a bare Alacritty window on 2026-08-15 and 387 /// ran the push/set/pop probe in a bare Alacritty window on 2026-08-15 and
@@ -590,8 +601,10 @@ fn appendTermTitle(
590 /// as the only way it may fail. Everything else it refuses, it refuses by 601 /// as the only way it may fail. Everything else it refuses, it refuses by
591 /// writing nothing, which is what makes "empty buffer means refusal" above 602 /// writing nothing, which is what makes "empty buffer means refusal" above
592 /// a rule rather than a hope. `anytype` accepts a builder that fails some 603 /// a rule rather than a hope. `anytype` accepts a builder that fails some
593 /// other way, and that error propagates out of `session()` and ends the 604 /// other way, and that error propagates out of `Core.frame` into the
594 /// client: not something a stray clipboard byte gets to do. `Value` is 605 /// driver's pump, which abandons the rest of the read
606 /// (`wallview.pumpTile`'s `break :frames`): not something a stray clipboard
607 /// byte gets to do. `Value` is
595 /// comptime for the same reason — it names the contract, and it lets each 608 /// comptime for the same reason — it names the contract, and it lets each
596 /// caller's value coerce to the type its builder actually declares. 609 /// caller's value coerce to the type its builder actually declares.
597 fn writeSideChannel( 610 fn writeSideChannel(
@@ -611,10 +624,9 @@ fn writeSideChannel(
611 624
612 // ---- prediction -------------------------------------------------------- 625 // ---- prediction --------------------------------------------------------
613 // 626 //
614 // The overlay is a display decision and nothing else. It never writes to 627 // Nothing below writes to the replica — see the invariants at the top of the
615 // the replica, so the replica keeps meaning exactly "what the daemon said" 628 // file. It is why the replica stays comparable to `muxd dump` at every
616 // and stays comparable to `muxd dump` at every instant. Everything below 629 // instant.
617 // either reads the replica or paints on top of it.
618 630
619 /// What the replica shows at one cell — the `prev_ch` a prediction is 631 /// What the replica shows at one cell — the `prev_ch` a prediction is
620 /// judged against later. 632 /// judged against later.
@@ -635,10 +647,10 @@ fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.Curso
635 /// replica has taken the frame, never before: the whole question is what 647 /// replica has taken the frame, never before: the whole question is what
636 /// the authoritative state says now. 648 /// the authoritative state says now.
637 /// 649 ///
638 /// Private, with `paintOverlay` and `offerKeystroke`, and that is the 650 /// Private, with `paintOverlay` and `offerKeystroke`: the wall's pump
639 /// change phase 3b made: the CLI wall used to call all three from a pump 651 /// drives a `Core` rather than hand-rolling the client's input path, so
640 /// that hand-rolled the client's input path, and now it drives `Core` 652 /// nothing outside this file reaches the overlay.
641 /// instead. The overlay is the one place the "never enters the replica" 653 /// The overlay is the one place the "never enters the replica"
642 /// rule is kept, so the fewer doors into it the better — if a driver ever 654 /// rule is kept, so the fewer doors into it the better — if a driver ever
643 /// needs one of these back, that is a second implementation announcing 655 /// needs one of these back, that is a second implementation announcing
644 /// itself. 656 /// itself.
@@ -722,7 +734,8 @@ fn offerKeystroke(
722 ) void { 734 ) void {
723 if (chunk.len != 1) { 735 if (chunk.len != 1) {
724 // An escape sequence, a multi-byte character, or a paste. None is 736 // An escape sequence, a multi-byte character, or a paste. None is
725 // one cell's worth of change and M9 speculates about none of them. 737 // one cell's worth of change, and prediction speculates about none
738 // of them.
726 // The decision is made here, so the count is recorded here — a 739 // The decision is made here, so the count is recorded here — a
727 // paste's lead byte is printable, so handing it to predictAt would 740 // paste's lead byte is printable, so handing it to predictAt would
728 // predict the paste's first character instead of refusing it. 741 // predict the paste's first character instead of refusing it.
@@ -864,15 +877,16 @@ pub const Routed = enum {
864 /// Done here, and it changed nothing — the driver's `continue`. 877 /// Done here, and it changed nothing — the driver's `continue`.
865 skip, 878 skip,
866 /// The replica took state from it: a snapshot, or a delta it accepted. 879 /// The replica took state from it: a snapshot, or a delta it accepted.
867 /// Named apart from `handled` because both drivers hang their own 880 /// Named apart from `handled` because the driver hangs its own
868 /// bookkeeping off the first state of an attach — the CLI writes its 881 /// bookkeeping off the first state of an attach — the tile records
869 /// wall tile, a wall tile narrates `[up]` on its label bar. 882 /// itself in the wall file (`client.recordOnState`) and narrates `[up]`
883 /// on its label bar.
870 state, 884 state,
871 /// The replica took state and then refused what arrived, and can no 885 /// The replica took state and then refused what arrived, and can no
872 /// longer be trusted. Both drivers answer by re-attaching from scratch 886 /// longer be trusted. The driver answers by re-attaching from scratch,
873 /// (quoting no seq — the whole problem is that what we hold is 887 /// quoting no seq and no epoch — the whole problem is that what we hold
874 /// untrusted), but with their own attach frame: a client quotes its 888 /// is untrusted — at whatever size its zoom claims
875 /// size and session name, a tile quotes whatever its zoom claims. 889 /// (`wallview.sendAttach`).
876 resync, 890 resync,
877 /// Not the Core's. `exit_status` and `taken_over` are the session's 891 /// Not the Core's. `exit_status` and `taken_over` are the session's
878 /// LIFECYCLE and `sessions_reply` is the daemon's session list; what 892 /// LIFECYCLE and `sessions_reply` is the daemon's session list; what
@@ -934,22 +948,23 @@ pub const Claim = enum { none, whole, session };
934 /// 948 ///
935 /// * `init` / `deinit` — the Core owns its Engine, its overlay and, once 949 /// * `init` / `deinit` — the Core owns its Engine, its overlay and, once
936 /// `takeTerminal` has run, the terminal's mode. `deinit` puts all three 950 /// `takeTerminal` has run, the terminal's mode. `deinit` puts all three
937 /// back, terminal first so anything the driver prints afterwards lands 951 /// back; see there for the order.
938 /// on the normal screen. 952 /// * `takeTerminal` + `ownTerminal` are the WHOLE-terminal path: raw mode
939 /// * `takeTerminal` once, then `ownTerminal` before handling ANY frame. 953 /// here, alternate screen on the first frame. No driver takes it today —
940 /// A driver that already owns the screen and lends it to one session at 954 /// the wall arms raw mode and SIGWINCH itself, and every session is a
941 /// a time uses `claimTerminal`/`releaseTerminal` instead — see `Claim`. 955 /// tile, which uses `claimTerminal`/`releaseTerminal` instead. See
956 /// `Claim`.
942 /// * per pass: `winch`, then `idle`. 957 /// * per pass: `winch`, then `idle`.
943 /// * per frame: `frame(type, payload)` routes it and returns what is 958 /// * per frame: `frame(type, payload)` routes it and returns what is
944 /// LEFT (see `Routed`) — the replica's apply included, because a driver 959 /// LEFT (see `Routed`) — the replica's apply included, because a driver
945 /// that applied on its own would be a second exhaustive switch over the 960 /// that applied on its own would be a second exhaustive switch over the
946 /// wire, which is what this replaced. 961 /// wire, which is what this replaced.
947 /// * per read of stdin: `readTyped`, the driver acts on the action, then 962 /// * per read of stdin: `readTyped`, then the driver acts on the action
948 /// `forward` sends what is left. A driver whose keyboard is on another 963 /// and passes the rest to `forward`. The wall's keyboard is on another
949 /// thread (the wall) skips `readTyped` and calls `forward` with what 964 /// thread, so it feeds its own `PrefixFilter` and calls `forward` with
950 /// that thread handed over — the chord layer is the same table either 965 /// what that thread handed over — the chord layer is the same table
951 /// way, and everything below it (mouse, wheel, scrollback, prediction) 966 /// either way, and everything below it (mouse, wheel, scrollback,
952 /// is in `forward`. 967 /// prediction) is in `forward`.
953 /// * around a reconnect: `dropScrollView` before, `reattached` after. 968 /// * around a reconnect: `dropScrollView` before, `reattached` after.
954 /// 969 ///
955 /// What it depends on: an Engine, a Replica, the prediction overlay, the 970 /// What it depends on: an Engine, a Replica, the prediction overlay, the
@@ -964,8 +979,10 @@ pub const Claim = enum { none, whole, session };
964 /// no, and the wall paints the stripe from `grid()` instead. 979 /// no, and the wall paints the stripe from `grid()` instead.
965 pub const Core = struct { 980 pub const Core = struct {
966 alloc: std.mem.Allocator, 981 alloc: std.mem.Allocator,
967 /// The user's terminal. `in_fd` is the descriptor the driver polls and 982 /// The user's terminal. `in_fd` is the tty this session is typed at —
968 /// `readTyped` reads; `out_fd` is everything this paints on. 983 /// `readTyped` reads it, and a driver whose keyboard is on another
984 /// thread passes it only for `is_tty` and the termios restore.
985 /// `out_fd` is everything this paints on.
969 in_fd: std.posix.fd_t, 986 in_fd: std.posix.fd_t,
970 out_fd: std.posix.fd_t, 987 out_fd: std.posix.fd_t,
971 /// Whether there is a terminal to own at all. A client whose stdin is a 988 /// Whether there is a terminal to own at all. A client whose stdin is a
@@ -1043,7 +1060,8 @@ pub const Core = struct {
1043 /// The same, at a size the driver already measured. 1060 /// The same, at a size the driver already measured.
1044 /// 1061 ///
1045 /// For a driver whose layout was cut from ONE reading of the terminal — 1062 /// For a driver whose layout was cut from ONE reading of the terminal —
1046 /// the wall measures once at startup and has no SIGWINCH handler — so 1063 /// the wall measures at startup and re-reads the terminal only through
1064 /// the promoted tile that answers the SIGWINCH (`setWallSize`) — so
1047 /// that its tiles clip to the size its stripes were cut from, rather 1065 /// that its tiles clip to the size its stripes were cut from, rather
1048 /// than to whatever a second ioctl says after the user dragged a corner. 1066 /// than to whatever a second ioctl says after the user dragged a corner.
1049 /// Two answers to "how big is this terminal" inside one screen is a 1067 /// Two answers to "how big is this terminal" inside one screen is a
@@ -1094,6 +1112,11 @@ pub const Core = struct {
1094 var raw = orig; 1112 var raw = orig;
1095 raw.lflag.ICANON = false; 1113 raw.lflag.ICANON = false;
1096 raw.lflag.ECHO = false; 1114 raw.lflag.ECHO = false;
1115 // ISIG and IXON off because those keys belong to the SESSION, not
1116 // to mux: Ctrl-C has to reach the remote shell as a byte, and
1117 // Ctrl-S must not freeze the local terminal out from under a
1118 // session that wanted it. It is also why `Ctrl-\` is the only way
1119 // out — nothing else here raises a signal any more.
1097 raw.lflag.ISIG = false; 1120 raw.lflag.ISIG = false;
1098 raw.iflag.IXON = false; 1121 raw.iflag.IXON = false;
1099 raw.iflag.ICRNL = false; 1122 raw.iflag.ICRNL = false;
@@ -1116,6 +1139,10 @@ pub const Core = struct {
1116 /// Enter the alternate screen, once, on the first frame that proves the 1139 /// Enter the alternate screen, once, on the first frame that proves the
1117 /// transport works. 1140 /// transport works.
1118 /// 1141 ///
1142 /// No driver takes this path today — every session is a wall tile and
1143 /// claims through `claimTerminal`. What follows is what any future
1144 /// whole-terminal client has to keep.
1145 ///
1119 /// Entering at setup would erase whatever the `--via` command wrote to its 1146 /// Entering at setup would erase whatever the `--via` command wrote to its
1120 /// inherited stderr (ssh reports auth and connection failures hundreds of 1147 /// inherited stderr (ssh reports auth and connection failures hundreds of
1121 /// ms after spawn), and would blank the screen for the whole of a hang like 1148 /// ms after spawn), and would blank the screen for the whole of a hang like
@@ -1452,6 +1479,8 @@ pub const Core = struct {
1452 /// view moved. Ignored once the view is live again: the page would be 1479 /// view moved. Ignored once the view is live again: the page would be
1453 /// painted over a screen it no longer describes. 1480 /// painted over a screen it no longer describes.
1454 fn scrollbackPage(self: *Core, payload: []const u8) !Pass { 1481 fn scrollbackPage(self: *Core, payload: []const u8) !Pass {
1482 // 6 = the `scrollback_chunk` header (u32 start, u16 count); the
1483 // rows follow it. Short of that there is nothing to render.
1455 if (self.scroll_rows == 0 or payload.len < 6) return .skip; 1484 if (self.scroll_rows == 0 or payload.len < 6) return .skip;
1456 if (!self.beginPaint()) return .carry_on; 1485 if (!self.beginPaint()) return .carry_on;
1457 defer self.endPaint(); 1486 defer self.endPaint();
@@ -1505,6 +1534,10 @@ pub const Core = struct {
1505 effect, 1534 effect,
1506 appendHostEffect, 1535 appendHostEffect,
1507 ), 1536 ),
1537 // Nothing to write: a `.reply` is an answer to the DAEMON, and
1538 // `Core.frame` never routes one here — `.selection_reply` is in
1539 // the `.skip` set above. Named rather than swept so that a
1540 // decoder that starts producing one is an edit here.
1508 .reply => {}, 1541 .reply => {},
1509 } 1542 }
1510 } 1543 }
@@ -1588,8 +1621,10 @@ pub const Core = struct {
1588 .exit_status, .taken_over, .sessions_reply => return .not_mine, 1621 .exit_status, .taken_over, .sessions_reply => return .not_mine,
1589 // Named rather than swept into the `else`, so that adding a 1622 // Named rather than swept into the `else`, so that adding a
1590 // meaning for one of them is an edit here and not a new switch 1623 // meaning for one of them is an edit here and not a new switch
1591 // somewhere else. Every one is either a reply to a request only 1624 // somewhere else. Every one is either part of muxa's
1592 // muxa makes, or a frame that travels the other way. 1625 // conversation with the daemon (its replies, and the
1626 // `cmd_state` pushes it subscribes to) or a frame that travels
1627 // the other way.
1593 .stats_reply, 1628 .stats_reply,
1594 .endpoint_reply, 1629 .endpoint_reply,
1595 .cmd_state, 1630 .cmd_state,
@@ -1834,9 +1869,9 @@ test "interact: an unknown command key is swallowed with its prefix" {
1834 try std.testing.expectEqualStrings("d", next.forward); 1869 try std.testing.expectEqualStrings("d", next.forward);
1835 } 1870 }
1836 1871
1837 // `l` has no meaning in a client yet (the switch swallows it) but the TABLE 1872 // The TABLE names `l` and `wallview.zoomChord` gives it meaning; what this
1838 // must name it, because the wall's zoomed tile reads its chords out of this 1873 // pins is the chord layer, not the zoom move.
1839 // same table. Split across reads for the same reason every other chord is: 1874 // Split across reads for the same reason every other chord is:
1840 // a read boundary is not a chord boundary. 1875 // a read boundary is not a chord boundary.
1841 test "interact: Ctrl-\\ l is a chord in the table, whoever acts on it" { 1876 test "interact: Ctrl-\\ l is a chord in the table, whoever acts on it" {
1842 var f: PrefixFilter = .{}; 1877 var f: PrefixFilter = .{};
@@ -2229,7 +2264,7 @@ test "prediction: a chunk that is not one printable byte is never speculated abo
2229 2264
2230 // A paste: several printable bytes in one read. This is the shape whose 2265 // A paste: several printable bytes in one read. This is the shape whose
2231 // lead byte would sail through the printability check, so the length 2266 // lead byte would sail through the printability check, so the length
2232 // guard is the only thing refusing it — and M9 refuses it, because a 2267 // guard is the only thing refusing it — and it is refused, because a
2233 // paste can carry newlines and bracketed-paste markers that are not one 2268 // paste can carry newlines and bracketed-paste markers that are not one
2234 // cell's worth of change each. 2269 // cell's worth of change each.
2235 offerKeystroke(alloc, &ov, replica, "abc", tty, null_fd); 2270 offerKeystroke(alloc, &ov, replica, "abc", tty, null_fd);
@@ -2546,9 +2581,7 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the
2546 test "interact: a borrowed terminal's claim is the client's, and every teardown undoes it" { 2581 test "interact: a borrowed terminal's claim is the client's, and every teardown undoes it" {
2547 // The claim a wall tile takes at a promote is a SUBSTRING of what a 2582 // The claim a wall tile takes at a promote is a SUBSTRING of what a
2548 // client takes at startup, and the release is the head of every 2583 // client takes at startup, and the release is the head of every
2549 // teardown there is. Structural rather than repeated: a mouse mode 2584 // teardown there is.
2550 // added to one claim is added to all of them, and one dropped from the
2551 // release is dropped from every path that undoes a claim.
2552 // 2585 //
2553 // The pairing this pins turns over many times per run — once per zoom, 2586 // The pairing this pins turns over many times per run — once per zoom,
2554 // where a client's turns over once per process — so a half that only 2587 // where a client's turns over once per process — so a half that only
src/mux_main.zig
Old New
@@ -34,8 +34,9 @@ const usage =
34 \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' 34 \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/'
35 \\ --version prints the version 35 \\ --version prints the version
36 \\ 36 \\
37 \\ mux wall [SPELLING...] shows several sessions at once, read-only, 37 \\ mux wall [SPELLING...] shows several sessions at once, one stripe
38 \\ one stripe each; `q` or Ctrl-\ leaves. SPELLING is the wall grammar 38 \\ each; `Enter` zooms the selected one and types into it, `q` or
39 \\ Ctrl-\ leaves. SPELLING is the wall grammar
39 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], 40 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION],
40 \\ one argument per tile, but `--sock PATH` may also be two arguments 41 \\ one argument per tile, but `--sock PATH` may also be two arguments
41 \\ as in muxweb); with none, the saved wall is shown. 42 \\ as in muxweb); with none, the saved wall is shown.
@@ -329,7 +330,7 @@ pub fn main() !u8 {
329 return 1; 330 return 1;
330 } 331 }
331 332
332 // Attach auto-start (M13): give the attach a daemon to land on. 333 // Attach auto-start: give the attach a daemon to land on.
333 // Unix-socket transport only — quic:// has nothing local to 334 // Unix-socket transport only — quic:// has nothing local to
334 // spawn, and --via's auto-starter is the remote proxy. 335 // spawn, and --via's auto-starter is the remote proxy.
335 const muxd_path = try spawn.findInPath( 336 const muxd_path = try spawn.findInPath(
src/protocol.zig
Old New
@@ -1,4 +1,5 @@
1 //! Wire protocol: length-prefixed frames over a Unix socket, plus the 1 //! Wire protocol: length-prefixed frames over a byte stream (unix socket,
2 //! QUIC stream, WebSocket message), plus the
2 //! apply semantics for deltas (see composeDelta). 3 //! apply semantics for deltas (see composeDelta).
3 //! Frame = 1 byte MsgType, u32 LE payload length, payload bytes. 4 //! Frame = 1 byte MsgType, u32 LE payload length, payload bytes.
4 //! The wire format is deliberately hand-rolled: payloads are row-keyed 5 //! The wire format is deliberately hand-rolled: payloads are row-keyed
@@ -19,15 +20,16 @@ pub const MsgType = enum(u8) {
19 stop_req = 0x07, // payload: empty; daemon shuts down as if signalled 20 stop_req = 0x07, // payload: empty; daemon shuts down as if signalled
20 endpoint_req = 0x08, // payload: empty; asks for the QUIC port, binding a listener lazily if needed 21 endpoint_req = 0x08, // payload: empty; asks for the QUIC port, binding a listener lazily if needed
21 await_req = 0x09, // payload: u64 LE since_seq, u32 LE settle_ms, u32 LE timeout_ms; either duration 0 = that mechanism off ++ optional session-name tail (empty = default session) 22 await_req = 0x09, // payload: u64 LE since_seq, u32 LE settle_ms, u32 LE timeout_ms; either duration 0 = that mechanism off ++ optional session-name tail (empty = default session)
22 status_req = 0x0a, // payload: empty 23 status_req = 0x0a, // payload: empty, or a session-name tail (empty = default session)
23 selection_req = 0x0b, // payload: SelectionReq (see encodeSelectionReq) 24 selection_req = 0x0b, // payload: SelectionReq (see encodeSelectionReq)
24 sessions_req = 0x0c, // payload: empty; asks which sessions the daemon is hosting 25 sessions_req = 0x0c, // payload: empty; asks which sessions the daemon is hosting
25 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt 26 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session)
26 // daemon -> client 27 // daemon -> client
27 snapshot = 0x81, // payload: SnapshotPrefix ++ full-state vt dump 28 snapshot = 0x81, // payload: SnapshotPrefix ++ full-state vt dump
28 exit_status = 0x82, // payload: 1 byte exit code 29 exit_status = 0x82, // payload: 1 byte exit code
29 // retired in M5 (attach joins instead of taking over); kept for 30 // Retired when attach became a JOIN rather than a takeover; no daemon
30 // wire-compat, currently unsent. 31 // sends it. The clients still handle it (`wallview`, `interact`)
32 // because a new client may attach to an old daemon that does.
31 taken_over = 0x84, // payload: empty; a newer client attached, you're out 33 taken_over = 0x84, // payload: empty; a newer client attached, you're out
32 scrollback_chunk = 0x85, // payload: u32 LE start, u16 LE count ++ vt rows 34 scrollback_chunk = 0x85, // payload: u32 LE start, u16 LE count ++ vt rows
33 stats_reply = 0x86, // payload: human-readable stats text 35 stats_reply = 0x86, // payload: human-readable stats text
@@ -449,15 +451,15 @@ pub const AwaitReq = struct {
449 451
450 pub const await_req_len = 16; 452 pub const await_req_len = 16;
451 453
452 /// Writes only the fixed 16 bytes: the pre-M18 payload, which is what an 454 /// Writes only the fixed 16 bytes: the payload from before named sessions,
453 /// empty name means on the wire. 455 /// which is what an empty name means on the wire.
454 /// 456 ///
455 /// The name must be empty, and that is asserted rather than documented. 457 /// The name must be empty, and that is asserted rather than documented.
456 /// This used to carry a comment admitting that a caller who set `.name` 458 /// This used to carry a comment admitting that a caller who set `.name`
457 /// and called this instead of `encodeAwaitReqNamed` would find it silently 459 /// and called this instead of `encodeAwaitReqNamed` would find it silently
458 /// dropped — a known way to send the wrong bytes, written down and left 460 /// dropped — a known way to send the wrong bytes, written down and left
459 /// live. Dropping `AwaitReq.name`'s default was considered and does not 461 /// live. Dropping `AwaitReq.name`'s default was considered and does not
460 /// fix it: it forces fourteen literals to say `.name = ""` and still lets 462 /// fix it: it forces every literal to say `.name = ""` and still lets
461 /// `.name = "b"` reach this function. The assert is what makes the misuse 463 /// `.name = "b"` reach this function. The assert is what makes the misuse
462 /// impossible to hold wrong quietly — it fires at the call site, in the 464 /// impossible to hold wrong quietly — it fires at the call site, in the
463 /// build modes the tests and the daemon run under. 465 /// build modes the tests and the daemon run under.
@@ -793,7 +795,7 @@ pub fn resolveName(wire_name: []const u8) []const u8 {
793 /// What the daemon plants in every session shell it spawns: the socket path 795 /// What the daemon plants in every session shell it spawns: the socket path
794 /// it bound, and the session's RESOLVED name. Not wire bytes — but they are 796 /// it bound, and the session's RESOLVED name. Not wire bytes — but they are
795 /// a contract between three modules that cannot import one another (the 797 /// a contract between three modules that cannot import one another (the
796 /// daemon plants them, `mux_main` refuses a self-attach on them, `client` 798 /// daemon plants them, `mux_main` refuses a self-attach on them, `wallview`
797 /// drops a self-tile from an auto-built wall on them), and this is the 799 /// drops a self-tile from an auto-built wall on them), and this is the
798 /// lowest module all three already import. Named in one place because three 800 /// lowest module all three already import. Named in one place because three
799 /// spellings of the same variable is three chances for one to be renamed 801 /// spellings of the same variable is three chances for one to be renamed
@@ -843,7 +845,8 @@ pub fn encodeAttachNamed(
843 /// hand-assembled — `muxd dump` and `muxa capture` had byte-identical 845 /// hand-assembled — `muxd dump` and `muxa capture` had byte-identical
844 /// copies. Wire layout belongs to the wire module: two binaries knowing 846 /// copies. Wire layout belongs to the wire module: two binaries knowing
845 /// the dump payload's shape is two binaries that can disagree about it. 847 /// the dump payload's shape is two binaries that can disagree about it.
846 /// An empty name writes exactly the pre-M18 one-byte payload. 848 /// An empty name writes exactly the one-byte payload from before named
849 /// sessions.
847 pub fn encodeDebugDumpNamed( 850 pub fn encodeDebugDumpNamed(
848 buf: *[debug_dump_max_len]u8, 851 buf: *[debug_dump_max_len]u8,
849 vt: bool, 852 vt: bool,
@@ -878,7 +881,7 @@ pub fn decodeAttach(payload: []const u8) !AttachReq {
878 } 881 }
879 882
880 /// Fixed prefix of every snapshot payload. Carries the grid size because 883 /// Fixed prefix of every snapshot payload. Carries the grid size because
881 /// under the latest-wins resize policy (M5) a client's tty may not match 884 /// under the latest-wins resize policy a client's tty may not match
882 /// the authoritative grid; the replica must follow the grid, not the tty. 885 /// the authoritative grid; the replica must follow the grid, not the tty.
883 /// `epoch` identifies the daemon instance that produced `seq`: a client 886 /// `epoch` identifies the daemon instance that produced `seq`: a client
884 /// echoes it back on reattach so the daemon can tell "you are current" from 887 /// echoes it back on reattach so the daemon can tell "you are current" from
@@ -1130,7 +1133,7 @@ test "attach v3 encode/decode round trip" {
1130 try std.testing.expectEqual(@as(u64, 0xA1B2C3D4E5F60718), a.have_epoch); 1133 try std.testing.expectEqual(@as(u64, 0xA1B2C3D4E5F60718), a.have_epoch);
1131 } 1134 }
1132 1135
1133 test "attach: a bare 20-byte payload decodes with an empty name (M17 wire compat)" { 1136 test "attach: a bare 20-byte payload decodes with an empty name (old-client wire compat)" {
1134 const req = try decodeAttach(&encodeAttach(120, 40, 7, 9)); 1137 const req = try decodeAttach(&encodeAttach(120, 40, 7, 9));
1135 try std.testing.expectEqual(@as(u16, 120), req.cols); 1138 try std.testing.expectEqual(@as(u16, 120), req.cols);
1136 try std.testing.expectEqual(@as(u64, 7), req.have_seq); 1139 try std.testing.expectEqual(@as(u64, 7), req.have_seq);
@@ -1144,7 +1147,7 @@ test "attach: the name tail rides behind the fixed 20 bytes and round-trips" {
1144 const req = try decodeAttach(wire); 1147 const req = try decodeAttach(wire);
1145 try std.testing.expectEqual(@as(u16, 80), req.cols); 1148 try std.testing.expectEqual(@as(u16, 80), req.cols);
1146 try std.testing.expectEqualStrings("wall-b", req.name); 1149 try std.testing.expectEqualStrings("wall-b", req.name);
1147 // An empty name encodes to exactly the M17 wire bytes — this IS the 1150 // An empty name encodes to exactly an old client's bytes — this IS the
1148 // cross-version story, so it is pinned, not assumed. 1151 // cross-version story, so it is pinned, not assumed.
1149 const bare = encodeAttachNamed(&buf, 80, 24, 3, 5, ""); 1152 const bare = encodeAttachNamed(&buf, 80, 24, 3, 5, "");
1150 try std.testing.expectEqualSlices(u8, &encodeAttach(80, 24, 3, 5), bare); 1153 try std.testing.expectEqualSlices(u8, &encodeAttach(80, 24, 3, 5), bare);
@@ -1337,8 +1340,9 @@ test "decodeAttach rejects a wrong-length payload" {
1337 // rejected outright rather than read as epoch 0 — an old client must 1340 // rejected outright rather than read as epoch 0 — an old client must
1338 // fail loudly, not be handed a session it cannot reason about. 1341 // fail loudly, not be handed a session it cannot reason about.
1339 try std.testing.expectError(error.BadPayload, decodeAttach(&[_]u8{0} ** 12)); 1342 try std.testing.expectError(error.BadPayload, decodeAttach(&[_]u8{0} ** 12));
1340 // 21 bytes is no longer a bad length on its own: past M18, one byte 1343 // 21 bytes is no longer a bad length on its own: with named sessions,
1341 // beyond the fixed part is a one-byte session name, not garbage. The 1344 // one byte beyond the fixed part is a one-byte session name, not
1345 // garbage. The
1342 // upper bound (name past session_name_max) is covered separately. 1346 // upper bound (name past session_name_max) is covered separately.
1343 } 1347 }
1344 1348
src/proxy.zig
Old New
@@ -1,6 +1,6 @@
1 //! `muxd proxy`: a bidirectional byte pump between stdio and the local 1 //! `muxd proxy`: a bidirectional byte pump between stdio and the local
2 //! daemon socket. Deliberately frame-agnostic — it contains no protocol 2 //! daemon socket. Deliberately frame-agnostic — it contains no protocol
3 //! knowledge at all. That is the M6 transport thesis: if an opaque byte 3 //! knowledge at all. That is the transport thesis: if an opaque byte
4 //! pipe suffices to carry the protocol over SSH, transport is a swap, 4 //! pipe suffices to carry the protocol over SSH, transport is a swap,
5 //! not a redesign. Keep this file's import list empty of `protocol`. 5 //! not a redesign. Keep this file's import list empty of `protocol`.
6 const std = @import("std"); 6 const std = @import("std");
@@ -23,8 +23,8 @@ const TmpDir = @import("testtmp").TmpDir;
23 /// The one real difference from the std default: SIG_IGN survives exec, a 23 /// 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 24 /// handler does not. So a caller that SPAWNS must install this after the
25 /// spawn, never before, or the child inherits the ignore across its exec — 25 /// spawn, never before, or the child inherits the ignore across its exec —
26 /// which is why client.zig installs its ignore only after spawning the 26 /// which is why wallview.zig installs its ignore only after opening the
27 /// transport child, why `muxd endpoint` calls this after its auto-start, 27 /// transport, why `muxd endpoint` calls this after its auto-start,
28 /// and why the order does not matter here (the proxy spawns nothing). 28 /// and why the order does not matter here (the proxy spawns nothing).
29 pub fn ignoreSigpipe() void { 29 pub fn ignoreSigpipe() void {
30 var ign: std.posix.Sigaction = .{ 30 var ign: std.posix.Sigaction = .{
src/server.zig
Old New
@@ -1,5 +1,6 @@
1 //! muxd's daemon core: up to max_sessions sessions (engine + pty + command 1 //! muxd's daemon core: up to max_sessions sessions (engine + pty + command
2 //! tracker each), one listener; a connection is a session, named at attach. 2 //! tracker each); a connection is a session, named at attach. One listener
3 //! on the unix socket, and a second on UDP when QUIC is configured.
3 //! Up to max_clients attached interactive clients plus a few dump-only 4 //! Up to max_clients attached interactive clients plus a few dump-only
4 //! observer connections. Every state update is broadcast to all attached 5 //! observer connections. Every state update is broadcast to all attached
5 //! clients; the grid follows the most recently active client — typing, 6 //! clients; the grid follows the most recently active client — typing,
@@ -100,7 +101,7 @@ const Stats = struct {
100 snapshot_bytes: u64 = 0, 101 snapshot_bytes: u64 = 0,
101 deltas: u64 = 0, 102 deltas: u64 = 0,
102 delta_bytes: u64 = 0, 103 delta_bytes: u64 = 0,
103 /// What the same updates would have cost as full snapshots (M2 model): 104 /// What the same updates would have cost a snapshot-only daemon:
104 /// measured, not estimated — dumpState length at each delta send. 105 /// measured, not estimated — dumpState length at each delta send.
105 snapshot_equiv_bytes: u64 = 0, 106 snapshot_equiv_bytes: u64 = 0,
106 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic. 107 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic.
@@ -151,14 +152,12 @@ pub fn installSignalHandlers() void {
151 152
152 /// Where one client's bytes go, and where they come from. 153 /// Where one client's bytes go, and where they come from.
153 /// 154 ///
154 /// There is exactly one variant today and it is a bare fd — which is the 155 /// It used to be a bare fd, which was the assumption the whole daemon was
155 /// assumption the whole daemon was built on: `clients[i].fd` went straight 156 /// built on: `clients[i].fd` went straight into the poll array, and
156 /// into the poll array, and send/close reached it directly. A QUIC client 157 /// send/close reached it directly. A QUIC client shares ONE UDP socket with
157 /// will share ONE UDP socket with every other peer and be identified by 158 /// every other peer and is identified by connection ID, so "the client's
158 /// connection ID, so "the client's fd" stops being something that can be 159 /// fd" stops being something that can be polled or sent to. Breaking that
159 /// polled or sent to. Breaking that assumption is the entire point of this 160 /// assumption is the entire point of this union.
160 /// union; `.quic` arrives in Task 2c and nothing above this line should
161 /// need to change when it does.
162 const Sink = union(enum) { 161 const Sink = union(enum) {
163 socket: std.posix.fd_t, 162 socket: std.posix.fd_t,
164 /// A QUIC peer: the listener that owns the shared UDP socket, plus the 163 /// A QUIC peer: the listener that owns the shared UDP socket, plus the
@@ -788,7 +787,8 @@ pub const Server = struct {
788 /// weaker one. A 0x0 attach makes no size claim (muxa send) and a 787 /// weaker one. A 0x0 attach makes no size claim (muxa send) and a
789 /// client with no size must never be the reason a shell spawns; but 788 /// client with no size must never be the reason a shell spawns; but
790 /// 1x1 is worse than 0x0, because it is a size a client really sends: 789 /// 1x1 is worse than 0x0, because it is a size a client really sends:
791 /// every unzoomed wall tile attaches 1x1 (web/mux.js). Gating on 790 /// every unzoomed BROWSER wall tile attaches 1x1 (web/mux.js); the CLI
791 /// wall's stripes attach 0x0. Gating on
792 /// "nonzero" let `muxweb host#newname` fork a shell whose engine, pty 792 /// "nonzero" let `muxweb host#newname` fork a shell whose engine, pty
793 /// and winsize were all 1x1 — and then applySize refused to move it, 793 /// and winsize were all 1x1 — and then applySize refused to move it,
794 /// recordSize was skipped, the slot stayed 0x0, and claimGrid could 794 /// recordSize was skipped, the slot stayed 0x0, and claimGrid could
@@ -1611,26 +1611,14 @@ pub const Server = struct {
1611 1611
1612 /// Feed bytes that arrived for client `i` by some route other than its 1612 /// Feed bytes that arrived for client `i` by some route other than its
1613 /// own fd, extracting whole frames as they complete. This is the seam 1613 /// own fd, extracting whole frames as they complete. This is the seam
1614 /// Task 2c fills: QUIC stream data arrives in arbitrary chunks from the 1614 /// QUIC arrives through: stream data comes in arbitrary chunks from the
1615 /// shared UDP socket, and lands here so that from the frame onward a 1615 /// shared UDP socket, and lands here so that from the frame onward a
1616 /// QUIC client and a socket client take byte-identical paths. 1616 /// QUIC client and a socket client take byte-identical paths.
1617 /// 1617 ///
1618 /// It is built in 2a, ahead of its caller, on purpose. The claim 2a 1618 /// The byte-split test below still earns its keep with a real caller in
1619 /// makes is that adding a transport will not require touching frame 1619 /// place: the chunk boundaries a live QUIC link happens to produce are
1620 /// handling — and that claim is only checkable if the seam exists and 1620 /// not the adversarial ones — a header split down the middle, two
1621 /// is exercised now, which the unit test below does. Without the test 1621 /// frames in one write — and only the test asks for those.
1622 /// it would also not even be type-checked: Zig analyses only what is
1623 /// referenced.
1624 ///
1625 /// Framing is re-implemented here rather than reused from protocol.zig
1626 /// because that module reads descriptors, and this milestone's fourth
1627 /// kill-criterion leg is that protocol.zig comes out of it byte-for-byte
1628 /// unchanged. Same wire format, read from a buffer instead of an fd.
1629 /// Two readers of one format is a duplication, and it is watched rather
1630 /// than tolerated: `proto.appendFrame` remains the single writer both
1631 /// read back, and the seam test below builds its input with it — so a
1632 /// change to the header that this reader missed fails that test rather
1633 /// than reaching a client.
1634 fn pushInbound(self: *Server, i: usize, bytes: []const u8) void { 1622 fn pushInbound(self: *Server, i: usize, bytes: []const u8) void {
1635 if (self.clients[i] == null) return; 1623 if (self.clients[i] == null) return;
1636 self.clients[i].?.inbound.appendSlice(self.alloc, bytes) catch { 1624 self.clients[i].?.inbound.appendSlice(self.alloc, bytes) catch {
@@ -2085,7 +2073,7 @@ pub const Server = struct {
2085 return; 2073 return;
2086 }; 2074 };
2087 // Attach joins the session; it no longer displaces whoever 2075 // Attach joins the session; it no longer displaces whoever
2088 // was already there (takeover retired in M5). 2076 // was already there (takeover is retired; attach joins).
2089 const slot = self.freeClientSlot() orelse { 2077 const slot = self.freeClientSlot() orelse {
2090 // Client table full. Refusing is the honest answer now 2078 // Client table full. Refusing is the honest answer now
2091 // that attaching can't evict anyone; the client exits 2079 // that attaching can't evict anyone; the client exits
@@ -2245,7 +2233,8 @@ pub const Server = struct {
2245 self.resyncSnapshot(si); 2233 self.resyncSnapshot(si);
2246 } 2234 }
2247 2235
2248 /// Counterfactual: what M2 would have sent for this one update as a 2236 /// Counterfactual: what a snapshot-only daemon would have sent for
2237 /// this one update as a
2249 /// full snapshot. Accrued once per update event, not once per 2238 /// full snapshot. Accrued once per update event, not once per
2250 /// recipient, so the delta-vs-snapshot ratio keeps meaning the same 2239 /// recipient, so the delta-vs-snapshot ratio keeps meaning the same
2251 /// thing however many clients are attached. 2240 /// thing however many clients are attached.
@@ -2688,8 +2677,9 @@ pub const Server = struct {
2688 self.stats.snapshots += 1; 2677 self.stats.snapshots += 1;
2689 self.stats.snapshot_bytes += payload.len; 2678 self.stats.snapshot_bytes += payload.len;
2690 } 2679 }
2691 // Per-event counterfactual: this snapshot IS what M2 would have 2680 // Per-event counterfactual: this snapshot IS what a snapshot-only
2692 // sent, so it counts once however many clients received it. 2681 // daemon would have sent, so it counts once however many clients
2682 // received it.
2693 if (sent) self.stats.snapshot_equiv_bytes += payload.len; 2683 if (sent) self.stats.snapshot_equiv_bytes += payload.len;
2694 } 2684 }
2695 2685
@@ -2717,9 +2707,9 @@ pub const Server = struct {
2717 /// - same size and a serviceable have_seq: a delta, to this client 2707 /// - same size and a serviceable have_seq: a delta, to this client
2718 /// only, since only it is behind; 2708 /// only, since only it is behind;
2719 /// - same size and an unserviceable have_seq: a snapshot, again to 2709 /// - same size and an unserviceable have_seq: a snapshot, again to
2720 /// this client only. This is the universal case in practice — the 2710 /// this client only. This is the case a FIRST attach takes — the
2721 /// shipped client attaches with have_seq=0 — which is exactly why 2711 /// shipped client opens with have_seq=0 — which is exactly why it
2722 /// it must not broadcast. 2712 /// must not broadcast.
2723 /// 2713 ///
2724 /// Serviceable means the seq is ours to interpret: `have_epoch` must 2714 /// Serviceable means the seq is ours to interpret: `have_epoch` must
2725 /// name THIS daemon instance. Without that check a client holding 2715 /// name THIS daemon instance. Without that check a client holding
@@ -2825,21 +2815,15 @@ pub const Server = struct {
2825 return @intCast(self.ses(si).eng.term.cols); 2815 return @intCast(self.ses(si).eng.term.cols);
2826 } 2816 }
2827 2817
2828 /// Text, but machine-parsed: the bench harness and the e2e tests split 2818 /// Blocking write — an observer has no send queue to accept into. See
2829 /// on these key=value pairs. Renaming or reordering fields breaks them. 2819 /// `statsText` for the format contract.
2830 ///
2831 /// Byte counters are accrued when a frame is ACCEPTED INTO A CLIENT'S
2832 /// QUEUE, not when the kernel takes it — "sent" is now a small lie, and
2833 /// pending_cap is what bounds it: no client can be more than one cap
2834 /// behind before it is dropped. The bench measures a live single client
2835 /// whose queue drains every pump, so the ratio it reports is unaffected.
2836 fn replyStatsObserver(self: *Server, fd: std.posix.fd_t) !void { 2820 fn replyStatsObserver(self: *Server, fd: std.posix.fd_t) !void {
2837 var buf: [stats_text_len]u8 = undefined; 2821 var buf: [stats_text_len]u8 = undefined;
2838 try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf)); 2822 try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf));
2839 } 2823 }
2840 2824
2841 // 256 was sized for the single-session text; the per-session tail 2825 // 256 was sized for the single-session text; the per-session tail
2842 // (M18) can add several "session NAME clients=N seq=N" segments, one 2826 // can add several "session NAME clients=N seq=N" segments, one
2843 // per live session up to max_sessions, and 256 stopped being enough 2827 // per live session up to max_sessions, and 256 stopped being enough
2844 // headroom for that plus the longest legal names. 2828 // headroom for that plus the longest legal names.
2845 // 2829 //
@@ -2933,7 +2917,7 @@ pub const Server = struct {
2933 /// behind before it is dropped. The bench measures a live single client 2917 /// behind before it is dropped. The bench measures a live single client
2934 /// whose queue drains every pump, so the ratio it reports is unaffected. 2918 /// whose queue drains every pump, so the ratio it reports is unaffected.
2935 /// 2919 ///
2936 /// Daemon-global now (M18): the old leading `seq=` field was ONE 2920 /// Daemon-global now: the old leading `seq=` field was ONE
2937 /// session's tracker, which had no honest answer once there could be 2921 /// session's tracker, which had no honest answer once there could be
2938 /// more than one — so it moved off the main line entirely. The main 2922 /// more than one — so it moved off the main line entirely. The main
2939 /// line keeps the truly global counters plus `sessions=N`, and every 2923 /// line keeps the truly global counters plus `sessions=N`, and every
@@ -3883,8 +3867,9 @@ test "Server: broadcast stats count every send but the counterfactual once" {
3883 // Actuals are per send: two clients, two deltas on the wire. 3867 // Actuals are per send: two clients, two deltas on the wire.
3884 try std.testing.expectEqual(@as(u64, 2), srv.stats.deltas); 3868 try std.testing.expectEqual(@as(u64, 2), srv.stats.deltas);
3885 try std.testing.expectEqual(@as(u64, @intCast(2 * fa.payload.len)), srv.stats.delta_bytes); 3869 try std.testing.expectEqual(@as(u64, @intCast(2 * fa.payload.len)), srv.stats.delta_bytes);
3886 // The counterfactual is per event: what M2 would have sent for this 3870 // The counterfactual is per event: what a snapshot-only daemon would
3887 // one update is one snapshot, however many clients received it. 3871 // have sent for this one update is one snapshot, however many clients
3872 // received it.
3888 const state = try srv.sessions[0].?.eng.dumpState(alloc); 3873 const state = try srv.sessions[0].?.eng.dumpState(alloc);
3889 defer alloc.free(state); 3874 defer alloc.free(state);
3890 try std.testing.expectEqual( 3875 try std.testing.expectEqual(
@@ -5910,7 +5895,7 @@ test "Server: stats reports live client slots, and the number comes down again"
5910 defer srv.deinit(); 5895 defer srv.deinit();
5911 5896
5912 var buf: [Server.stats_text_len]u8 = undefined; 5897 var buf: [Server.stats_text_len]u8 = undefined;
5913 // indexOf rather than endsWith (M18): the text no longer ends on the 5898 // indexOf rather than endsWith: the text no longer ends on the
5914 // global gauge — a per-session tail follows it — so the assertion has 5899 // global gauge — a per-session tail follows it — so the assertion has
5915 // to name the field it means rather than lean on it being last. 5900 // to name the field it means rather than lean on it being last.
5916 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null); 5901 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null);
@@ -5957,7 +5942,7 @@ test "Server: stats reports live client slots, and the number comes down again"
5957 5942
5958 // The fields the harnesses parse are still where they were: appended, 5943 // The fields the harnesses parse are still where they were: appended,
5959 // never reordered. The old leading `seq=` was one session's tracker 5944 // never reordered. The old leading `seq=` was one session's tracker
5960 // and moved into the per-session tail (M18); the main line now starts 5945 // and moved into the per-session tail; the main line now starts
5961 // with the counters that were always daemon-global. 5946 // with the counters that were always daemon-global.
5962 const text = try srv.statsText(&buf); 5947 const text = try srv.statsText(&buf);
5963 try std.testing.expect(std.mem.startsWith(u8, text, "snapshots=")); 5948 try std.testing.expect(std.mem.startsWith(u8, text, "snapshots="));
@@ -7263,7 +7248,8 @@ test "Server: a bare 20-byte attach lands in the default session" {
7263 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 7248 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
7264 defer srv.deinit(); 7249 defer srv.deinit();
7265 7250
7266 // The pre-M18 wire spelling, byte for byte: no name tail at all. An 7251 // The wire spelling from before named sessions, byte for byte: no name
7252 // tail at all. An
7267 // old client must land in the default session, not create a nameless 7253 // old client must land in the default session, not create a nameless
7268 // one beside it. 7254 // one beside it.
7269 const c = try std.net.connectUnixSocket(sock_path); 7255 const c = try std.net.connectUnixSocket(sock_path);
@@ -7434,7 +7420,8 @@ test "Server: a 1x1 attach joins but never creates" {
7434 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 7420 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
7435 defer srv.deinit(); 7421 defer srv.deinit();
7436 7422
7437 // 1x1 is the size EVERY unzoomed wall tile attaches at (web/mux.js), so 7423 // 1x1 is the size every unzoomed BROWSER wall tile attaches at
7424 // (web/mux.js) — the CLI wall's stripes attach 0x0 — so
7438 // unlike 0x0 this is a size a real client really sends. It must be 7425 // unlike 0x0 this is a size a real client really sends. It must be
7439 // refused for creation exactly as 0x0 is: applySize will not move a 7426 // refused for creation exactly as 0x0 is: applySize will not move a
7440 // grid to 1x1, so a session created at 1x1 could never be resized by 7427 // grid to 1x1, so a session created at 1x1 could never be resized by
src/wall.zig
Old New
@@ -1,7 +1,7 @@
1 //! The wall: an ordered list of TARGET spellings, shared by muxweb today 1 //! The wall: an ordered list of TARGET spellings — attach history, written
2 //! and the mux CLI later — one owner for the spelling grammar, the 2 //! by every grid-claiming attach (muxweb and mux alike) — one owner for the
3 //! session split, and the persisted file, so the wall built in a browser 3 //! spelling grammar, the session split, and the persisted file, so the wall
4 //! is the wall the CLI sees. 4 //! built in a browser is the wall the CLI sees.
5 //! 5 //!
6 //! Spelling grammar (one string; also the line format of the state file 6 //! Spelling grammar (one string; also the line format of the state file
7 //! and the body of the hub's POST /tiles): 7 //! and the body of the hub's POST /tiles):
@@ -212,9 +212,6 @@ pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
212 lines.deinit(alloc); 212 lines.deinit(alloc);
213 } 213 }
214 214
215 /// Append `spelling` to the wall file unless it is already there, byte
216 /// for byte. Returns whether the file changed.
217 ///
218 /// Dedup is on the SPELLING, never on the session's identity: the same 215 /// Dedup is on the SPELLING, never on the session's identity: the same
219 /// session reached as `HOST#S` and as `quic://…#S` is two tiles, 216 /// session reached as `HOST#S` and as `quic://…#S` is two tiles,
220 /// deliberately — identity dedup would need an endpoint handshake the 217 /// deliberately — identity dedup would need an endpoint handshake the
src/wallview.zig
Old New
@@ -2,8 +2,10 @@
2 //! once, the same multiattach the browser hub gives, without a browser. 2 //! once, the same multiattach the browser hub gives, without a browser.
3 //! Targets are the wall grammar's spellings (wall.zig), so `mux wall` with 3 //! Targets are the wall grammar's spellings (wall.zig), so `mux wall` with
4 //! no arguments shows the SAME wall the browser built — one state file, 4 //! no arguments shows the SAME wall the browser built — one state file,
5 //! one grammar. Every tile attaches at 0x0 (join, never claim the grid, 5 //! one grammar. An UNZOOMED tile attaches at 0x0 (join, never claim the
6 //! never create a session — muxa's discipline). `q` or Ctrl-\ leaves. 6 //! grid, never create a session — muxa's discipline); a zoomed one claims
7 //! the terminal's size, which is all of `sendAttach`. On the wall, `q` or
8 //! Ctrl-\ leaves.
7 //! 9 //!
8 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump); 10 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump);
9 //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place. 11 //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place.
@@ -40,18 +42,19 @@
40 //! 42 //!
41 //! Zoomed, the terminal belongs to the session: `Ctrl-\` is its command 43 //! Zoomed, the terminal belongs to the session: `Ctrl-\` is its command
42 //! prefix, read out of interact.zig's PrefixFilter so there is one chord 44 //! prefix, read out of interact.zig's PrefixFilter so there is one chord
43 //! table and not a twin. `d` and `w` both unzoom (`d` is the muscle memory 45 //! table and not a twin. `w` UNZOOMS and `d` DETACHES and leaves mux —
44 //! the child-spawn era left behind, `w` is where the model is going), 46 //! two different keys, and `zoomChord` is where the difference lives.
45 //! `n`/`p` move the zoom to the next/previous TILE, `l` skips back to the 47 //! `n`/`p`/`c` move the zoom around the DAEMON's session ring rather than
46 //! last tile zoomed — and unzooms when there is none. Everything else the 48 //! around the wall's tiles, `l` skips back to the last tile zoomed — and
49 //! unzooms when there is none. Everything else the
47 //! prefix takes is swallowed, exactly as in a client. 50 //! prefix takes is swallowed, exactly as in a client.
48 //! 51 //!
49 //! And BELOW that chord layer a zoomed tile is a client: every tile owns an 52 //! And BELOW that chord layer a zoomed tile is a client: every tile owns an
50 //! `interact.Core` from birth, and a promoted one drives it with the same 53 //! `interact.Core` from birth, and a promoted one drives it with the same
51 //! calls client.zig's session loop makes. So the wheel scrolls a zoomed 54 //! calls client.zig's session loop used to make. So the wheel scrolls a
52 //! session's history, an application that asks for the mouse gets every 55 //! zoomed session's history, an application that asks for the mouse gets
53 //! byte of it, and the prediction overlay is the client's own — none of 56 //! every byte of it, and the prediction overlay is the client's own — none
54 //! which the wall could offer while it hand-rolled a subset of that path. 57 //! of which the wall could offer while it hand-rolled a subset of that path.
55 //! What a tile keeps to itself is the STRIPE: a crop of the same replica, 58 //! What a tile keeps to itself is the STRIPE: a crop of the same replica,
56 //! painted by the wall while the Core is not allowed to paint at all. 59 //! painted by the wall while the Core is not allowed to paint at all.
57 //! 60 //!
@@ -97,8 +100,8 @@ const sockpath = @import("sockpath");
97 const proxy = @import("proxy"); 100 const proxy = @import("proxy");
98 const Engine = @import("engine").Engine; 101 const Engine = @import("engine").Engine;
99 const paint = @import("paint"); 102 const paint = @import("paint");
100 // For `Counters` only: the zoomed tile's are published to `Shared` so the 103 // Counters ride out through `Shared` because a detached pump never reaches
101 // exit can print the stats line a detached pump never gets to. 104 // a `Core.deinit`.
102 // The chord table and the prediction hooks a zoomed tile shares with the 105 // The chord table and the prediction hooks a zoomed tile shares with the
103 // client: one interaction core, not a second copy (interact.zig). 106 // client: one interaction core, not a second copy (interact.zig).
104 const interact = @import("interact"); 107 const interact = @import("interact");
@@ -171,6 +174,9 @@ pub fn layoutStripes(
171 n: usize, 174 n: usize,
172 tty_rows: u16, 175 tty_rows: u16,
173 ) error{ TooSmall, OutOfMemory }![]Stripe { 176 ) error{ TooSmall, OutOfMemory }![]Stripe {
177 // The 65535 guards the cast, not the wall: `max_tiles` is 32, so no
178 // caller can reach it. `n` is `usize` because this is pure and gets
179 // called with counts, and a total function beats a debug-only trap.
174 if (n == 0 or tty_rows / @as(u16, @intCast(@min(n, 65535))) < 2) return error.TooSmall; 180 if (n == 0 or tty_rows / @as(u16, @intCast(@min(n, 65535))) < 2) return error.TooSmall;
175 const nn: u16 = @intCast(n); 181 const nn: u16 = @intCast(n);
176 const per = tty_rows / nn; 182 const per = tty_rows / nn;
@@ -276,11 +282,15 @@ const Shared = struct {
276 /// LIVE tile — `Core.banner` belongs to a Core, and the Core that 282 /// LIVE tile — `Core.banner` belongs to a Core, and the Core that
277 /// matters is the one being zoomed TO — so the message is left where 283 /// matters is the one being zoomed TO — so the message is left where
278 /// that pump will find it on its promote. 284 /// that pump will find it on its promote.
285 /// Sized for a corner banner, not for arbitrary text: `setNotice`
286 /// truncates rather than allocate for a message nobody can read at
287 /// that width anyway.
279 notice: [96]u8 = undefined, 288 notice: [96]u8 = undefined,
280 notice_len: usize = 0, 289 notice_len: usize = 0,
281 /// The terminal, measured once at startup (there is no SIGWINCH 290 /// The terminal's shape: measured at startup, and re-written by the one
282 /// handler — see the module header). Stripes are cut from it and a 291 /// PROMOTED pump that answers a SIGWINCH (`setWallSize`) — the flag is
283 /// promoted tile claims exactly it. 292 /// process-wide, so only the tile owning the screen may consume it.
293 /// Stripes are cut from it and a promoted tile claims exactly it.
284 size: proto.Size, 294 size: proto.Size,
285 /// The selected tile's index. Under `paint_mu` rather than atomic 295 /// The selected tile's index. Under `paint_mu` rather than atomic
286 /// because it is read while a label bar is being drawn: a pump 296 /// because it is read while a label bar is being drawn: a pump
@@ -315,7 +325,7 @@ const Tile = struct {
315 /// Only pumps answer `repaint_gen`, so a tile whose pump has ENDED — a 325 /// Only pumps answer `repaint_gen`, so a tile whose pump has ENDED — a
316 /// refused attach, a session that exited — has nobody to redraw it 326 /// refused attach, a session that exited — has nobody to redraw it
317 /// after a screen clear. That was invisible while a clear happened once 327 /// after a screen clear. That was invisible while a clear happened once
318 /// per zoom; phase 1 clears on every `n`/`p`/`l` too, so a dead stripe 328 /// per zoom; the wall clears on every `n`/`p`/`l` too, so a dead stripe
319 /// would vanish for the wall's whole remaining life, and zooming one 329 /// would vanish for the wall's whole remaining life, and zooming one
320 /// would paint an entirely blank terminal with no cursor and no way 330 /// would paint an entirely blank terminal with no cursor and no way
321 /// out that the screen admits to. The keyboard reads this and paints 331 /// out that the screen admits to. The keyboard reads this and paints
@@ -616,8 +626,8 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine) bool {
616 /// tile IS the zoom, and with `paint_mu` held for as long as the paint takes 626 /// tile IS the zoom, and with `paint_mu` held for as long as the paint takes
617 /// (see `interact.Sink`). 627 /// (see `interact.Sink`).
618 /// 628 ///
619 /// This is phase 1's enforcement point, unchanged and now SINGLE. Then it 629 /// This is the enforcement point, and now the only one. It used to be
620 /// was spelled at four sites — the promote repaint, the frame repaint, the 630 /// spelled at four sites — the promote repaint, the frame repaint, the
621 /// expiry repaint and the speculation — and any one of them forgetting it 631 /// expiry repaint and the speculation — and any one of them forgetting it
622 /// was a prediction glyph painted onto whichever session the zoom had moved 632 /// was a prediction glyph painted onto whichever session the zoom had moved
623 /// to. Now a demoted tile's Core cannot write a grid byte at all: no rows, 633 /// to. Now a demoted tile's Core cannot write a grid byte at all: no rows,
@@ -683,10 +693,6 @@ fn drainBell(fd: std.posix.fd_t) void {
683 } else |_| {} 693 } else |_| {}
684 } 694 }
685 695
686 /// Hand the zoomed tile's prediction counters out to the thread that will
687 /// print them. Only a PROMOTED pump calls this: a stripe forwards nothing
688 /// and its counters are all zero, so letting one publish would clobber the
689 /// numbers of the tile the user was actually typing at.
690 /// The terminal is a different shape than it was. Written by the one 696 /// The terminal is a different shape than it was. Written by the one
691 /// promoted pump that answered the SIGWINCH, so the next re-cut of the 697 /// promoted pump that answered the SIGWINCH, so the next re-cut of the
692 /// stripes and the next tile to be promoted use the size that is true now 698 /// stripes and the next tile to be promoted use the size that is true now
@@ -697,6 +703,10 @@ fn setWallSize(shared: *Shared, size: proto.Size) void {
697 shared.size = size; 703 shared.size = size;
698 } 704 }
699 705
706 /// Hand the zoomed tile's prediction counters out to the thread that will
707 /// print them. Only a PROMOTED pump calls this: a stripe forwards nothing
708 /// and its counters are all zero, so letting one publish would clobber the
709 /// numbers of the tile the user was actually typing at.
700 fn publishStats(shared: *Shared, c: interact.PredictCounters) void { 710 fn publishStats(shared: *Shared, c: interact.PredictCounters) void {
701 shared.paint_mu.lock(); 711 shared.paint_mu.lock();
702 defer shared.paint_mu.unlock(); 712 defer shared.paint_mu.unlock();
@@ -828,7 +838,6 @@ fn takeKeys(t: *Tile, out: []u8) []u8 {
828 return out[0..n]; 838 return out[0..n];
829 } 839 }
830 840
831 /// Empty the doorbell so the next ring is visible to poll.
832 fn drainWake(t: *const Tile) void { 841 fn drainWake(t: *const Tile) void {
833 drainBell(t.wake_r); 842 drainBell(t.wake_r);
834 } 843 }
@@ -905,7 +914,7 @@ fn redial(
905 /// One tile's life: dial → attach → replay frames into its Core → repaint. 914 /// One tile's life: dial → attach → replay frames into its Core → repaint.
906 /// Runs on its own thread (see module header). On transport death: 915 /// Runs on its own thread (see module header). On transport death:
907 /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch, 916 /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch,
908 /// and M7's snapshot-vs-delta resolution does the rest. Ends when `running` 917 /// and the snapshot-vs-delta resolution does the rest. Ends when `running`
909 /// clears, the session exits, or the attach is refused. 918 /// clears, the session exits, or the attach is refused.
910 /// 919 ///
911 /// This thread is also the tile's WRITER: the promote resize and every 920 /// This thread is also the tile's WRITER: the promote resize and every
@@ -1034,11 +1043,6 @@ fn pumpTile(t: *Tile) void {
1034 1043
1035 const zoomed = t.shared.zoom.load(.acquire) == t.idx; 1044 const zoomed = t.shared.zoom.load(.acquire) == t.idx;
1036 1045
1037 // PROMOTE, and BEFORE the mailbox is drained below: the claim goes
1038 // out before the first keystroke can, which is what keeps the
1039 // passivity rule exception-free — the client that types this
1040 // session is a full-size one, claiming under latest-wins like any
1041 // other. No attach, no dial: the same connection, resized.
1042 // `Ctrl-\ d` under this zoom: hand the daemon its slot back before 1046 // `Ctrl-\ d` under this zoom: hand the daemon its slot back before
1043 // the process dies, rather than leaving it for the socket's death 1047 // the process dies, rather than leaving it for the socket's death
1044 // to be noticed. Only this thread may write the frame, so the 1048 // to be noticed. Only this thread may write the frame, so the
@@ -1073,6 +1077,11 @@ fn pumpTile(t: *Tile) void {
1073 if (pending.expired(std.time.milliTimestamp())) 1077 if (pending.expired(std.time.milliTimestamp()))
1074 core.banner("[no session list: upgrade muxd]"); 1078 core.banner("[no session list: upgrade muxd]");
1075 1079
1080 // PROMOTE, and BEFORE the mailbox is drained below: the claim goes
1081 // out before the first keystroke can, which is what keeps the
1082 // passivity rule exception-free — the client that types this
1083 // session is a full-size one, claiming under latest-wins like any
1084 // other. No attach, no dial: the same connection, resized.
1076 if (zoomed and !promoted) { 1085 if (zoomed and !promoted) {
1077 promoted = true; 1086 promoted = true;
1078 ever_promoted = true; 1087 ever_promoted = true;
@@ -1418,8 +1427,8 @@ pub const ZoomMove = union(enum) {
1418 /// a chord MEANS can be asserted without a terminal, two daemons and a 1427 /// a chord MEANS can be asserted without a terminal, two daemons and a
1419 /// pty: `selectKey`'s reason, one layer up. 1428 /// pty: `selectKey`'s reason, one layer up.
1420 /// 1429 ///
1421 /// `w` UNZOOMS and `d` DETACHES, and phase 3c is where those stopped being 1430 /// `w` UNZOOMS and `d` DETACHES; they used to be the same key.
1422 /// the same key. While the zoom was still a child-spawn's replacement, `d` 1431 /// While the zoom was still a child-spawn's replacement, `d`
1423 /// meant "come back from this tile" because that is what it had meant to 1432 /// meant "come back from this tile" because that is what it had meant to
1424 /// the child; now that `mux TARGET` is itself a wall entered zoomed, `d` 1433 /// the child; now that `mux TARGET` is itself a wall entered zoomed, `d`
1425 /// has to keep meaning what it means to every user's fingers — detach the 1434 /// has to keep meaning what it means to every user's fingers — detach the
@@ -1505,7 +1514,7 @@ pub fn zoomChord(
1505 /// 1514 ///
1506 /// The one thing it DOES paint is for the dead. Only pumps answer 1515 /// The one thing it DOES paint is for the dead. Only pumps answer
1507 /// `repaint_gen`, so a tile whose pump has ended has nobody to redraw it 1516 /// `repaint_gen`, so a tile whose pump has ended has nobody to redraw it
1508 /// after the clear above — and phase 1 clears the screen on every zoom 1517 /// after the clear above — and the wall clears the screen on every zoom
1509 /// move, not once per zoom, so what used to be a corner case is now every 1518 /// move, not once per zoom, so what used to be a corner case is now every
1510 /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never 1519 /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never
1511 /// comes back to the wall, and zooming a dead tile paints an entirely blank 1520 /// comes back to the wall, and zooming a dead tile paints an entirely blank
@@ -1619,7 +1628,7 @@ fn paintEmptyWallLocked(shared: *Shared) void {
1619 /// One hold of `paint_mu` for the same reason `setZoom` takes it: between 1628 /// One hold of `paint_mu` for the same reason `setZoom` takes it: between
1620 /// the clear and the new geometry there must be no window in which a pump 1629 /// the clear and the new geometry there must be no window in which a pump
1621 /// paints a stripe at rows that have just changed owner. `t.stripe` is 1630 /// paints a stripe at rows that have just changed owner. `t.stripe` is
1622 /// written here and read only under this lock (`paintTile`, 1631 /// written here and read only under this lock (`paintStripe`,
1623 /// `paintLabelLocked`), which is what makes moving it safe at all. 1632 /// `paintLabelLocked`), which is what makes moving it safe at all.
1624 /// 1633 ///
1625 /// A failed re-layout keeps the old geometry: forgetting a tile can only 1634 /// A failed re-layout keeps the old geometry: forgetting a tile can only
@@ -1767,6 +1776,7 @@ fn sameTarget(a: client.Target, b: client.Target) bool {
1767 /// an add, where it would leave the new tile pointing at somebody else's 1776 /// an add, where it would leave the new tile pointing at somebody else's
1768 /// rows. So a wall that cannot show another stripe refuses to grow one. 1777 /// rows. So a wall that cannot show another stripe refuses to grow one.
1769 fn wallFits(rows: u16, n: usize) bool { 1778 fn wallFits(rows: u16, n: usize) bool {
1779 // Guards the cast below, as in `layoutStripes` — not the wall.
1770 if (n == 0 or n > 65535) return false; 1780 if (n == 0 or n > 65535) return false;
1771 return rows / @as(u16, @intCast(n)) >= 2; 1781 return rows / @as(u16, @intCast(n)) >= 2;
1772 } 1782 }
@@ -1918,7 +1928,7 @@ fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8)
1918 1928
1919 /// What a pump's ending means for the wall. Only the tile under the ZOOM 1929 /// What a pump's ending means for the wall. Only the tile under the ZOOM
1920 /// can end the program: a stripe that goes quiet narrates itself and the 1930 /// can end the program: a stripe that goes quiet narrates itself and the
1921 /// wall goes on, which is what phase 1 built the dead-tile paint for. 1931 /// wall goes on, which is what the dead-tile paint is for.
1922 const EndAction = union(enum) { 1932 const EndAction = union(enum) {
1923 /// A stripe went quiet. Its label already says so. 1933 /// A stripe went quiet. Its label already says so.
1924 ignore, 1934 ignore,
src/webhub.zig
Old New
@@ -1,4 +1,4 @@
1 //! The muxweb hub's HTTP/WebSocket layer (M-web Task 5): route table, 1 //! The muxweb hub's HTTP/WebSocket layer: route table,
2 //! Origin gate, and the WS endpoint naming — the decisions std.http does 2 //! Origin gate, and the WS endpoint naming — the decisions std.http does
3 //! NOT make for us. The connection loop and per-tile pump build on this 3 //! NOT make for us. The connection loop and per-tile pump build on this
4 //! (Tasks 6-7); the assets are @embedFile'd by webhub_main.zig (the exe 4 //! (Tasks 6-7); the assets are @embedFile'd by webhub_main.zig (the exe
@@ -441,7 +441,7 @@ pub fn route(assets: Assets, path: []const u8) ?Asset {
441 } 441 }
442 442
443 // --------------------------------------------------------------------------- 443 // ---------------------------------------------------------------------------
444 // The wire between hub and browser (M-web Task 6): WebSocket binary 444 // The wire between hub and browser: WebSocket binary
445 // messages, one envelope byte. 0x00 + mux protocol frame verbatim, both 445 // messages, one envelope byte. 0x00 + mux protocol frame verbatim, both
446 // directions; 0x01 + UTF-8 JSON control message, hub→browser only. That 446 // directions; 0x01 + UTF-8 JSON control message, hub→browser only. That
447 // is the whole vocabulary — anything the mux protocol learns to say 447 // is the whole vocabulary — anything the mux protocol learns to say
@@ -727,7 +727,7 @@ fn redial(
727 /// death this narrates `reconnecting`, re-dials on the CLI's own backoff 727 /// death this narrates `reconnecting`, re-dials on the CLI's own backoff
728 /// schedule (client.nextBackoffMs — no retry cap, deliberately), then 728 /// schedule (client.nextBackoffMs — no retry cap, deliberately), then
729 /// narrates `up`; the browser's replica quotes have_seq/have_epoch in a 729 /// narrates `up`; the browser's replica quotes have_seq/have_epoch in a
730 /// fresh attach and M7's snapshot-vs-delta resolution does the rest. 730 /// fresh attach and the snapshot-vs-delta resolution does the rest.
731 /// 731 ///
732 /// The dead-leg bound below is best-effort rather than a bound: it is 732 /// The dead-leg bound below is best-effort rather than a bound: it is
733 /// measured between passes of this loop, and a blocking upstream 733 /// measured between passes of this loop, and a blocking upstream
@@ -862,7 +862,7 @@ fn dialLoop(
862 } 862 }
863 } 863 }
864 864
865 /// One accepted connection, start to finish (M-web Task 7). Static 865 /// One accepted connection, start to finish. Static
866 /// requests loop for keep-alive; a WebSocket upgrade consumes the 866 /// requests loop for keep-alive; a WebSocket upgrade consumes the
867 /// connection into a tile pump and never returns to HTTP. 867 /// connection into a tile pump and never returns to HTTP.
868 /// `/tiles`: the runtime half the embedded page cannot know — one object 868 /// `/tiles`: the runtime half the embedded page cannot know — one object
src/webhub_main.zig
Old New
@@ -1,4 +1,4 @@
1 //! muxweb — the hub binary (M-web Task 7). `muxweb [TARGET ...] 1 //! muxweb — the hub binary. `muxweb [TARGET ...]
2 //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one 2 //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one
3 //! WebSocket per tile, dialing each TARGET the way the mux CLI does. 3 //! WebSocket per tile, dialing each TARGET the way the mux CLI does.
4 //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), 4 //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff),
@@ -8,8 +8,9 @@
8 //! is the tile's label, suffix and all. 8 //! is the tile's label, suffix and all.
9 //! 9 //!
10 //! The wall is now a persisted list the page edits at runtime, so argv is 10 //! The wall is now a persisted list the page edits at runtime, so argv is
11 //! an OVERRIDE, not the only source: with targets, argv becomes the wall 11 //! an override of the VIEW only: with targets, argv is what this run shows
12 //! and is saved; without, the last run's wall is restored. The standing 12 //! and is ADDED to the wall (deduped, nothing removed); without, the last
13 //! run's whole wall is restored and served. The standing
13 //! non-goal (no config file) survives — the state file is written by the 14 //! non-goal (no config file) survives — the state file is written by the
14 //! program, never by hand. 15 //! program, never by hand.
15 16
@@ -27,7 +28,8 @@ const usage =
27 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the 28 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the
28 \\ spelling the wall file holds; `mux wall` takes both too 29 \\ spelling the wall file holds; `mux wall` takes both too
29 \\ with no TARGET the wall from the last run is restored; with TARGETs 30 \\ with no TARGET the wall from the last run is restored; with TARGETs
30 \\ argv replaces it and becomes the saved wall 31 \\ argv is added to the saved wall (deduped) and shown; nothing already
32 \\ there is removed
31 \\ #SESSION names the daemon session the tile attaches to (default: the 33 \\ #SESSION names the daemon session the tile attaches to (default: the
32 \\ default session) — the same host twice, two sessions, two tiles 34 \\ default session) — the same host twice, two sessions, two tiles
33 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key 35 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key