c7c055bf
feat: a wall tile is an interact.Core, so the wheel works zoomed
a73x 2026-08-20 04:18
Commit message
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" |