092b7229
docs: the tree stops describing the world before the convergence
a73x 2026-08-20 08:24
Commit message
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 |