a73x

c7c055bf

feat: a wall tile is an interact.Core, so the wheel works zoomed

a73x   2026-08-20 04:18

Commit message
feat: a wall tile is an interact.Core, so the wheel works zoomed

Each tile hand-rolled its interaction: its own replica and overlay, its
own paint decisions, its own input forwarding — and no mouse support at
all, because the wheel needs the session's modes, the tile's scrollback
view and its transport, and phase 1 had none of them in one place.

Now every tile owns a Core from birth (never built at promote time: a
stripe paints from that replica, and the pty modes that gate speculation
arrive long before anyone zooms). Promote takes the terminal for the
session — the mouse modes and the flag that arms taking them off again —
resizes, and repaints locally from the hot replica. Demote gives the
claim back and sends nothing on the wire.

The paint gate is one place now (interact.Sink): a demoted tile's Core
cannot write a grid byte, and the wall draws the stripe from the same
replica through Core.grid(). Phase 1 spelled that at four sites.

Also: the four copies of the tile's reconnect become one, wallview's
private ttySize twin dies, and the wall's screen setup/teardown are
interact's own constants — so a wall left while a tile is still zoomed
takes that session's modes off with it.

build.zig
Old New
@@ -275,7 +275,7 @@ const mod_table = [_]ModSpec{
275 // typed-at session; the overlay machinery itself is interact's, shared — 275 // typed-at session; the overlay machinery itself is interact's, shared —
276 // and phase 3 promotes the tile into that core rather than growing a 276 // and phase 3 promotes the tile into that core rather than growing a
277 // second copy of it. 277 // second copy of it.
278 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true }, 278 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint" }, .quic_tests = true },
279 // ---- layer 5 ---- 279 // ---- layer 5 ----
280 // wall owns the spelling grammar and the state file, so argv is parsed 280 // wall owns the spelling grammar and the state file, so argv is parsed
281 // by the SAME rules the page's POST /tiles and the restored file are — 281 // by the SAME rules the page's POST /tiles and the restored file are —
src/interact.zig
Old New
@@ -370,11 +370,18 @@ const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1
370 /// were mirrored onto this terminal (`appendMouseModes`) and a promoted 370 /// were mirrored onto this terminal (`appendMouseModes`) and a promoted
371 /// tile's application can have asked for modes the client never wanted. 371 /// tile's application can have asked for modes the client never wanted.
372 /// 372 ///
373 /// It leads `terminal_teardown` — the exit path — and it IS the demote 373 /// It leads `terminal_teardown` — the exit path — and it is inside
374 /// path (`Core.releaseTerminal`) and the wall's exit path 374 /// `wall_teardown`. Every route out of a terminal claim writes these
375 /// (`wall_teardown`). Every route out of a terminal claim writes these
376 /// bytes; that is the pairing, and one test pins all four. 375 /// bytes; that is the pairing, and one test pins all four.
377 const session_release = "\x1b[?2004l" ++ mouse_teardown; 376 ///
377 /// Public because the DEMOTE does not write it from here. A wall's zoom
378 /// transition and a tile's pump are different threads, and the release has
379 /// to be ordered against the NEXT tile's claim rather than merely happen —
380 /// so the thread that moves the zoom writes it, before the store that lets
381 /// the next pump see the move (wallview's `setZoom`). Exported rather than
382 /// duplicated, for `wall_setup`/`wall_teardown`'s reason: a mouse mode
383 /// added to the claim must not need finding in four places.
384 pub const session_release = "\x1b[?2004l" ++ mouse_teardown;
378 385
379 /// What a driver that lends its screen to one session at a time writes on 386 /// What a driver that lends its screen to one session at a time writes on
380 /// the way in and on the way out. The CLI wall is the driver: it holds the 387 /// the way in and on the way out. The CLI wall is the driver: it holds the
@@ -386,10 +393,17 @@ const session_release = "\x1b[?2004l" ++ mouse_teardown;
386 /// design rather than a coincidence. The wall can be left while a tile is 393 /// design rather than a coincidence. The wall can be left while a tile is
387 /// still PROMOTED — stdin closes under a zoom, and `q` is not reachable 394 /// still PROMOTED — stdin closes under a zoom, and `q` is not reachable
388 /// from inside one — so every mode a session set through that tile 395 /// from inside one — so every mode a session set through that tile
389 /// (bracketed paste, the mouse modes it asked for, the window title it 396 /// (bracketed paste, the mouse modes it asked for) has to come off here as
390 /// pushed) has to come off here as well as at the demote. A wall exit and a 397 /// well as at the demote. A wall exit and a client exit are the same
391 /// client exit are the same terminal, restored the same way; the only 398 /// terminal, restored the same way; the only difference is that the wall
392 /// difference is that the wall wrote the screen half once for N sessions. 399 /// wrote the screen half once for N sessions.
400 ///
401 /// The TITLE is the one thing that is NOT symmetric, deliberately. A
402 /// promoted tile sets it and a demote leaves it standing: the wall pushed
403 /// one title for its whole life and pops it here, so a user who zooms
404 /// through four sessions sees four titles and gets their own back when the
405 /// wall exits. Restoring at each demote would need a title to restore TO,
406 /// and mux cannot read one back — the tmux behaviour, and the right one.
393 pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J"; 407 pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J";
394 pub const wall_teardown = terminal_teardown; 408 pub const wall_teardown = terminal_teardown;
395 409
@@ -1019,7 +1033,11 @@ pub const Core = struct {
1019 // we never entered would wipe the user's own scrollback, and a wall 1033 // we never entered would wipe the user's own scrollback, and a wall
1020 // tile never entered it at all — the wall is still on it, and still 1034 // tile never entered it at all — the wall is still on it, and still
1021 // using it, after this tile's pump has gone. 1035 // using it, after this tile's pump has gone.
1022 self.releaseTerminal(); 1036 // `.write`, always. This is the path a pump takes when its SESSION
1037 // ends under a zoom — nobody moved the zoom, so nobody else wrote
1038 // the release, and the wall goes on running with the terminal still
1039 // in that session's modes.
1040 self.releaseTerminal(.write);
1023 if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {}; 1041 if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {};
1024 dumpPredictStats(self.overlay.counters); 1042 dumpPredictStats(self.overlay.counters);
1025 self.overlay.deinit(); 1043 self.overlay.deinit();
@@ -1128,16 +1146,70 @@ pub const Core = struct {
1128 /// 1146 ///
1129 /// It sets `claim`, and `claim` is what arms the undo. Every mode a 1147 /// It sets `claim`, and `claim` is what arms the undo. Every mode a
1130 /// promoted session then asks this terminal for goes out under that 1148 /// promoted session then asks this terminal for goes out under that
1131 /// flag (`writeSideChannel`) and comes off under it 1149 /// flag (`writeSideChannel`), so the set and its undo are one flag
1132 /// (`releaseTerminal`), so the set and its undo are one flag rather 1150 /// rather than an ordering — `ownTerminal`'s argument, applied to the
1133 /// than an ordering — `ownTerminal`'s argument, applied to the pair 1151 /// pair that turns over many times per wall instead of once per
1134 /// that turns over many times per wall instead of once per process. 1152 /// process.
1135 pub fn claimTerminal(self: *Core) void { 1153 ///
1136 if (!self.is_tty or self.claim != .none) return; 1154 /// Taken under the SINK, and that is about order, not about painting.
1137 proto.writeAllFd(self.out_fd, session_claim) catch {}; 1155 /// A driver that shares its terminal writes the previous holder's
1156 /// release itself, under the same lock, immediately before the store
1157 /// that makes the handover visible (wallview's `setZoom`). A claim
1158 /// taken outside that lock can be preempted between reading the store
1159 /// and writing its modes, land AFTER the release meant to precede it,
1160 /// and leave a terminal in modes nothing is arranged to undo — because
1161 /// the demote deliberately writes nothing. So the sink's answer is both
1162 /// "may I write" and "is this handover still mine", and `false` here is
1163 /// a promote the zoom moved out from under: it claims nothing, and the
1164 /// next pass demotes a Core that never held anything.
1165 /// The session's OWN modes go on top, and that half is what a promote
1166 /// needs and an attach does not. An attaching client is told the modes
1167 /// moments later — `sendResync` ends with `term_modes` — but a
1168 /// promote's resize is answered by `resyncSnapshot`, which carries no
1169 /// modes, deliberately. So a tile whose application asked for the mouse
1170 /// an hour ago would hold a terminal that never heard about it, and
1171 /// every wheel report would be eaten as this client's scrollback
1172 /// instead of reaching the application it belongs to.
1173 ///
1174 /// The Core knows without asking: `semantic` has tracked every mode
1175 /// sample since the tile was born, because only the WRITE was ever
1176 /// gated on the claim. Level-setting is idempotent, so a session with
1177 /// nothing to say pays a few bytes.
1178 pub fn claimTerminal(self: *Core) bool {
1179 if (!self.is_tty or self.claim != .none) return false;
1180 if (!self.beginPaint()) return false;
1181 defer self.endPaint();
1182 // Set BEFORE the writes: it is what admits them (`writeSideChannel`
1183 // refuses everything under `.none`), and what arms taking them off.
1138 self.claim = .session; 1184 self.claim = .session;
1185 proto.writeAllFd(self.out_fd, session_claim) catch {};
1186 // Written raw rather than through the wrapper `semanticFrame` uses:
1187 // the sink is not reentrant and this call already holds it.
1188 writeSideChannel(
1189 self.alloc,
1190 self.out_fd,
1191 true,
1192 client_core.State,
1193 .{ .terminal_modes = self.semantic.terminal_modes },
1194 appendTermState,
1195 ) catch {};
1196 return true;
1139 } 1197 }
1140 1198
1199 /// Who writes the undo when a claim is given up.
1200 ///
1201 /// `.write` is the ordinary answer and the only one a Core can reach on
1202 /// its own. `.already_written` exists because a shared terminal's
1203 /// handover has to be ORDERED, not merely eventual: the wall's zoom
1204 /// transition writes the release itself, on the thread that moves the
1205 /// zoom, before the store that lets the next tile's pump see the move.
1206 /// The pump that is being demoted then finds the bytes already gone and
1207 /// has only its own state left to drop. Spelled as an argument rather
1208 /// than a second method so every call site has to say which it is —
1209 /// silently taking the wrong one is a terminal left reporting clicks,
1210 /// or two releases racing a claim.
1211 pub const Undo = enum { write, already_written };
1212
1141 /// Give the terminal back: the demote, and every other way out. 1213 /// Give the terminal back: the demote, and every other way out.
1142 /// 1214 ///
1143 /// Nothing goes on the WIRE — a demote is client-local, which is the 1215 /// Nothing goes on the WIRE — a demote is client-local, which is the
@@ -1146,16 +1218,18 @@ pub const Core = struct {
1146 /// A wall left still reporting clicks into the user's shell is the 1218 /// A wall left still reporting clicks into the user's shell is the
1147 /// failure this pairs against. 1219 /// failure this pairs against.
1148 /// 1220 ///
1149 /// The scroll view goes with it. The stripe that resumes paints live 1221 /// The scroll view goes with it whichever way the undo went. The stripe
1150 /// state from the same replica, so a Core still suppressing paints for 1222 /// that resumes paints live state from the same replica, so a Core
1151 /// a history page would come back to a zoom showing nothing. 1223 /// still suppressing paints for a history page would come back to a
1224 /// zoom showing nothing.
1152 /// 1225 ///
1153 /// Idempotent, and it says which teardown by what was claimed — the 1226 /// Idempotent, and it says which teardown by what was claimed — the
1154 /// same flag, read the other way. 1227 /// same flag, read the other way.
1155 pub fn releaseTerminal(self: *Core) void { 1228 pub fn releaseTerminal(self: *Core, undo: Undo) void {
1156 const held = self.claim; 1229 const held = self.claim;
1157 self.claim = .none; 1230 self.claim = .none;
1158 self.dropScrollView(); 1231 self.dropScrollView();
1232 if (undo == .already_written) return;
1159 switch (held) { 1233 switch (held) {
1160 .none => {}, 1234 .none => {},
1161 .whole => proto.writeAllFd(self.out_fd, terminal_teardown) catch {}, 1235 .whole => proto.writeAllFd(self.out_fd, terminal_teardown) catch {},
@@ -1340,8 +1414,25 @@ pub const Core = struct {
1340 1414
1341 /// A term_event or term_modes frame: decoded by the shared core, then 1415 /// A term_event or term_modes frame: decoded by the shared core, then
1342 /// rendered onto the host terminal by this platform's adapters. 1416 /// rendered onto the host terminal by this platform's adapters.
1417 ///
1418 /// The decode happens whatever the sink says and the WRITE does not,
1419 /// which is the same split the claim already makes: a demoted wall tile
1420 /// tracks its session's modes for the promote that will need them (see
1421 /// `claimTerminal`) and puts nothing on a terminal it does not hold.
1422 ///
1423 /// Under the sink for a reason the claim gate does NOT cover, and the
1424 /// two are worth keeping apart. The claim answers ORDER — may these
1425 /// bytes exist on this terminal at all. The sink answers ATOMICITY — an
1426 /// OSC 52 clipboard payload runs to 64 KiB and leaves here as a write
1427 /// loop, so another thread's screen clear spliced into the middle of it
1428 /// leaves the terminal hunting for a string terminator and eating
1429 /// everything painted after. Short escapes were never the hazard; the
1430 /// one unbounded side channel is.
1343 fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void { 1431 fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void {
1344 switch (self.semantic.receive(frame_type, payload)) { 1432 const decoded = self.semantic.receive(frame_type, payload);
1433 if (!self.beginPaint()) return;
1434 defer self.endPaint();
1435 switch (decoded) {
1345 .ignored => {}, 1436 .ignored => {},
1346 .state => |state| { 1437 .state => |state| {
1347 // Mode samples are deliberately not deduplicated. 1438 // Mode samples are deliberately not deduplicated.
@@ -1380,6 +1471,11 @@ pub const Core = struct {
1380 /// sends an empty one, so a repeat can never clear a title the user is 1471 /// sends an empty one, so a repeat can never clear a title the user is
1381 /// looking at. 1472 /// looking at.
1382 fn titleFrame(self: *Core, payload: []const u8) !void { 1473 fn titleFrame(self: *Core, payload: []const u8) !void {
1474 // Under the sink, `semanticFrame`'s atomicity reason: a title is
1475 // bounded at `term_title_max` but it is still an OSC with a
1476 // terminator, and a clear spliced into one is the same lost screen.
1477 if (!self.beginPaint()) return;
1478 defer self.endPaint();
1383 try writeSideChannel( 1479 try writeSideChannel(
1384 self.alloc, 1480 self.alloc,
1385 self.out_fd, 1481 self.out_fd,
@@ -2400,6 +2496,168 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the
2400 } 2496 }
2401 } 2497 }
2402 2498
2499 test "interact: a borrowed terminal's claim is the client's, and every teardown undoes it" {
2500 // The claim a wall tile takes at a promote is a SUBSTRING of what a
2501 // client takes at startup, and the release is the head of every
2502 // teardown there is. Structural rather than repeated: a mouse mode
2503 // added to one claim is added to all of them, and one dropped from the
2504 // release is dropped from every path that undoes a claim.
2505 //
2506 // The pairing this pins turns over many times per run — once per zoom,
2507 // where a client's turns over once per process — so a half that only
2508 // works on the exit path is a wall that leaves a terminal reporting
2509 // clicks into a shell the moment somebody unzooms.
2510 try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1006h", session_claim);
2511 try std.testing.expect(std.mem.endsWith(u8, terminal_setup, session_claim));
2512 try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release));
2513 try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null);
2514 // And it is exported, because the DEMOTE is written by a thread that
2515 // holds no Core: wallview's `setZoom`. A release that only a Core could
2516 // write is a release that cannot be ordered against the next Core's
2517 // claim.
2518 try std.testing.expectEqualStrings(
2519 "\x1b[?2004l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l",
2520 session_release,
2521 );
2522 // The wall enters the same screen a client does, minus what a SESSION
2523 // brings — its tiles bring that, one at a time.
2524 try std.testing.expect(std.mem.indexOf(u8, wall_setup, session_claim) == null);
2525 try std.testing.expect(std.mem.startsWith(u8, wall_setup, "\x1b[22;0t\x1b[?1049h"));
2526
2527 // Every mode the tile's claim turns ON has an `l` for it in the
2528 // release...
2529 inline for (client_mouse_capture) |dec| {
2530 try std.testing.expect(std.mem.indexOf(
2531 u8,
2532 session_claim,
2533 comptime std.fmt.comptimePrint("\x1b[?{d}h", .{dec}),
2534 ) != null);
2535 try std.testing.expect(std.mem.indexOf(
2536 u8,
2537 session_release,
2538 comptime std.fmt.comptimePrint("\x1b[?{d}l", .{dec}),
2539 ) != null);
2540 }
2541 // ...and so does every mode the DAEMON can ask a promoted tile to
2542 // mirror, which is the half that drifts: a zoomed tile's application
2543 // can turn on 1002 or 1003, modes the client's own capture set never
2544 // names and nothing else would take back off.
2545 inline for (proto.mouse_modes) |m| {
2546 try std.testing.expect(std.mem.indexOf(
2547 u8,
2548 session_release,
2549 comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}),
2550 ) != null);
2551 }
2552 }
2553
2554 test "interact: a promote takes the mouse, a demote gives it back, a demote twice writes nothing" {
2555 const alloc = std.testing.allocator;
2556 // Non-blocking, so a half of the pair that stopped writing FAILS here
2557 // rather than parking this test on a read that will never return. A
2558 // wedged test step prints nothing at all, and a mutation that hangs the
2559 // suite is a mutation whose answer nobody gets.
2560 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
2561 defer std.posix.close(p[0]);
2562 defer std.posix.close(p[1]);
2563
2564 var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 });
2565 // A pipe is not a tty, and a Core with no terminal claims nothing —
2566 // which is the case this test is NOT about. Set by hand rather than
2567 // built on a pty fixture, because what is under test is four escape
2568 // sequences and a flag.
2569 core.is_tty = true;
2570 defer core.deinit();
2571
2572 var buf: [256]u8 = undefined;
2573
2574 try std.testing.expect(core.claimTerminal());
2575 try std.testing.expectEqual(Claim.session, core.claim);
2576 try std.testing.expectEqualStrings(session_claim, buf[0..try std.posix.read(p[0], &buf)]);
2577
2578 // A promote onto the tile already zoomed re-asserts the grid, not the
2579 // modes: nothing is written a second time, so nothing has to come off
2580 // twice either. `false` says so — the same answer a promote gets when
2581 // the zoom moved out from under it.
2582 try std.testing.expect(!core.claimTerminal());
2583
2584 core.releaseTerminal(.write);
2585 try std.testing.expectEqual(Claim.none, core.claim);
2586 try std.testing.expectEqualStrings(session_release, buf[0..try std.posix.read(p[0], &buf)]);
2587
2588 // A release with nothing claimed writes nothing. That is what makes
2589 // `deinit` safe on a tile nobody ever zoomed — and what stops a wall
2590 // from unsetting a mode the SESSION never set, on a terminal the wall
2591 // is about to hand back to the user.
2592 core.releaseTerminal(.write);
2593 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
2594
2595 // `.already_written` drops the claim and writes NOTHING, because the
2596 // bytes were somebody else's to emit and they already did. A shared
2597 // terminal's handover is ordered by that split; a second copy of the
2598 // release from here is the race it exists to remove.
2599 try std.testing.expect(core.claimTerminal());
2600 _ = drainPipe(p[0], &buf);
2601 core.releaseTerminal(.already_written);
2602 try std.testing.expectEqual(Claim.none, core.claim);
2603 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
2604 }
2605
2606 test "interact: only the session's lifecycle frames come back to the driver" {
2607 const alloc = std.testing.allocator;
2608 // No terminal at either end: every paint and every side channel is
2609 // refused, so what this exercises is the ROUTING and nothing else.
2610 var core = try Core.initSized(alloc, -1, -1, .{ .cols = 80, .rows = 24 });
2611 defer core.deinit();
2612
2613 // The set, named. Adding to it is asking every driver to learn a new
2614 // meaning, so it is pinned rather than described.
2615 const lifecycle = [_]proto.MsgType{ .exit_status, .taken_over, .sessions_reply };
2616 for (lifecycle) |t| {
2617 try std.testing.expectEqual(Routed.not_mine, try core.frame(t, ""));
2618 }
2619
2620 // ...and nothing else is, over every type the enum names.
2621 inline for (@typeInfo(proto.MsgType).@"enum".fields) |f| {
2622 const t: proto.MsgType = @enumFromInt(f.value);
2623 var is_lifecycle = false;
2624 for (lifecycle) |l| {
2625 if (l == t) is_lifecycle = true;
2626 }
2627 if (!is_lifecycle) {
2628 // An error is an answer too — it is certainly not the Core
2629 // declining to route — so a type that fails on an empty payload
2630 // counts as one the Core owns.
2631 const r = core.frame(t, "") catch Routed.handled;
2632 try std.testing.expect(r != .not_mine);
2633 }
2634 }
2635 }
2636
2637 test "interact: a frame the Core answers itself never reaches the driver" {
2638 const alloc = std.testing.allocator;
2639 var core = try Core.initSized(alloc, -1, -1, .{ .cols = 80, .rows = 24 });
2640 defer core.deinit();
2641
2642 // A title is looked at, acted on (refused here — no terminal claim) and
2643 // done with. The driver's loop carries on without a branch for it.
2644 try std.testing.expectEqual(Routed.handled, try core.frame(.term_title, "hello"));
2645 // A snapshot too short to read left the replica untouched, so there is
2646 // nothing to paint and nothing to record: the driver's `continue`.
2647 try std.testing.expectEqual(Routed.skip, try core.frame(.snapshot, ""));
2648 try std.testing.expect(!core.rep.state_since_attach);
2649 // A page of history for a view that is already live would be painted
2650 // over a screen it no longer describes.
2651 try std.testing.expectEqual(Routed.skip, try core.frame(.scrollback_chunk, "\x00\x00\x00\x00\x01\x00"));
2652 // A delta the replica could not compose is a resync — and it is STATE
2653 // to its driver anyway, because `apply` marks the attach as landed
2654 // before it can refuse. Both drivers write their attach history off
2655 // that, and a shell that dies in the same read as its first delta must
2656 // not lose it.
2657 try std.testing.expectEqual(Routed.resync, try core.frame(.delta, ""));
2658 try std.testing.expect(core.rep.state_since_attach);
2659 }
2660
2403 test "interact: an unvalidated clipboard effect writes nothing" { 2661 test "interact: an unvalidated clipboard effect writes nothing" {
2404 const alloc = std.testing.allocator; 2662 const alloc = std.testing.allocator;
2405 var out: std.ArrayList(u8) = .empty; 2663 var out: std.ArrayList(u8) = .empty;
src/wallview.zig
Old New
@@ -86,9 +86,7 @@ const xdg = @import("xdg");
86 const sockpath = @import("sockpath"); 86 const sockpath = @import("sockpath");
87 const proxy = @import("proxy"); 87 const proxy = @import("proxy");
88 const Engine = @import("engine").Engine; 88 const Engine = @import("engine").Engine;
89 const Replica = @import("replica").Replica;
90 const paint = @import("paint"); 89 const paint = @import("paint");
91 const predict = @import("predict");
92 // The chord table and the prediction hooks a zoomed tile shares with the 90 // The chord table and the prediction hooks a zoomed tile shares with the
93 // client: one interaction core, not a second copy (interact.zig). 91 // client: one interaction core, not a second copy (interact.zig).
94 const interact = @import("interact"); 92 const interact = @import("interact");
@@ -456,34 +454,58 @@ fn moveSelection(tiles: []Tile, shared: *Shared, next: usize) void {
456 paintLabelLocked(&tiles[next]); 454 paintLabelLocked(&tiles[next]);
457 } 455 }
458 456
459 /// Draw this tile's session — as a stripe on the wall, or full-screen while 457 /// Draw this tile's session as a STRIPE on the wall.
460 /// it is the zoom, or not at all while some OTHER tile is.
461 /// 458 ///
462 /// Returns whether anything actually reached the terminal, because the 459 /// Returns whether anything actually reached the terminal, because the
463 /// repaint generation must only be recorded by a pump that satisfied it: a 460 /// repaint generation must only be recorded by a pump that satisfied it: a
464 /// tile hidden behind another's zoom has not, and its stripe would stay 461 /// tile hidden behind another's zoom has not, and its stripe would stay
465 /// blank when the zoom ends. 462 /// blank when the zoom ends.
466 fn paintTile(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, overlay: *predict.Overlay) bool { 463 ///
464 /// The full-screen half of this is the tile's Core now
465 /// (`interact.Core.repaint`, and every paint a frame drives through it):
466 /// while a tile is PROMOTED its Core owns the terminal and paints the whole
467 /// of it, and while it is DEMOTED the Core paints nothing at all and this
468 /// draws the wall's own crop of the same replica. One replica per tile, one
469 /// applier for it, two ways of looking at it.
470 fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine) bool {
467 t.shared.paint_mu.lock(); 471 t.shared.paint_mu.lock();
468 defer t.shared.paint_mu.unlock(); 472 defer t.shared.paint_mu.unlock();
469 switch (paintModeLocked(t)) { 473 if (paintModeLocked(t) != .stripe) return false;
470 .none => return false, 474 paint.renderStripe(alloc, eng, t.stripe.top + 1, .{
471 .stripe => paint.renderStripe(alloc, eng, t.stripe.top + 1, .{ 475 .cols = t.shared.size.cols,
472 .cols = t.shared.size.cols, 476 .rows = t.viewRows(),
473 .rows = t.viewRows(), 477 }, t.shared.out_fd) catch {};
474 }, t.shared.out_fd) catch {},
475 .full => {
476 paint.renderClipped(alloc, eng, t.shared.size, t.shared.out_fd) catch {};
477 // The overlay goes back on top after every authoritative paint,
478 // client.zig's reason verbatim: the rows just drawn have
479 // overwritten predictions that are still outstanding, and one
480 // frame of flicker is exactly what prediction exists to avoid.
481 interact.paintOverlay(alloc, overlay, eng.cursorPos(), t.shared.size, t.shared.out_fd);
482 },
483 }
484 return true; 478 return true;
485 } 479 }
486 480
481 /// The wall's answer to a Core asking whether it may paint: only while this
482 /// tile IS the zoom, and with `paint_mu` held for as long as the paint takes
483 /// (see `interact.Sink`).
484 ///
485 /// This is phase 1's enforcement point, unchanged and now SINGLE. Then it
486 /// was spelled at four sites — the promote repaint, the frame repaint, the
487 /// expiry repaint and the speculation — and any one of them forgetting it
488 /// was a prediction glyph painted onto whichever session the zoom had moved
489 /// to. Now a demoted tile's Core cannot write a grid byte at all: no rows,
490 /// no overlay, nothing.
491 ///
492 /// The lock is what makes the answer stay true for the length of the paint.
493 /// A zoom that moved between the decision and the bytes would put one
494 /// session's rows on another session's screen — the same reason `setZoom`
495 /// does its whole transition under one hold.
496 fn tilePaintBegin(ctx: ?*anyopaque) bool {
497 const t: *Tile = @ptrCast(@alignCast(ctx.?));
498 t.shared.paint_mu.lock();
499 if (paintModeLocked(t) == .full) return true;
500 t.shared.paint_mu.unlock();
501 return false;
502 }
503
504 fn tilePaintEnd(ctx: ?*anyopaque) void {
505 const t: *Tile = @ptrCast(@alignCast(ctx.?));
506 t.shared.paint_mu.unlock();
507 }
508
487 /// The one place a wall tile puts an attach on the wire. 0x0 while 509 /// The one place a wall tile puts an attach on the wire. 0x0 while
488 /// unzoomed — a stripe never claims the grid and never creates a session — 510 /// unzoomed — a stripe never claims the grid and never creates a session —
489 /// and this terminal's size while zoomed, because a reconnect under a zoom 511 /// and this terminal's size while zoomed, because a reconnect under a zoom
@@ -518,11 +540,19 @@ fn ring(t: *const Tile) void {
518 /// enforcement of "an unzoomed tile claims nothing". There is no other 540 /// enforcement of "an unzoomed tile claims nothing". There is no other
519 /// caller and there must not be one. 541 /// caller and there must not be one.
520 /// 542 ///
521 /// Mouse reporting is deliberately NOT part of this in phase 1: the wall 543 /// Mouse reports travel this path too, unsplit. The splitting is the
522 /// never asks its terminal for mouse reports, so nothing arriving here can 544 /// PUMP's, because everything it needs is the pump's: whether the session's
523 /// be one, and the client's wheel/scrollback machinery is not wired up. A 545 /// own application asked for the mouse, where this tile's scrollback view
524 /// zoomed tile is keyboard-only until phase 3 converges the two input 546 /// sits, and the transport a wheel notch turns into a `fetch_scrollback`.
525 /// loops and it comes for free. 547 /// So the mailbox carries whatever the terminal wrote, minus the chord, and
548 /// `interact.Core.forward` takes it apart on the other side.
549 ///
550 /// Nothing arriving here while the wall is UNZOOMED can be a mouse report:
551 /// the wall asks its own terminal for no mouse mode, and a promoted tile's
552 /// modes come off at the demote (`Core.releaseTerminal`). A report in
553 /// flight across that boundary lands in the unzoomed key loop instead,
554 /// where its bytes are selection keys or nothing — the wall's input, never
555 /// a session's.
526 /// 556 ///
527 /// A chunk that does not fit is dropped WHOLE. The obvious alternative — 557 /// A chunk that does not fit is dropped WHOLE. The obvious alternative —
528 /// copy what fits — splices: the head of one read lands in the mailbox, the 558 /// copy what fits — splices: the head of one read lands in the mailbox, the
@@ -601,21 +631,53 @@ fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Trans
601 return null; 631 return null;
602 } 632 }
603 633
604 /// One tile's life: dial → attach → replay frames into the replica → 634 /// The transport died, or the dial has to be redone: rebuild it on the
605 /// repaint. Runs on its own thread (see module header). On transport 635 /// CLI's backoff schedule and re-attach quoting what this tile already
606 /// death: reconnect on the CLI's backoff schedule, quoting 636 /// holds. False means the pump is finished — the wall quit, or the tile was
607 /// have_seq/have_epoch, and M7's snapshot-vs-delta resolution does the 637 /// forgotten while it was retrying.
608 /// rest. Ends when `running` clears, the session exits, or the attach is 638 ///
609 /// refused. 639 /// One function for what were four copies of the same five steps. They had
640 /// begun to differ: only some of them dropped a scroll view that a resync
641 /// was about to make meaningless, and none of them told the overlay that
642 /// what it was predicting against no longer existed.
643 fn redial(
644 t: *Tile,
645 alloc: std.mem.Allocator,
646 core: *interact.Core,
647 transport: *client.Transport,
648 target: client.Target,
649 state: *State,
650 ) bool {
651 transport.close();
652 state.* = .reconnecting;
653 paintLabel(t, state.*);
654 // The resync's own paint is what will arrive, so a history page held
655 // here would be silently replaced a moment later.
656 core.dropScrollView();
657 transport.* = dial(alloc, t, target) orelse return false;
658 // Clears `state_since_attach` (so the next exit_status is read as a
659 // refusal again) and drops speculation made against a connection that
660 // no longer exists.
661 core.reattached();
662 const have = core.rep.attachArgs();
663 sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false;
664 return true;
665 }
666
667 /// One tile's life: dial → attach → replay frames into its Core → repaint.
668 /// Runs on its own thread (see module header). On transport death:
669 /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch,
670 /// and M7's snapshot-vs-delta resolution does the rest. Ends when `running`
671 /// clears, the session exits, or the attach is refused.
610 /// 672 ///
611 /// This thread is also the tile's WRITER: the promote resize and every 673 /// This thread is also the tile's WRITER: the promote resize and every
612 /// keystroke the keyboard queued go out from here, because a Transport has 674 /// keystroke the keyboard queued go out from here, because a Transport has
613 /// exactly one owning thread (module header). 675 /// exactly one owning thread (module header).
614 fn pumpTile(t: *Tile) void { 676 fn pumpTile(t: *Tile) void {
615 // FIRST defer, so it runs LAST: every `return` below — a refused 677 // FIRST defer, so it runs LAST: every `return` below — a refused
616 // attach, an exited session, a dial the quit interrupted, an engine 678 // attach, an exited session, a dial the quit interrupted, a Core that
617 // that would not initialise — is this tile going quiet for good, and 679 // would not initialise — is this tile going quiet for good, and the
618 // the keyboard needs to know which tiles it has to paint for. Declared 680 // keyboard needs to know which tiles it has to paint for. Declared
619 // before the allocator's own defer so nothing can end this thread 681 // before the allocator's own defer so nothing can end this thread
620 // without it running. 682 // without it running.
621 defer t.alive.store(false, .release); 683 defer t.alive.store(false, .release);
@@ -631,18 +693,32 @@ fn pumpTile(t: *Tile) void {
631 // stderr would corrupt the alternate screen. Same as the hub's pump. 693 // stderr would corrupt the alternate screen. Same as the hub's pump.
632 if (target == .hand) target.hand.report_fallback = false; 694 if (target == .hand) target.hand.report_fallback = false;
633 695
634 const eng = Engine.init(alloc, .{ .cols = 80, .rows = 24 }) catch return; 696 // ONE Core per tile, from birth — never built at promote time. It owns
635 defer eng.deinit(); 697 // this tile's replica and its prediction overlay for the tile's whole
636 var rep = Replica.init(alloc, eng); 698 // life, because both are things a STRIPE needs: the stripe is painted
637 699 // from that replica, and what decides whether a keystroke may be
638 // Prediction for the zoomed tile — the client's overlay, not a second 700 // speculated at all is the pty's line discipline, which arrives in
639 // one (client.zig owns offerKeystroke/reconcileOverlay/paintOverlay). 701 // `.pty_mode` frames long before anyone zooms. A Core built at promote
640 // It stays here while the tile is a stripe rather than being built at 702 // would be a second replica for one tile, which is the invariant.
641 // promote time, because the thing that decides whether a keystroke may 703 //
642 // be speculated at all is the pty's line discipline, and that arrives 704 // Sized from the wall's ONE reading of the terminal rather than a
643 // in `.pty_mode` frames long before anyone zooms. 705 // second ioctl: the stripes were cut from that number, and a promoted
644 var overlay = predict.Overlay.init(alloc, t.shared.size.cols, t.shared.size.rows); 706 // tile claims exactly it.
645 defer overlay.deinit(); 707 //
708 // `in_fd` is the wall's stdin and this Core never reads it — the
709 // keyboard thread does, on the far side of the mailbox. It is passed
710 // because it is the truth about whether there is a terminal here at all
711 // (`is_tty`), which the mouse split and the side channels are gated on.
712 var core = interact.Core.initSized(
713 alloc,
714 std.posix.STDIN_FILENO,
715 t.shared.out_fd,
716 t.shared.size,
717 ) catch return;
718 defer core.deinit();
719 // Where this Core's paints are allowed to land: nowhere, until this
720 // tile is the zoom. See `tilePaintBegin`.
721 core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd };
646 722
647 paintLabel(t, .connecting); 723 paintLabel(t, .connecting);
648 var transport = dial(alloc, t, target) orelse return; 724 var transport = dial(alloc, t, target) orelse return;
@@ -671,68 +747,68 @@ fn pumpTile(t: *Tile) void {
671 if (fds[1].revents != 0) drainWake(t); 747 if (fds[1].revents != 0) drainWake(t);
672 748
673 const zoomed = t.shared.zoom.load(.acquire) == t.idx; 749 const zoomed = t.shared.zoom.load(.acquire) == t.idx;
674 if (zoomed != promoted) { 750
675 promoted = zoomed; 751 // PROMOTE, and BEFORE the mailbox is drained below: the claim goes
676 if (zoomed) { 752 // out before the first keystroke can, which is what keeps the
677 // PROMOTE. The claim goes out before the first keystroke 753 // passivity rule exception-free — the client that types this
678 // can, which is what keeps the passivity rule 754 // session is a full-size one, claiming under latest-wins like any
679 // exception-free: the client that types this session is a 755 // other. No attach, no dial: the same connection, resized.
680 // full-size one, claiming under latest-wins like any 756 if (zoomed and !promoted) {
681 // other. No attach, no dial — the same connection, resized. 757 promoted = true;
682 overlay.setResizePending(true); 758 // The TERMINAL side of the claim, which the daemon never hears
683 transport.writeFrame( 759 // about. The wall owns the screen and keeps it; what a session
684 .resize, 760 // needs on top is the mouse modes its wheel is read out of —
685 &proto.encodeSize(t.shared.size.cols, t.shared.size.rows), 761 // without them this terminal answers the wheel by synthesising
686 ) catch { 762 // arrow keys into the session (DEC 1007), which is exactly what
687 transport.close(); 763 // a zoomed tile did before it had a Core. Setting it is also
688 state = .reconnecting; 764 // what ARMS the demote's teardown; see `Core.claimTerminal`.
689 paintLabel(t, state); 765 //
690 transport = dial(alloc, t, target) orelse return; 766 // Its answer is discarded on purpose. `false` is the zoom
691 rep.state_since_attach = false; 767 // having moved away between the load above and the lock the
692 const have = rep.attachArgs(); 768 // claim takes — the claim is then correctly not made, and the
693 sendAttach(t, &transport, have.have_seq, have.have_epoch) catch return; 769 // next pass demotes a Core holding nothing. What still goes out
694 continue :outer; 770 // either way is the RESIZE, because it is about the daemon's
695 }; 771 // grid rather than about this terminal, and re-asserting a size
696 // The replica has been hot the whole time this was a 772 // the session already has costs one snapshot and claims nothing
697 // stripe, so the zoom paints from it NOW rather than 773 // under latest-wins.
698 // waiting for the daemon's answering snapshot. That is the 774 _ = core.claimTerminal();
699 // headline: moving the zoom costs a local repaint. 775 core.overlay.setResizePending(true);
700 _ = paintTile(t, alloc, eng, &overlay); 776 transport.writeFrame(
701 } else { 777 .resize,
702 // DEMOTE. Nothing goes on the wire, deliberately — see the 778 &proto.encodeSize(t.shared.size.cols, t.shared.size.rows),
703 // module header. Only the speculation is dropped, because 779 ) catch {
704 // it describes a screen this terminal no longer shows. 780 if (!redial(t, alloc, &core, &transport, target, &state)) return;
705 overlay.flush(); 781 continue :outer;
706 } 782 };
783 // The replica has been hot the whole time this was a stripe, so
784 // the zoom paints from it NOW rather than waiting for the
785 // daemon's answering snapshot. That is the headline: moving the
786 // zoom costs a local repaint.
787 core.repaint() catch {};
707 } 788 }
708 789
709 // Whatever the keyboard left is sent whatever the zoom is doing 790 // Whatever the keyboard left, through everything a plain client's
710 // now: it was typed AT this session while this tile was the zoom, 791 // keystrokes go through: the mouse split, the wheel, alternate
711 // and the keyboard is the only writer of that mailbox. 792 // scroll, the scrollback view, the prediction and the input frame.
793 // Phase 1 hand-rolled a subset of that, and a zoomed tile had no
794 // wheel at all.
795 //
796 // Sent whatever the zoom is doing NOW: these bytes were typed AT
797 // this session while this tile was the zoom, and the keyboard is
798 // the only writer of that mailbox. The demote is deliberately
799 // handled AFTER them, for the same reason read the other way — a
800 // report typed at the zoom is the zoom's to split, not raw bytes
801 // for somebody's shell to echo.
712 var keys_buf: [mailbox_max]u8 = undefined; 802 var keys_buf: [mailbox_max]u8 = undefined;
713 const keys = takeKeys(t, &keys_buf); 803 const keys = takeKeys(t, &keys_buf);
714 if (keys.len > 0) { 804 if (keys.len > 0) {
715 if (zoomed) { 805 // A paint that would not allocate is not a dead link: the
716 // Speculate under `paint_mu`, and only while this tile 806 // replica is untouched and the next frame redraws from it.
717 // still owns the terminal: an overlay glyph painted onto 807 const step = core.forward(&transport, keys) catch interact.Step.ok;
718 // another tile's zoom would be graffiti. The prediction is 808 if (step == .lost) {
719 // an OVERLAY — it never enters the replica. 809 if (!redial(t, alloc, &core, &transport, target, &state)) return;
720 t.shared.paint_mu.lock();
721 if (paintModeLocked(t) == .full) {
722 interact.offerKeystroke(alloc, &overlay, eng, keys, t.shared.size, t.shared.out_fd);
723 }
724 t.shared.paint_mu.unlock();
725 }
726 transport.writeFrame(.input, keys) catch {
727 transport.close();
728 state = .reconnecting;
729 paintLabel(t, state);
730 transport = dial(alloc, t, target) orelse return;
731 rep.state_since_attach = false;
732 const have = rep.attachArgs();
733 sendAttach(t, &transport, have.have_seq, have.have_epoch) catch return;
734 continue :outer; 810 continue :outer;
735 }; 811 }
736 // Input is moving again, so "input dropped" has stopped being 812 // Input is moving again, so "input dropped" has stopped being
737 // news. Repainted rather than merely cleared, because the bar 813 // news. Repainted rather than merely cleared, because the bar
738 // is still carrying the old sentence until something draws 814 // is still carrying the old sentence until something draws
@@ -741,11 +817,30 @@ fn pumpTile(t: *Tile) void {
741 if (t.in_dropped.swap(false, .acq_rel)) paintLabel(t, state); 817 if (t.in_dropped.swap(false, .acq_rel)) paintLabel(t, state);
742 } 818 }
743 819
820 // DEMOTE. Nothing goes on the WIRE, deliberately — that is the
821 // whole of "an unzoomed tile claims nothing", and the e2e attach
822 // counter is its witness. What DOES come off is the terminal
823 // claim: the mouse modes this session asked for and the bracketed
824 // paste it armed, because the wall is about to show stripes again
825 // and a wall still reporting clicks into the user's shell is what
826 // that pairing exists to prevent.
827 if (!zoomed and promoted) {
828 promoted = false;
829 // `.already_written`: the bytes went out on the KEYBOARD's
830 // thread, under `paint_mu`, before the store this pass read —
831 // see `setZoom`. Writing them again from here is what made the
832 // handover a race, so what is left is the claim flag and the
833 // scroll view.
834 core.releaseTerminal(.already_written);
835 // The speculation described a screen this terminal no longer
836 // shows.
837 core.overlay.flush();
838 }
839
744 // A prediction the daemon never answered must not sit on the 840 // A prediction the daemon never answered must not sit on the
745 // screen forever, and only the clock can say so — no frame will. 841 // screen forever, and only the clock can say so — no frame will.
746 if (zoomed and overlay.expire(std.time.milliTimestamp()) == .contradicted) { 842 // Only while promoted: a stripe has no prediction on it to retire.
747 _ = paintTile(t, alloc, eng, &overlay); 843 if (promoted) core.idle() catch {};
748 }
749 844
750 // A zoom wrote over every stripe, and a quiet session sends 845 // A zoom wrote over every stripe, and a quiet session sends
751 // nothing to trigger a repaint. The replica is current — the 846 // nothing to trigger a repaint. The replica is current — the
@@ -755,10 +850,13 @@ fn pumpTile(t: *Tile) void {
755 const gen = t.shared.repaint_gen.load(.acquire); 850 const gen = t.shared.repaint_gen.load(.acquire);
756 if (gen != painted_gen) { 851 if (gen != painted_gen) {
757 paintLabel(t, state); 852 paintLabel(t, state);
758 // Recorded only if the paint landed: a tile hidden behind 853 // A PROMOTED tile satisfies the generation by owning the
759 // another tile's zoom has not satisfied this generation, and 854 // screen: its Core painted the whole terminal at the promote
760 // must repaint when the zoom gives the terminal back. 855 // and repaints it on every frame after. A demoted one owes a
761 if (paintTile(t, alloc, eng, &overlay)) painted_gen = gen; 856 // stripe, and records only if the stripe landed — a tile hidden
857 // behind another tile's zoom has not satisfied anything and
858 // must repaint when the terminal comes back to the wall.
859 if (promoted or paintStripe(t, alloc, core.grid())) painted_gen = gen;
762 } 860 }
763 861
764 // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames 862 // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames
@@ -771,77 +869,57 @@ fn pumpTile(t: *Tile) void {
771 .frame => |f| f, 869 .frame => |f| f,
772 .incomplete => break :frames, 870 .incomplete => break :frames,
773 .closed => { 871 .closed => {
774 transport.close(); 872 if (!redial(t, alloc, &core, &transport, target, &state)) return;
775 state = .reconnecting;
776 paintLabel(t, state);
777 transport = dial(alloc, t, target) orelse return;
778 rep.state_since_attach = false;
779 const have = rep.attachArgs();
780 sendAttach(t, &transport, have.have_seq, have.have_epoch) catch return;
781 continue :outer; 873 continue :outer;
782 }, 874 },
783 }; 875 };
784 defer frame.deinit(alloc); 876 defer frame.deinit(alloc);
785 switch (frame.type) { 877 // Everything the frame means to the replica and the screen,
786 .snapshot, .delta => switch (rep.apply(frame.type, frame.payload) catch break :frames) { 878 // in the one place that switch lives. What the Core paints
787 .painted => { 879 // it paints only if this tile owns the terminal — and what
788 if (state != .up) { 880 // it WRITES it writes only then either, which is why a
789 state = .up; 881 // stripe's session can set a title or arm the mouse without
790 paintLabel(t, state); 882 // the wall's terminal hearing about it.
791 } 883 //
792 // The overlay is judged against the replica the 884 // The one exception is deliberate and is `.pty_mode`: the
793 // frame has just been fed into, which is the 885 // Core feeds the overlay's mode whatever the claim, because
794 // only authority there is. A snapshot resolves 886 // the pty's line discipline is the entire gate on
795 // the promote's resize; a delta gets the 887 // speculation — a password prompt must never be predicted —
796 // ordinary verdict, which is discarded here 888 // and a promote has to start from the truth rather than
797 // because the repaint below is unconditional 889 // from `.never` and a round trip.
798 // and total — a stripe and a zoom both redraw 890 const routed = core.frame(frame.type, frame.payload) catch break :frames;
799 // from the replica, not from the delta. 891 switch (routed) {
800 if (frame.type == .snapshot) { 892 .skip, .handled => {},
801 overlay.setGrid(rep.grid.cols, rep.grid.rows); 893 .state => {
802 overlay.setResizePending(false); 894 if (state != .up) {
803 overlay.flush(); 895 state = .up;
804 overlay.noteSeq(rep.last_seq); 896 paintLabel(t, state);
805 } else { 897 }
806 _ = interact.reconcileOverlay( 898 // The Core has already painted the terminal if this
807 alloc, 899 // tile owns it; this is the wall's own crop of the
808 &overlay, 900 // same replica, and it draws only when it doesn't.
809 eng, 901 _ = paintStripe(t, alloc, core.grid());
810 rep.last_seq,
811 std.time.milliTimestamp(),
812 );
813 }
814 _ = paintTile(t, alloc, eng, &overlay);
815 },
816 // The replica is suspect, not the transport:
817 // re-attach quoting (0,0) explicitly — a quoted
818 // seq would invite the delta that cannot fix us.
819 .resync => {
820 rep.state_since_attach = false;
821 sendAttach(t, &transport, 0, 0) catch return;
822 },
823 }, 902 },
824 .exit_status => { 903 // The replica is suspect, not the transport: re-attach
825 // Before any replay frame this is the refusal path 904 // quoting (0,0) explicitly — a quoted seq would invite
826 // (a 0x0 attach joins but never creates); after, 905 // the delta that cannot fix us.
827 // the session really ended. 906 .resync => {
828 state = if (rep.state_since_attach) .exited else .refused; 907 core.rep.state_since_attach = false;
829 paintLabel(t, state); 908 sendAttach(t, &transport, 0, 0) catch return;
830 return;
831 }, 909 },
832 // The one mode a stripe is NOT passive about. What the 910 .not_mine => switch (frame.type) {
833 // pty's line discipline is doing is the entire gate on 911 .exit_status => {
834 // speculation — a password prompt must never be 912 // Before any replay frame this is the refusal
835 // predicted — so the overlay is told even while 913 // path (a 0x0 attach joins but never creates);
836 // unzoomed, and a promote starts from the truth rather 914 // after, the session really ended.
837 // than from `.never`. Titles, clipboard and bells stay 915 state = if (core.rep.state_since_attach) .exited else .refused;
838 // the interactive client's business. 916 paintLabel(t, state);
839 .pty_mode => { 917 return;
840 if (proto.decodePtyMode(frame.payload)) |flags| { 918 },
841 overlay.setMode(flags); 919 // `taken_over` is unsent (wire-compat only), and a
842 } else |_| {} 920 // `sessions_reply` answers a question no tile asks.
921 else => {},
843 }, 922 },
844 else => {},
845 } 923 }
846 // Only the socket link guarantees one readable event is 924 // Only the socket link guarantees one readable event is
847 // one frame; QUIC may have buffered more. 925 // one frame; QUIC may have buffered more.
@@ -917,6 +995,30 @@ pub fn zoomChord(
917 /// is the promoted PUMP's to send (one thread owns a transport); a demote 995 /// is the promoted PUMP's to send (one thread owns a transport); a demote
918 /// has nothing to send at all, which is the point. 996 /// has nothing to send at all, which is the point.
919 /// 997 ///
998 /// What DOES go on the terminal here is the outgoing zoom's release, and it
999 /// is written on this thread rather than left to that tile's pump because
1000 /// the handover has to be ORDERED and not merely eventual. Two pumps are
1001 /// two threads: A's release and B's claim race, and measured on a real pty
1002 /// A's release landed AFTER B's claim in five runs out of six — benign only
1003 /// because B's level-set happened to follow and repair it. A pump stalled
1004 /// in a dial backoff makes that window seconds wide, and the zoomed tile
1005 /// spends them with every mode off, which is the pre-3b bug exactly.
1006 ///
1007 /// Written BEFORE the store, so it is ordered against every claim that can
1008 /// follow: a pump reads the zoom with `.acquire` and takes its claim under
1009 /// this same lock (`Core.claimTerminal` goes through the sink), so it
1010 /// either claimed entirely before this or finds the zoom already moved.
1011 ///
1012 /// It also closes a hole that is worse than a stray escape. Legacy mouse
1013 /// reporting (1000 without 1006) spells a report `ESC [ M` plus three bytes
1014 /// that are ordinary characters — column 81 is `q`, which QUITS the wall,
1015 /// and column 88 is `x`, which forgets the selected tile and rewrites the
1016 /// wall file. A terminal 88 columns wide or more can therefore destroy wall
1017 /// state with one wheel spin, if reporting is still on when the unzoomed
1018 /// key loop starts reading. Turning it off before the zoom moves means the
1019 /// only reports that can reach that loop are bytes already in the kernel
1020 /// buffer.
1021 ///
920 /// The one thing it DOES paint is for the dead. Only pumps answer 1022 /// The one thing it DOES paint is for the dead. Only pumps answer
921 /// `repaint_gen`, so a tile whose pump has ended has nobody to redraw it 1023 /// `repaint_gen`, so a tile whose pump has ended has nobody to redraw it
922 /// after the clear above — and phase 1 clears the screen on every zoom 1024 /// after the clear above — and phase 1 clears the screen on every zoom
@@ -927,6 +1029,13 @@ pub fn zoomChord(
927 fn setZoom(tiles: []Tile, shared: *Shared, next: usize) void { 1029 fn setZoom(tiles: []Tile, shared: *Shared, next: usize) void {
928 shared.paint_mu.lock(); 1030 shared.paint_mu.lock();
929 defer shared.paint_mu.unlock(); 1031 defer shared.paint_mu.unlock();
1032 // See the note above: the outgoing session's modes come off HERE, on
1033 // the thread that is moving the zoom, ahead of the store that lets the
1034 // next tile's pump claim. The demoted pump writes nothing
1035 // (`Core.Undo.already_written`) — this is that write.
1036 if (shared.zoom.load(.acquire) != no_zoom) {
1037 proto.writeAllFd(shared.out_fd, interact.session_release) catch {};
1038 }
930 shared.zoom.store(next, .release); 1039 shared.zoom.store(next, .release);
931 if (next != no_zoom) { 1040 if (next != no_zoom) {
932 // The selection follows the zoom, so leaving one puts the marker 1041 // The selection follows the zoom, so leaving one puts the marker
@@ -1107,19 +1216,11 @@ fn forgetTile(
1107 relayout(alloc, tiles, present, shared, stepPresent(present, sel, true) orelse sel); 1216 relayout(alloc, tiles, present, shared, stepPresent(present, sel, true) orelse sel);
1108 } 1217 }
1109 1218
1110 fn ttySize(fd: std.posix.fd_t) ?proto.Size {
1111 if (!std.posix.isatty(fd)) return null;
1112 var ws: std.posix.winsize = undefined;
1113 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
1114 if (ws.col < 2 or ws.row < 2) return null;
1115 return .{ .cols = ws.col, .rows = ws.row };
1116 }
1117
1118 /// Run the wall until `q` or Ctrl-\. Returns an exit code. 1219 /// Run the wall until `q` or Ctrl-\. Returns an exit code.
1119 pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 { 1220 pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1120 const stdin_fd = std.posix.STDIN_FILENO; 1221 const stdin_fd = std.posix.STDIN_FILENO;
1121 const stdout_fd = std.posix.STDOUT_FILENO; 1222 const stdout_fd = std.posix.STDOUT_FILENO;
1122 const size = ttySize(stdout_fd) orelse { 1223 const size = interact.ttySize(stdout_fd) orelse {
1123 std.debug.print("mux: wall needs a terminal\n", .{}); 1224 std.debug.print("mux: wall needs a terminal\n", .{});
1124 return 2; 1225 return 2;
1125 }; 1226 };
@@ -1145,8 +1246,25 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1145 raw.iflag.ICRNL = false; 1246 raw.iflag.ICRNL = false;
1146 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); 1247 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
1147 // Alternate screen, cursor hidden for the wall's whole lifetime, 1248 // Alternate screen, cursor hidden for the wall's whole lifetime,
1148 // autowrap off (stripe clipping is the terminal's right edge). 1249 // autowrap off (stripe clipping is the terminal's right edge), title
1149 proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[H\x1b[2J") catch {}; 1250 // pushed. Written from interact's own constant, because the wall's exit
1251 // has to undo what a promoted TILE can have added on top of it and only
1252 // that module knows the whole list — see `interact.wall_teardown`.
1253 proto.writeAllFd(stdout_fd, interact.wall_setup) catch {};
1254 // From here to the key loop every step can fail — a tile array, a
1255 // doorbell pipe per tile, the `present` array — and each of them is a
1256 // `try` that unwinds straight past the restore at the bottom. That
1257 // leaves the user on an alternate screen, in raw mode, with a title
1258 // pushed and a wall that never appeared: no cursor, no prompt, and
1259 // nothing on screen to say why.
1260 //
1261 // It fires only on that window. Every ordinary way out of this function
1262 // is the explicit teardown below, because the function ends in
1263 // `std.posix.exit` rather than a return.
1264 errdefer {
1265 proto.writeAllFd(stdout_fd, interact.wall_teardown) catch {};
1266 std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {};
1267 }
1150 1268
1151 var shared = Shared{ .out_fd = stdout_fd, .size = size }; 1269 var shared = Shared{ .out_fd = stdout_fd, .size = size };
1152 // Never freed: the pump threads are detached and hold pointers into 1270 // Never freed: the pump threads are detached and hold pointers into
@@ -1262,7 +1380,12 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1262 // pump threads are detached and die with the process; joining them 1380 // pump threads are detached and die with the process; joining them
1263 // could wait on a blocked readFrame forever. 1381 // could wait on a blocked readFrame forever.
1264 shared.paint_mu.lock(); 1382 shared.paint_mu.lock();
1265 proto.writeAllFd(stdout_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {}; 1383 // Every mode this terminal can be carrying, off — the wall's own and
1384 // any a PROMOTED tile's session set through it. `q` is not reachable
1385 // from inside a zoom, so the way out of a wall that is still zoomed is
1386 // stdin closing under it, and that path must not leave a terminal
1387 // reporting clicks into the user's shell.
1388 proto.writeAllFd(stdout_fd, interact.wall_teardown) catch {};
1266 std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {}; 1389 std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {};
1267 // Now that the ordinary screen is back: an `x` whose file edit failed 1390 // Now that the ordinary screen is back: an `x` whose file edit failed
1268 // still removed the tile from this wall, so the sentence is about the 1391 // still removed the tile from this wall, so the sentence is about the
@@ -1519,3 +1642,64 @@ test "resolveSpelling refuses a sun_path-overflowing sock path" {
1519 const long = "--sock /" ++ "x" ** 200; 1642 const long = "--sock /" ++ "x" ** 200;
1520 try std.testing.expectError(error.SockPathTooLong, resolveSpelling(alloc, long, null, 30_000)); 1643 try std.testing.expectError(error.SockPathTooLong, resolveSpelling(alloc, long, null, 30_000));
1521 } 1644 }
1645
1646 test "setZoom writes the outgoing zoom's release, and writes it before the store" {
1647 // The keyboard thread's half of the terminal handover. A demoted pump
1648 // deliberately writes nothing (`Core.Undo.already_written`), so if this
1649 // stops emitting, nothing does — and the wall goes on reporting mouse
1650 // clicks into whatever shell the user lands in.
1651 //
1652 // A pipe stands in for the terminal. `ring` writes to a `wake_w` of -1
1653 // and swallows the EBADF, which is what makes a Tile testable without
1654 // threads: the doorbell is a wake, never a fact.
1655 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
1656 defer std.posix.close(p[0]);
1657 defer std.posix.close(p[1]);
1658
1659 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 } };
1660 var tiles = [_]Tile{
1661 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
1662 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
1663 };
1664
1665 var buf: [512]u8 = undefined;
1666
1667 // Zoomed OUT: there is no outgoing session, so there is nothing of a
1668 // session's to take off. The wall's own screen bytes are all this may
1669 // write — a release here would unset modes the user's own terminal may
1670 // have arrived with.
1671 setZoom(&tiles, &shared, 0);
1672 var out = readAvail(p[0], &buf);
1673 try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) == null);
1674
1675 // Zoom A -> zoom B: A's session comes off. The ORDER is what matters
1676 // and it is structural rather than timed — the release is written under
1677 // `paint_mu`, ahead of the store, so the bytes are on the wire before
1678 // any pump can read the new zoom and claim. Here that shows up as the
1679 // release preceding the screen clear the same call writes.
1680 setZoom(&tiles, &shared, 1);
1681 out = readAvail(p[0], &buf);
1682 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse
1683 return error.NoReleaseOnZoomMove;
1684 const clear = std.mem.indexOf(u8, out, "\x1b[H\x1b[2J") orelse
1685 return error.NoClearOnZoomMove;
1686 try std.testing.expect(rel < clear);
1687
1688 // Zoom B -> the wall: same release, and this is the path the e2e
1689 // no-leaked-mode leg counts.
1690 setZoom(&tiles, &shared, no_zoom);
1691 out = readAvail(p[0], &buf);
1692 try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) != null);
1693 }
1694
1695 /// Everything waiting on a non-blocking pipe. Non-blocking so a half that
1696 /// stopped writing fails the assertion rather than parking the test on a
1697 /// read that will never return.
1698 fn readAvail(fd: std.posix.fd_t, buf: []u8) []const u8 {
1699 var n: usize = 0;
1700 while (std.posix.read(fd, buf[n..])) |got| {
1701 if (got == 0) break;
1702 n += got;
1703 } else |_| {}
1704 return buf[0..n];
1705 }
test/e2e.sh
Old New
@@ -5616,6 +5616,48 @@ for _s in a b; do
5616 echo "e2e FAIL: zoom skip: session $_s did not keep the size its promote claimed:" 5616 echo "e2e FAIL: zoom skip: session $_s did not keep the size its promote claimed:"
5617 cat "$OUT.zsst$_s"; exit 1; } 5617 cat "$OUT.zsst$_s"; exit 1; }
5618 done 5618 done
5619
5620 # The terminal handover, decoded out of the same capture. Three zoom moves
5621 # happened above (in, across, back) plus an unzoom, and each one hands the
5622 # terminal from one session to another. Which order the two halves land in
5623 # is not cosmetic: if the incoming tile's CLAIM lands before the outgoing
5624 # tile's RELEASE, the release unsets the modes the claim just set and the
5625 # zoomed session spends the rest of its turn with mouse reporting off — the
5626 # pre-3b bug, and the wheel silently stops working.
5627 #
5628 # It was a real race and not a theoretical one. With the release left to the
5629 # demoted tile's PUMP (two threads, no ordering between them), this decode
5630 # read CCRRR — claim-before-release — in four runs out of six on this
5631 # machine; it is benign only when the incoming level-set happens to land
5632 # afterwards and repair it, and a pump stalled in a dial backoff makes the
5633 # window seconds wide. The fix is that the thread MOVING the zoom writes the
5634 # release itself, under `paint_mu`, before the store the next pump reads
5635 # (wallview `setZoom`), so the order is structural rather than lucky.
5636 #
5637 # Both needles are exact byte strings that nothing else emits. The claim is
5638 # the only place those two escapes are adjacent — the level-set that follows
5639 # it spells the same modes with three others in between — and the release is
5640 # a nine-escape run written whole.
5641 _ZS_CLAIM=$(printf '\033[?1000h\033[?1006h')
5642 _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1016l')
5643 # Byte offsets, merged and sorted, then read as a word. `grep -oba` is what
5644 # makes this possible at all: the capture is binary, these are escape
5645 # sequences with no newlines, and a line-oriented grep would see one line.
5646 _zs_order=$( { grep -obaF -- "$_ZS_CLAIM" "$OUT.zscap" | sed 's/:.*/ C/' || true; \
5647 grep -obaF -- "$_ZS_REL" "$OUT.zscap" | sed 's/:.*/ R/' || true; } \
5648 | sort -n | awk '{printf "%s", $2}' )
5649 # CRCRCR: three handovers, each release before the next claim. The trailing
5650 # R the wall's own exit writes is not asserted here — the no-leaked-mode leg
5651 # owns that one.
5652 case "$_zs_order" in
5653 CRCRCR*) ;;
5654 *)
5655 echo "e2e FAIL: zoom skip: the terminal handover was $_zs_order, not CRCRCR —"
5656 echo " an incoming tile claimed the terminal before the outgoing one"
5657 echo " released it, so the release unset the modes the claim had just"
5658 echo " set and the zoomed session lost its mouse:"
5659 cat "$OUT.zspc"; exit 1 ;;
5660 esac
5619 assert_stopped "$SOCK37" "$D34PID" "zoom skip" "$OUT.zsstop" 5661 assert_stopped "$SOCK37" "$D34PID" "zoom skip" "$OUT.zsstop"
5620 D34PID="" 5662 D34PID=""
5621 ok "the zoom skips between tiles with Ctrl-\\ n / Ctrl-\\ l, and the daemon sees no new attach" 5663 ok "the zoom skips between tiles with Ctrl-\\ n / Ctrl-\\ l, and the daemon sees no new attach"