8bad3fee
refactor: comments say what the code is for, not how it got here
a73x 2026-08-30 19:12
Commit message
docscheck.blocks
| Old | New | ||
|---|---|---|---|
| @@ -40,14 +40,14 @@ server_test_upgrade.zig 1 | |||
| 40 | server.zig 108 | 40 | server.zig 108 |
| 41 | shellint.zig 7 | 41 | shellint.zig 7 |
| 42 | sockpath.zig 5 | 42 | sockpath.zig 5 |
| 43 | spawn.zig 2 | 43 | spawn.zig 0 |
| 44 | term.zig 0 | 44 | term.zig 0 |
| 45 | testtmp.zig 1 | 45 | testtmp.zig 1 |
| 46 | upgrade.zig 2 | 46 | upgrade.zig 2 |
| 47 | wall_host.zig 7 | 47 | wall_host.zig 7 |
| 48 | wall_layout.zig 2 | 48 | wall_layout.zig 2 |
| 49 | wall_picker.zig 6 | 49 | wall_picker.zig 6 |
| 50 | wall_pump.zig 45 | 50 | wall_pump.zig 0 |
| 51 | wall_test_harness.zig 1 | 51 | wall_test_harness.zig 1 |
| 52 | wall_test_host.zig 0 | 52 | wall_test_host.zig 0 |
| 53 | wall_test_layout.zig 2 | 53 | wall_test_layout.zig 2 |
src/cli/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,41 +1,26 @@ | |||
| 1 | //! This image, as a path something can exec: `/proc/self/exe` read THROUGH | 1 | //! The file to exec when mux starts another copy of itself — a daemon. |
| 2 | //! to the file it names. Every production start hands execve this and never | 2 | //! `/proc/self/exe` read through to the real path, so a start runs THIS |
| 3 | //! a name resolved against PATH — that is the end of the ambient-PATH trap, | 3 | //! binary and never a `mux` that PATH happens to find. Under src/cli/ |
| 4 | //! where an `execvp("muxd")` grades whatever release is installed and did | 4 | //! because asking the OS about the running process is not a client's question. |
| 5 | //! (an e2e leg whose daemon had died ran a v0.0.1-10 with no agent code in | ||
| 6 | //! it and passed). | ||
| 7 | //! | ||
| 8 | //! Under src/cli/ because it asks the OS about the process it is in, which | ||
| 9 | //! is a question a client module may not ask. | ||
| 10 | const std = @import("std"); | 5 | const std = @import("std"); |
| 11 | 6 | ||
| 12 | /// The kernel's link to the running image. Only the fallback: a spawn is | 7 | /// The kernel's link to the running image. The fallback only: a start is |
| 13 | /// worth more than the name it will wear, so a readlink that fails still | 8 | /// worth more than the name it will wear. |
| 14 | /// starts a daemon. | ||
| 15 | pub const self_exe = "/proc/self/exe"; | 9 | pub const self_exe = "/proc/self/exe"; |
| 16 | 10 | ||
| 17 | /// The file every production start execs: this image, resolved through | 11 | /// The file every production start execs: this image, resolved through |
| 18 | /// the /proc link to the path it names. | 12 | /// the /proc link to the path it names. |
| 19 | pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { | 13 | pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { |
| 20 | // Resolved, and that is the point. The kernel takes a process's `comm` | 14 | // Resolved, not the link: `comm` is the basename of the filename handed |
| 21 | // from the basename of the FILENAME handed to execve, so exec'ing the | 15 | // to execve, so exec'ing the link names every daemon `exe` in ps and pgrep. |
| 22 | // link itself leaves every daemon called `exe` — nothing `pgrep mux`, | ||
| 23 | // `killall mux`, `ps -o comm` or systemd's MainPID name can find — | ||
| 24 | // while only the args still read `mux d start`. The e2e that reads a | ||
| 25 | // spawned daemon's `/proc/PID/comm` is what says so. | ||
| 26 | return execOrLink(std.fs.selfExePath(buf) catch return self_exe); | 16 | return execOrLink(std.fs.selfExePath(buf) catch return self_exe); |
| 27 | } | 17 | } |
| 28 | 18 | ||
| 29 | /// The resolved path if it can still be exec'd, the /proc link if it cannot. | 19 | /// The resolved path if it can still be exec'd, the /proc link if it cannot. |
| 30 | /// Split from `selfExe` so the fallback is assertable without deleting a | 20 | /// Split out so the fallback is assertable without deleting a live binary. |
| 31 | /// binary out from under a running process. | ||
| 32 | fn execOrLink(resolved: []const u8) []const u8 { | 21 | fn execOrLink(resolved: []const u8) []const u8 { |
| 33 | // A readlink that SUCCEEDS can still name nothing: `make install` | 22 | // A readlink that SUCCEEDS can still name nothing: `make install` leaves |
| 34 | // replaces the file under a running wall and the kernel then answers | 23 | // `/…/mux (deleted)`, a description. The start outweighs the name. |
| 35 | // `/…/mux (deleted)`, which is a description and not a path. The link | ||
| 36 | // still opens the old inode, so the daemon starts wearing `exe` — the | ||
| 37 | // name is worth less than the start, and a refusal here would be an | ||
| 38 | // auto-start that fails for the length of one install. | ||
| 39 | std.posix.access(resolved, std.posix.X_OK) catch return self_exe; | 24 | std.posix.access(resolved, std.posix.X_OK) catch return self_exe; |
| 40 | return resolved; | 25 | return resolved; |
| 41 | } | 26 | } |
| @@ -45,17 +30,14 @@ fn execOrLink(resolved: []const u8) []const u8 { | |||
| 45 | test "selfExe: the exec'd name is a real file, not the /proc link" { | 30 | test "selfExe: the exec'd name is a real file, not the /proc link" { |
| 46 | var buf: [std.fs.max_path_bytes]u8 = undefined; | 31 | var buf: [std.fs.max_path_bytes]u8 = undefined; |
| 47 | const exe = selfExe(&buf); | 32 | const exe = selfExe(&buf); |
| 48 | // Handing execve the link itself is what named every spawned daemon | 33 | // comm is the basename of the filename exec'd, so this resolution IS the |
| 49 | // `exe`: comm is the basename of the filename exec'd, so the resolution | 34 | // name the daemon wears in ps, pgrep and killall. |
| 50 | // IS the name the daemon wears in ps, pgrep and killall. | ||
| 51 | try std.testing.expect(!std.mem.eql(u8, exe, self_exe)); | 35 | try std.testing.expect(!std.mem.eql(u8, exe, self_exe)); |
| 52 | try std.posix.access(exe, std.posix.X_OK); | 36 | try std.posix.access(exe, std.posix.X_OK); |
| 53 | } | 37 | } |
| 54 | 38 | ||
| 55 | test "selfExe: a resolved path that is no longer a file falls back to the link" { | 39 | test "selfExe: a resolved path that is no longer a file falls back to the link" { |
| 56 | // What `make install` does to a running wall: the readlink SUCCEEDS and | 40 | // What `make install` does to a running wall. Spelled as the suffix the |
| 57 | // answers `/…/mux (deleted)`, so the only thing that can tell is asking | ||
| 58 | // whether the answer is still executable. Spelled as the suffix the | ||
| 59 | // kernel actually appends, because that is the string this must survive. | 41 | // kernel actually appends, because that is the string this must survive. |
| 60 | var buf: [std.fs.max_path_bytes]u8 = undefined; | 42 | var buf: [std.fs.max_path_bytes]u8 = undefined; |
| 61 | const live = try std.fs.selfExePath(&buf); | 43 | const live = try std.fs.selfExePath(&buf); |
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,7 @@ | |||
| 1 | //! One tile's thread: the transport it owns in BOTH directions, its | 1 | //! One tile's thread: its transport in BOTH directions, its attach, claims, |
| 2 | //! attach, its claims and releases, its agent channels, its redials, and | 2 | //! releases, agent channels, redials, and the paint hooks the interaction |
| 3 | //! the paint hooks the interaction core calls back on. The keyboard hands | 3 | //! core calls back on. The keyboard hands it work through the tile's mailbox |
| 4 | //! this thread work through the tile's mailbox and doorbell and reads its | 4 | //! and doorbell. Nothing here touches another tile. |
| 5 | //! answers back the same way; nothing here touches another tile. | ||
| 6 | const std = @import("std"); | 5 | const std = @import("std"); |
| 7 | const proto = @import("term").protocol; | 6 | const proto = @import("term").protocol; |
| 8 | const client = @import("client"); | 7 | const client = @import("client"); |
| @@ -26,23 +25,17 @@ pub fn copySelection( | |||
| 26 | { | 25 | { |
| 27 | t.shared.paint_mu.lock(); | 26 | t.shared.paint_mu.lock(); |
| 28 | defer t.shared.paint_mu.unlock(); | 27 | defer t.shared.paint_mu.unlock(); |
| 29 | // The drag is this tile's own Core's now: a drag is per tile, and | ||
| 30 | // the pump that owns the link is the one whose Core holds it. | ||
| 31 | const held = core.drag.range(); | 28 | const held = core.drag.range(); |
| 32 | answer = core.selectionCopy(payload, held); | 29 | answer = core.selectionCopy(payload, held); |
| 33 | switch (answer) { | 30 | switch (answer) { |
| 34 | // `is_tty` and not the tile's claim: a tile that has released | 31 | // `is_tty`, not the claim: a tile that released the terminal can |
| 35 | // the terminal can still be the one whose finished drag the | 32 | // still own the finished drag, and the copy is still the user's. |
| 36 | // answer reaches, and the copy is still the user's. A piped | ||
| 37 | // `mux` asks its terminal for no mouse modes, so it can have no | ||
| 38 | // drag to copy in the first place. | ||
| 39 | .text => |text| if (t.shared.is_tty) | 33 | .text => |text| if (t.shared.is_tty) |
| 40 | interact.writeSelectionCopy(alloc, t.shared.out_fd, text) catch {}, | 34 | interact.writeSelectionCopy(alloc, t.shared.out_fd, text) catch {}, |
| 41 | .none, .too_large => {}, | 35 | .none, .too_large => {}, |
| 42 | } | 36 | } |
| 43 | } | 37 | } |
| 44 | // Outside the hold: `tileBanner` takes the same lock, which is not | 38 | // Outside the hold: `tileBanner` takes the same non-reentrant lock. |
| 45 | // reentrant. The banner lands in this tile's own rect. | ||
| 46 | if (answer == .too_large) wv.tileBanner(t, "[selection too large to copy]"); | 39 | if (answer == .too_large) wv.tileBanner(t, "[selection too large to copy]"); |
| 47 | } | 40 | } |
| 48 | 41 | ||
| @@ -52,22 +45,14 @@ pub fn tilePaintBegin(ctx: ?*anyopaque) bool { | |||
| 52 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | 45 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); |
| 53 | if (t.gone.load(.acquire)) return false; | 46 | if (t.gone.load(.acquire)) return false; |
| 54 | t.shared.paint_mu.lock(); | 47 | t.shared.paint_mu.lock(); |
| 55 | // Read UNDER the lock, not before it: a pump that tested the flag and | 48 | // Under the lock, not before: a pump that loses the race paints over the |
| 56 | // then lost the race for `paint_mu` would paint its rect on top of the | 49 | // popup, and `picker_stamp` suppresses the repaint that would repair it. |
| 57 | // box the keyboard had just drawn — and `picker_stamp` suppresses the | ||
| 58 | // identical repaint that would have repaired it, so the damage sticks | ||
| 59 | // until a key changes the frame. | ||
| 60 | if (wv.popupOpen(t.shared)) { | 50 | if (wv.popupOpen(t.shared)) { |
| 61 | t.shared.paint_mu.unlock(); | 51 | t.shared.paint_mu.unlock(); |
| 62 | return false; | 52 | return false; |
| 63 | } | 53 | } |
| 64 | // A pass a relayout has since superseded paints at a rect the screen | 54 | // A pass a relayout superseded paints at a rect the screen no longer |
| 65 | // no longer has. The relayout cleared the screen and moved this tile's | 55 | // has, across a neighbour's bar that nothing repaints after. |
| 66 | // neighbours' bars under the OLD offsets, and the pump re-takes a | ||
| 67 | // pass only at the top of its loop — so a paint from this pass would | ||
| 68 | // land a session's rows across a neighbour's bar, and nothing repaints | ||
| 69 | // that bar afterwards. The relayout already rang this pump; its next | ||
| 70 | // pass carries the new rect and the generation that repaints. | ||
| 71 | if (t.shared.repaint_gen.load(.acquire) != t.pass_gen) { | 56 | if (t.shared.repaint_gen.load(.acquire) != t.pass_gen) { |
| 72 | t.shared.paint_mu.unlock(); | 57 | t.shared.paint_mu.unlock(); |
| 73 | return false; | 58 | return false; |
| @@ -78,13 +63,8 @@ pub fn tilePaintBegin(ctx: ?*anyopaque) bool { | |||
| 78 | pub fn tilePaintEnd(ctx: ?*anyopaque) void { | 63 | pub fn tilePaintEnd(ctx: ?*anyopaque) void { |
| 79 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | 64 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); |
| 80 | defer t.shared.paint_mu.unlock(); | 65 | defer t.shared.paint_mu.unlock(); |
| 81 | // Before releasing the terminal: the cursor belongs to the FOCUSED tile. | 66 | // The cursor belongs to the FOCUSED tile: an unfocused paint's last act |
| 82 | // A focused paint records where its cursor landed; an unfocused paint's | 67 | // puts it back, hidden for the move so the show does not flash it there. |
| 83 | // last act is to put the cursor back there, so a redraw in tile 2 cannot | ||
| 84 | // steal the eye while the keys go to tile 1. The cursor is hidden for | ||
| 85 | // the move so the show after it does not flash it at the unfocused | ||
| 86 | // tile's final position before the CUP lands — then re-shown so the | ||
| 87 | // focused tile's cursor rests visible until its next paint. | ||
| 88 | if (t.idx == t.shared.sel) { | 68 | if (t.idx == t.shared.sel) { |
| 89 | if (t.core) |core| t.shared.cursor = core.screenCursor(); | 69 | if (t.core) |core| t.shared.cursor = core.screenCursor(); |
| 90 | } else { | 70 | } else { |
| @@ -95,12 +75,9 @@ pub fn tilePaintEnd(ctx: ?*anyopaque) void { | |||
| 95 | } | 75 | } |
| 96 | 76 | ||
| 97 | /// The one place a wall tile puts an attach on the wire. A tile the user | 77 | /// The one place a wall tile puts an attach on the wire. A tile the user |
| 98 | /// asked for — the entry tile, a chord-born tile, a local line off the | 78 | /// asked for claims its rect, and that size is what lets the daemon create |
| 99 | /// saved wall — claims its rect on attach, and that size is what lets the | 79 | /// the session; a view tile attaches at 0x0 so it can only JOIN, then takes |
| 100 | /// daemon create the session. A view tile (wall argv, a remote saved | 80 | /// its rect with the resize doorbell one frame later. |
| 101 | /// line) attaches at 0x0 so it can only JOIN, then takes its rect with the | ||
| 102 | /// resize doorbell one frame later — every tile still claims its | ||
| 103 | /// rectangle, and a redial comes back claiming what the tile claimed. | ||
| 104 | pub fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void { | 81 | pub fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void { |
| 105 | // Snapshot under `paint_mu`: the keyboard may relayout (re-cut stripes, | 82 | // Snapshot under `paint_mu`: the keyboard may relayout (re-cut stripes, |
| 106 | // resize) concurrently with the pump's first attach. | 83 | // resize) concurrently with the pump's first attach. |
| @@ -123,18 +100,16 @@ pub fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u6 | |||
| 123 | have_epoch, | 100 | have_epoch, |
| 124 | proto.wireName(t.r.session), | 101 | proto.wireName(t.r.session), |
| 125 | )); | 102 | )); |
| 126 | // A view tile made no size claim, so it owes its rect now: the same | 103 | // A view tile made no size claim, so it owes its rect now, on the same |
| 127 | // doorbell path a relayout takes (adoptSize + .resize), run on this | 104 | // doorbell path a relayout takes. |
| 128 | // pump thread which is the transport's only writer. | ||
| 129 | if (!t.creates) { | 105 | if (!t.creates) { |
| 130 | t.shared.paint_mu.lock(); | 106 | t.shared.paint_mu.lock(); |
| 131 | t.resize_pending = true; | 107 | t.resize_pending = true; |
| 132 | t.shared.paint_mu.unlock(); | 108 | t.shared.paint_mu.unlock(); |
| 133 | wv.ring(t); | 109 | wv.ring(t); |
| 134 | } | 110 | } |
| 135 | // Re-armed on EVERY attach, not once per process: a redial is a fresh | 111 | // Re-armed on EVERY attach: a redial lands on a fresh daemon-side slot, |
| 136 | // attach onto a fresh daemon-side slot, which remembers no offer. Empty | 112 | // which remembers no offer. |
| 137 | // payload, and a daemon too old to know the frame skips it. | ||
| 138 | if (t.r.agent) try tr.writeFrame(.agent_offer, ""); | 113 | if (t.r.agent) try tr.writeFrame(.agent_offer, ""); |
| 139 | } | 114 | } |
| 140 | 115 | ||
| @@ -150,29 +125,19 @@ pub const ClaimStep = enum { | |||
| 150 | 125 | ||
| 151 | /// May this tile take the terminal for the arm it is holding? | 126 | /// May this tile take the terminal for the arm it is holding? |
| 152 | fn claimAllowed(t: *Tile) bool { | 127 | fn claimAllowed(t: *Tile) bool { |
| 153 | // BEFORE the claim, never after. The keyboard arms `claim_pending` and | 128 | // BEFORE the claim, never after: an arm that outlives its focus and then |
| 154 | // the pump reads it a pass later, and the focus can move in between — | 129 | // succeeds puts two tiles' modes on one terminal, and nothing undoes it. |
| 155 | // a poller's tile arriving, the birth an Enter makes, all of it under | ||
| 156 | // the host picker's popup. An arm that outlives its focus and then | ||
| 157 | // SUCCEEDS puts two tiles' modes on one terminal, rests the cursor on | ||
| 158 | // the loser, and takes the focus notice with it; the outgoing tile's | ||
| 159 | // release was consumed a pass earlier, so nothing undoes any of it. | ||
| 160 | // | ||
| 161 | // Judging the claim's REFUSAL cannot cover this: the arm that matters | ||
| 162 | // is the one retried after the popup closed, which the sink admits. | ||
| 163 | t.shared.paint_mu.lock(); | 130 | t.shared.paint_mu.lock(); |
| 164 | defer t.shared.paint_mu.unlock(); | 131 | defer t.shared.paint_mu.unlock(); |
| 165 | return t.shared.sel == t.idx; | 132 | return t.shared.sel == t.idx; |
| 166 | } | 133 | } |
| 167 | 134 | ||
| 168 | /// The wall's ONLY door to `Core.claimTerminal`: the focus test, the size | 135 | /// The wall's ONLY door to `Core.claimTerminal`: focus test, size adopt, |
| 169 | /// adopt, and the claim, in that order and never apart. A caller that could | 136 | /// claim, in that order and never apart. Around it is a stale claim. |
| 170 | /// reach the claim around this is a caller that can mint a stale one. | ||
| 171 | pub fn claimFocus(t: *Tile, core: *interact.Core, rect: proto.Size) ClaimStep { | 137 | pub fn claimFocus(t: *Tile, core: *interact.Core, rect: proto.Size) ClaimStep { |
| 172 | if (!claimAllowed(t)) return .dropped; | 138 | if (!claimAllowed(t)) return .dropped; |
| 173 | // A tile born before somebody resized the terminal clips its paints to | 139 | // The tile's current RECT, never the whole terminal: a wall of two would |
| 174 | // a screen that is gone; the tile's current RECT, never the whole | 140 | // otherwise let this tile paint over its neighbour. |
| 175 | // terminal, which on a wall of two would let it paint over a neighbour. | ||
| 176 | if (core.size.cols != rect.cols or core.size.rows != rect.rows) | 141 | if (core.size.cols != rect.cols or core.size.rows != rect.rows) |
| 177 | core.adoptSize(rect); | 142 | core.adoptSize(rect); |
| 178 | return afterClaim(t, core.claimTerminal(), core.claim != .none, core.is_tty); | 143 | return afterClaim(t, core.claimTerminal(), core.claim != .none, core.is_tty); |
| @@ -180,23 +145,13 @@ pub fn claimFocus(t: *Tile, core: *interact.Core, rect: proto.Size) ClaimStep { | |||
| 180 | 145 | ||
| 181 | /// A refused focus claim, judged. | 146 | /// A refused focus claim, judged. |
| 182 | pub fn afterClaim(t: *Tile, claimed: bool, held: bool, is_tty: bool) ClaimStep { | 147 | pub fn afterClaim(t: *Tile, claimed: bool, held: bool, is_tty: bool) ClaimStep { |
| 183 | // `held` is `Core.claim != .none`. A claim answers false for three | 148 | // A claim answers false for three reasons and only the SINK's refusal is |
| 184 | // reasons and only ONE of them is worth another pass: not a tty (there | 149 | // worth another pass. Read off the Core: `picker_open` can clear between. |
| 185 | // is no terminal to hold), already held (re-arming would re-take the | ||
| 186 | // focus notice every pass, forever), and the SINK refused — which is | ||
| 187 | // the host picker, whose popup admits no paint and a claim writes the | ||
| 188 | // session's modes through one. Read off the Core, never off | ||
| 189 | // `picker_open` a second time: the keyboard can clear that flag between | ||
| 190 | // the two loads, and then the refusal is dropped exactly as before. | ||
| 191 | if (claimed or held or !is_tty) return .done; | 150 | if (claimed or held or !is_tty) return .done; |
| 192 | t.shared.paint_mu.lock(); | 151 | t.shared.paint_mu.lock(); |
| 193 | defer t.shared.paint_mu.unlock(); | 152 | defer t.shared.paint_mu.unlock(); |
| 194 | // ...and only while this tile is STILL the focus. `claimAllowed` asked | 153 | // ...and only while this tile is STILL the focus, or re-arming mints the |
| 195 | // the same question before the claim; the keyboard can answer it | 154 | // stale arm `claimAllowed` exists to stop. |
| 196 | // differently in between, and re-arming then would mint the very stale | ||
| 197 | // arm that check exists to stop. Left CLEAR rather than cleared — the | ||
| 198 | // caller's `swap` did that — because a focus that came back inside this | ||
| 199 | // window re-armed it legitimately. | ||
| 200 | if (t.shared.sel != t.idx) return .dropped; | 155 | if (t.shared.sel != t.idx) return .dropped; |
| 201 | t.claim_pending.store(true, .release); | 156 | t.claim_pending.store(true, .release); |
| 202 | return .rearmed; | 157 | return .rearmed; |
| @@ -212,7 +167,6 @@ fn publishStats(shared: *Shared, c: interact.PredictCounters) void { | |||
| 212 | 167 | ||
| 213 | /// Validated first: these bytes came out of a peer's `sessions_reply` and | 168 | /// Validated first: these bytes came out of a peer's `sessions_reply` and |
| 214 | /// `SessionName.of` memcpys with no bound of its own (`client.validPick`). | 169 | /// `SessionName.of` memcpys with no bound of its own (`client.validPick`). |
| 215 | /// A name it refuses is a name nobody is moved to. | ||
| 216 | fn postAnswer(t: *Tile, pick: []const u8) void { | 170 | fn postAnswer(t: *Tile, pick: []const u8) void { |
| 217 | const name = client.validPick(pick) orelse return; | 171 | const name = client.validPick(pick) orelse return; |
| 218 | { | 172 | { |
| @@ -231,9 +185,8 @@ fn endWith(t: *Tile, reason: EndReason, code: u8) void { | |||
| 231 | t.end.store(@intFromEnum(reason), .release); | 185 | t.end.store(@intFromEnum(reason), .release); |
| 232 | } | 186 | } |
| 233 | 187 | ||
| 234 | /// Whole-mailbox chunking, so `offerKeystroke` (one-byte chunks only) counts | 188 | /// Whole-mailbox chunking, so `offerKeystroke` counts keys arriving between |
| 235 | /// keystrokes that arrive between polls as suppressed: a wall predicts a | 189 | /// polls as suppressed. Splitting would speculate against a stale replica. |
| 236 | /// little less. Splitting would speculate against a stale replica. | ||
| 237 | pub fn takeKeys(t: *Tile, out: []u8) []u8 { | 190 | pub fn takeKeys(t: *Tile, out: []u8) []u8 { |
| 238 | t.in_mu.lock(); | 191 | t.in_mu.lock(); |
| 239 | defer t.in_mu.unlock(); | 192 | defer t.in_mu.unlock(); |
| @@ -250,10 +203,8 @@ fn drainWake(t: *const Tile) void { | |||
| 250 | 203 | ||
| 251 | /// Every wall dial's ssh sends its prompts to this client's popup. | 204 | /// Every wall dial's ssh sends its prompts to this client's popup. |
| 252 | pub fn askOn(target: client.Target, shared: *const Shared) client.Target { | 205 | pub fn askOn(target: client.Target, shared: *const Shared) client.Target { |
| 253 | // ONE place, so the two exclusions hold by construction rather than by | 206 | // ONE place, so the two exclusions hold by construction: the entry attach |
| 254 | // a call somebody has to remember: the entry attach opens on the main | 207 | // opens before any listener exists, and a poller spells `BatchMode`. |
| 255 | // thread before any listener exists, and a poller never comes through | ||
| 256 | // here at all — it spells `BatchMode` and asks nothing. | ||
| 257 | var out = target; | 208 | var out = target; |
| 258 | if (out != .hand) return out; | 209 | if (out != .hand) return out; |
| 259 | const l = shared.prompts orelse return out; | 210 | const l = shared.prompts orelse return out; |
| @@ -265,37 +216,26 @@ pub fn askOn(target: client.Target, shared: *const Shared) client.Target { | |||
| 265 | fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { | 216 | fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { |
| 266 | var target = askOn(target_in, t.shared); | 217 | var target = askOn(target_in, t.shared); |
| 267 | var backoff_ms: u64 = 0; | 218 | var backoff_ms: u64 = 0; |
| 268 | // `gone` as well as `running`: a tile forgotten while it is retrying a | 219 | // `gone` as well as `running`: a tile forgotten while retrying a dead |
| 269 | // dead host must stop retrying, not keep a thread and a backoff alive | 220 | // host must not keep a thread and a backoff alive. |
| 270 | // for a tile that is no longer on the wall. | ||
| 271 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | 221 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { |
| 272 | // The reason is not kept — a tile that cannot dial says | 222 | // The reason is not kept — the picker row polls the same host and |
| 273 | // `connecting`, and the sentence ssh gave belongs to the picker | 223 | // carries it. The PID is: only it separates a refusal from a dead box. |
| 274 | // row, which polls the same host on its own interval — ten seconds | ||
| 275 | // for exactly this host, since a failed `.hand` poll answers | ||
| 276 | // `.pipe` and `pollDelayMs` stretches those. The PID is: it is the | ||
| 277 | // only thing that tells a dial the user refused from a box that is | ||
| 278 | // merely down. | ||
| 279 | var d: handoff.Dial = .{}; | 224 | var d: handoff.Dial = .{}; |
| 280 | if (client.Transport.open(alloc, target, null, -1, &d)) |tr| return tr else |_| {} | 225 | if (client.Transport.open(alloc, target, null, -1, &d)) |tr| return tr else |_| {} |
| 281 | // A refused prompt is an answer, and retrying is arguing with it: | 226 | // A refused prompt is an answer, and retrying is arguing with it: |
| 282 | // without this the user who pressed Esc is asked again every two | 227 | // without this, Esc is answered with the same prompt every two seconds. |
| 283 | // seconds for as long as the wall is up. Another go is a birth | ||
| 284 | // from the picker, which is a thing they can choose to do. | ||
| 285 | if (t.shared.prompts) |l| { | 228 | if (t.shared.prompts) |l| { |
| 286 | if (l.declined(d.ssh_pid)) { | 229 | if (l.declined(d.ssh_pid)) { |
| 287 | // The bar says it, before the pump goes: an unfocused tile | 230 | // The bar says it before the pump goes: an unfocused tile that |
| 288 | // that ends without exiting narrates on its own bar and | 231 | // ends without exiting narrates itself (`wallview.endedTile`). |
| 289 | // asks the keyboard for nothing (`wallview.endedTile`). | ||
| 290 | wv.paintLabel(t, .declined); | 232 | wv.paintLabel(t, .declined); |
| 291 | endWith(t, .declined, 1); | 233 | endWith(t, .declined, 1); |
| 292 | return null; | 234 | return null; |
| 293 | } | 235 | } |
| 294 | } | 236 | } |
| 295 | // An ask buys ONE attempt. Every retry below is the wall's own | 237 | // An ask buys ONE attempt: the asking word per backoff would restart a |
| 296 | // idea: the asking word per backoff would restart a daemon for as | 238 | // daemon for as long as the tile lives. |
| 297 | // long as the tile lives, and a fallback line per backoff would | ||
| 298 | // scroll the alternate screen the tiles are painted on. | ||
| 299 | if (target == .hand) target.hand.asked = false; | 239 | if (target == .hand) target.hand.asked = false; |
| 300 | backoff_ms = client.nextBackoffMs(backoff_ms); | 240 | backoff_ms = client.nextBackoffMs(backoff_ms); |
| 301 | // Sliced sleep so quit is never behind a full backoff. | 241 | // Sliced sleep so quit is never behind a full backoff. |
| @@ -310,11 +250,8 @@ fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Tr | |||
| 310 | } | 250 | } |
| 311 | 251 | ||
| 312 | /// One forwarded ssh-agent channel, this end of it: the id the daemon | 252 | /// One forwarded ssh-agent channel, this end of it: the id the daemon |
| 313 | /// allocated, and an fd to THIS machine's agent. | 253 | /// allocated, and an fd to THIS machine's agent. Thread-local by |
| 314 | /// | 254 | /// construction — the table lives in `pumpTile`'s frame, so nothing locks. |
| 315 | /// Thread-local by construction — the table lives in `pumpTile`'s frame and | ||
| 316 | /// no other thread can see it, which is why nothing here takes a lock and | ||
| 317 | /// why these helpers take the table as a slice rather than reaching for one. | ||
| 318 | pub const AgentLocal = struct { id: u32, fd: std.posix.fd_t }; | 255 | pub const AgentLocal = struct { id: u32, fd: std.posix.fd_t }; |
| 319 | 256 | ||
| 320 | /// Fixed at `proto.agent_chans_max`, which is what the daemon opens anyway: | 257 | /// Fixed at `proto.agent_chans_max`, which is what the daemon opens anyway: |
| @@ -335,10 +272,8 @@ pub fn findLocal(locals: []?AgentLocal, id: u32) ?usize { | |||
| 335 | return null; | 272 | return null; |
| 336 | } | 273 | } |
| 337 | 274 | ||
| 338 | /// Hang one channel up from this end and say so, because the daemon is | 275 | /// Hang one channel up from this end and say so: the daemon is holding the |
| 339 | /// holding the far socket open waiting for bytes that are not coming. A | 276 | /// far socket open for bytes that are not coming. |
| 340 | /// failed write is the transport itself being gone, which the caller's next | ||
| 341 | /// pass turns into a redial. | ||
| 342 | pub fn closeLocal(locals: []?AgentLocal, slot: usize, transport: *client.Transport) void { | 277 | pub fn closeLocal(locals: []?AgentLocal, slot: usize, transport: *client.Transport) void { |
| 343 | const ch = locals[slot] orelse return; | 278 | const ch = locals[slot] orelse return; |
| 344 | locals[slot] = null; | 279 | locals[slot] = null; |
| @@ -353,13 +288,11 @@ pub fn openAgentChan( | |||
| 353 | offered: bool, | 288 | offered: bool, |
| 354 | sock: []const u8, | 289 | sock: []const u8, |
| 355 | ) bool { | 290 | ) bool { |
| 356 | // The OFFER is the consent, and it is per TILE: a wall where one tile | 291 | // The OFFER is the consent, and it is per TILE: one tile typed with `-A` |
| 357 | // was typed with `-A` must not hand another tile's host the keys, | 292 | // must not hand another tile's host the keys. |
| 358 | // whoever asks. | ||
| 359 | if (!offered) return false; | 293 | if (!offered) return false; |
| 360 | // A live id reused. Refusing keeps the channel already on that id | 294 | // A live id reused. Refusing keeps the channel already on that id, which |
| 361 | // intact, which is the half of the collision that has real bytes moving | 295 | // is the half of the collision with real bytes moving through it. |
| 362 | // through it. | ||
| 363 | if (findLocal(locals, id) != null) return false; | 296 | if (findLocal(locals, id) != null) return false; |
| 364 | const fd = client.connectAgent(sock) orelse return false; | 297 | const fd = client.connectAgent(sock) orelse return false; |
| 365 | if (storeLocal(locals, id, fd) == null) { | 298 | if (storeLocal(locals, id, fd) == null) { |
| @@ -381,10 +314,8 @@ pub fn deliverAgentData( | |||
| 381 | closeLocal(locals, s, transport); | 314 | closeLocal(locals, s, transport); |
| 382 | return true; | 315 | return true; |
| 383 | } | 316 | } |
| 384 | // Bytes onto the fd in order, never parsed and never reassembled: the | 317 | // Bytes onto the fd in order, never parsed: one agent message can arrive |
| 385 | // daemon reads the far end in `agent_data_max` bites, so one agent | 318 | // as several frames and several messages as one; the agent self-delimits. |
| 386 | // message can arrive as several frames and several messages as one. The | ||
| 387 | // agent protocol delimits itself over a stream, and this end is a pipe. | ||
| 388 | proto.writeAllFd(locals[s].?.fd, payload[proto.agent_id_len..]) catch | 319 | proto.writeAllFd(locals[s].?.fd, payload[proto.agent_id_len..]) catch |
| 389 | closeLocal(locals, s, transport); | 320 | closeLocal(locals, s, transport); |
| 390 | return true; | 321 | return true; |
| @@ -402,9 +333,6 @@ pub fn dropLocals(locals: []?AgentLocal) void { | |||
| 402 | /// The transport died, or the dial has to be redone: rebuild it on the CLI's | 333 | /// The transport died, or the dial has to be redone: rebuild it on the CLI's |
| 403 | /// backoff and re-attach quoting what this tile holds. False means the pump | 334 | /// backoff and re-attach quoting what this tile holds. False means the pump |
| 404 | /// is finished — the wall quit, or the tile was forgotten while retrying. | 335 | /// is finished — the wall quit, or the tile was forgotten while retrying. |
| 405 | /// | ||
| 406 | /// One function for what were four copies of five steps, which had begun to | ||
| 407 | /// differ: only some dropped a scroll view a resync was about to invalidate. | ||
| 408 | fn redial( | 336 | fn redial( |
| 409 | t: *Tile, | 337 | t: *Tile, |
| 410 | alloc: std.mem.Allocator, | 338 | alloc: std.mem.Allocator, |
| @@ -413,27 +341,17 @@ fn redial( | |||
| 413 | target: client.Target, | 341 | target: client.Target, |
| 414 | state: *State, | 342 | state: *State, |
| 415 | /// This tile's agent channels, which the dying connection owned. Dropped | 343 | /// This tile's agent channels, which the dying connection owned. Dropped |
| 416 | /// HERE, and here only, for the reason this function exists at all: | 344 | /// HERE and only here, so no call site can strand one. |
| 417 | /// every call site that had to remember would be one more chance to | ||
| 418 | /// strand a channel on a connection that cannot close it. | ||
| 419 | agents: []?AgentLocal, | 345 | agents: []?AgentLocal, |
| 420 | ) bool { | 346 | ) bool { |
| 421 | // A close that follows our own detach is the daemon saying goodbye back, | 347 | // A close after our own detach is the daemon saying goodbye, not a tear: |
| 422 | // not a tear to heal: the pump wrote the .detach frame and set | 348 | // redialing would re-attach the slot the user just released. |
| 423 | // `detach_ack` before the save's file I/O window let readFrame see the | ||
| 424 | // daemon's side. Redialing here would re-attach a slot the user just | ||
| 425 | // released. | ||
| 426 | if (t.detach_ack.load(.acquire)) return false; | 349 | if (t.detach_ack.load(.acquire)) return false; |
| 427 | // Before the cold-dial refusal below, which returns without reconnecting | 350 | // Before the cold-dial refusal below, which returns without reconnecting |
| 428 | // — a pump that ends still owes these fds. | 351 | // — a pump that ends still owes these fds. |
| 429 | dropLocals(agents); | 352 | dropLocals(agents); |
| 430 | // The plain client's rule, kept for the tile a `mux TARGET` is: a | 353 | // A transport that died before any state carried no session, so there is |
| 431 | // transport that died before a single frame of state carried no | 354 | // nothing to resume; wall tiles retry forever because a box reboots. |
| 432 | // session, so there is nothing to resume and retrying a bad host or a | ||
| 433 | // typo'd `--via` only makes an unkillable client. `session_epoch` is | ||
| 434 | // the right signal because it is set from the first snapshot and never | ||
| 435 | // reset. Wall tiles do the opposite deliberately — they retry forever, | ||
| 436 | // because a wall is a thing you leave up while a box reboots. | ||
| 437 | if (!t.retry_cold and core.rep.session_epoch == 0) { | 355 | if (!t.retry_cold and core.rep.session_epoch == 0) { |
| 438 | endWith(t, .lost, 1); | 356 | endWith(t, .lost, 1); |
| 439 | return false; | 357 | return false; |
| @@ -441,19 +359,15 @@ fn redial( | |||
| 441 | transport.close(); | 359 | transport.close(); |
| 442 | state.* = .reconnecting; | 360 | state.* = .reconnecting; |
| 443 | wv.paintLabel(t, state.*); | 361 | wv.paintLabel(t, state.*); |
| 444 | // ...and the same news for a one-tile wall, which has no label | 362 | // The one-tile wall has no label bar to read this off. `banner` is gated |
| 445 | // bar on screen to read it off. The corner banner is the plain client's | 363 | // on the sink, so a stripe's redial writes nothing here. |
| 446 | // own, said before the dial rather than inside it for its reason: it is | ||
| 447 | // a PAINT on the session's screen, and the Core is what paints. `banner` | ||
| 448 | // is gated on the sink, so a stripe's re-dial writes nothing here. | ||
| 449 | core.banner("[reconnecting]"); | 364 | core.banner("[reconnecting]"); |
| 450 | // The resync's own paint is what will arrive, so a history page held | 365 | // The resync's own paint is what will arrive, so a history page held |
| 451 | // here would be silently replaced a moment later. | 366 | // here would be silently replaced a moment later. |
| 452 | core.dropScrollView(); | 367 | core.dropScrollView(); |
| 453 | transport.* = dial(alloc, t, target) orelse return false; | 368 | transport.* = dial(alloc, t, target) orelse return false; |
| 454 | // Clears `state_since_attach` (so the next exit_status is read as a | 369 | // Clears `state_since_attach` and drops speculation made against a |
| 455 | // refusal again) and drops speculation made against a connection that | 370 | // connection that no longer exists. |
| 456 | // no longer exists — the Core's own highlight with it. | ||
| 457 | core.reattached(); | 371 | core.reattached(); |
| 458 | const have = core.rep.attachArgs(); | 372 | const have = core.rep.attachArgs(); |
| 459 | sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; | 373 | sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; |
| @@ -472,21 +386,16 @@ const Pass = struct { | |||
| 472 | // A relayout re-cut this tile: the pump owes the daemon THIS pass's | 386 | // A relayout re-cut this tile: the pump owes the daemon THIS pass's |
| 473 | // content size. | 387 | // content size. |
| 474 | resize: bool, | 388 | resize: bool, |
| 475 | // The repaint generation this pass was taken under: the pump's repaint | 389 | // The repaint generation this pass was taken under: a later read would |
| 476 | // decision compares THIS, not a later read, so a relayout between the | 390 | // let a relayout be consumed by a paint at this pass's stale rect. |
| 477 | // pass and the decision is repainted by the next pass rather than | ||
| 478 | // consumed by a paint at this pass's stale rect. | ||
| 479 | gen: u64, | 391 | gen: u64, |
| 480 | }; | 392 | }; |
| 481 | 393 | ||
| 482 | pub fn takePass(t: *Tile) Pass { | 394 | pub fn takePass(t: *Tile) Pass { |
| 483 | t.shared.paint_mu.lock(); | 395 | t.shared.paint_mu.lock(); |
| 484 | defer t.shared.paint_mu.unlock(); | 396 | defer t.shared.paint_mu.unlock(); |
| 485 | // The flag comes out of the SAME hold as the rect it describes. Read a | 397 | // The flag comes out of the SAME hold as the rect it describes: a |
| 486 | // pass apart from its doorbell and a relayout landing between the two | 398 | // relayout landing between two reads is swallowed, and nothing resends. |
| 487 | // is swallowed: the pump sends the rect it snapshotted first, clears | ||
| 488 | // the flag, and the daemon keeps a grid the tile has already stopped | ||
| 489 | // painting at — nothing re-sends, because the claim path does not. | ||
| 490 | const owed = t.resize_pending; | 399 | const owed = t.resize_pending; |
| 491 | t.resize_pending = false; | 400 | t.resize_pending = false; |
| 492 | const gen = t.shared.repaint_gen.load(.acquire); | 401 | const gen = t.shared.repaint_gen.load(.acquire); |
| @@ -504,33 +413,18 @@ pub fn takePass(t: *Tile) Pass { | |||
| 504 | }; | 413 | }; |
| 505 | } | 414 | } |
| 506 | 415 | ||
| 507 | /// Absolute rows count from the oldest row the daemon keeps, and a resync | 416 | /// One tile's life: dial → attach → replay frames into its Core → repaint at |
| 508 | /// renames that space: a kept highlight inverts rows nobody selected. | 417 | /// its rect, on its own thread. On transport death it reconnects on the CLI's |
| 509 | /// One tile's life: dial → attach → replay frames into its Core → repaint | 418 | /// backoff. This thread is also the tile's only WRITER: every frame the |
| 510 | /// at its rect. Runs on its own thread (see module header). On transport | 419 | /// keyboard doorbells for goes out from here. |
| 511 | /// death: reconnect on the CLI's backoff schedule, quoting | ||
| 512 | /// have_seq/have_epoch, and the snapshot-vs-delta resolution does the rest. | ||
| 513 | /// Ends when `running` clears, the session exits, or the attach is refused. | ||
| 514 | /// | ||
| 515 | /// This thread is also the tile's WRITER: every frame the keyboard doorbells | ||
| 516 | /// for — a resize, a detach, a focus claim or release — goes out from here, | ||
| 517 | /// because a Transport has exactly one owning thread (module header). | ||
| 518 | pub fn pumpTile(t: *Tile) void { | 420 | pub fn pumpTile(t: *Tile) void { |
| 519 | // FIRST defer, so it runs LAST: every `return` below — a refused | 421 | // FIRST defer, so it runs LAST: every `return` below is this tile going |
| 520 | // attach, an exited session, a dial the quit interrupted, a Core that | 422 | // quiet, and the keyboard's test is `!alive` — so the bell follows the store. |
| 521 | // would not initialise — is this tile going quiet for good, and the | ||
| 522 | // keyboard needs to know which tiles it has to paint for. Declared | ||
| 523 | // before the allocator's own defer so nothing can end this thread | ||
| 524 | // without it running. | ||
| 525 | // The bell goes with the store and after it, `endWith`'s reason: the | ||
| 526 | // keyboard's test is `!alive`, so a ring that precedes the store is a | ||
| 527 | // wake-up that finds nothing. | ||
| 528 | defer { | 423 | defer { |
| 529 | t.alive.store(false, .release); | 424 | t.alive.store(false, .release); |
| 530 | wv.ringKeyboard(t.shared); | 425 | wv.ringKeyboard(t.shared); |
| 531 | // LAST, after the bell above has finished reading `t.shared`: this | 426 | // LAST, after the bell has finished reading `t.shared`: this hands the |
| 532 | // is what hands the slot to `birthTile`, and nothing may touch the | 427 | // slot to `birthTile`, and nothing may touch the tile after it. |
| 533 | // tile after it. | ||
| 534 | t.pump_done.store(true, .release); | 428 | t.pump_done.store(true, .release); |
| 535 | } | 429 | } |
| 536 | 430 | ||
| @@ -541,22 +435,12 @@ pub fn pumpTile(t: *Tile) void { | |||
| 541 | const alloc = gpa.allocator(); | 435 | const alloc = gpa.allocator(); |
| 542 | 436 | ||
| 543 | // Whether this tile may narrate and may start a daemon travels IN its | 437 | // Whether this tile may narrate and may start a daemon travels IN its |
| 544 | // target, set once by whoever made the tile: a picker Enter is an ask, | 438 | // target. The pump's own copy: `t.r.target` belongs to the keyboard. |
| 545 | // a poll's list is not. This is the pump's own copy — `t.r.target` is | ||
| 546 | // read by the KEYBOARD thread under `paint_mu` for chord births and is | ||
| 547 | // never written from here — and it is spent below, once. | ||
| 548 | var target = t.r.target; | 439 | var target = t.r.target; |
| 549 | 440 | ||
| 550 | // ONE Core per tile, from birth. It owns this tile's replica, its | 441 | // ONE Core per tile, from birth: it owns this tile's replica, prediction |
| 551 | // prediction overlay and its drag for the tile's whole life: the tile | 442 | // overlay and drag for the tile's whole life. `in_fd` is the wall's stdin |
| 552 | // paints from that replica at its own offset, and what decides whether | 443 | // and this Core never reads it — it is passed for `is_tty`. |
| 553 | // a keystroke may be speculated at all is the pty's line discipline, | ||
| 554 | // which arrives in `.pty_mode` frames long before the tile is focused. | ||
| 555 | // | ||
| 556 | // `in_fd` is the wall's stdin and this Core never reads it — the | ||
| 557 | // keyboard thread does, on the far side of the mailbox. It is passed | ||
| 558 | // because it is the truth about whether there is a terminal here at all | ||
| 559 | // (`is_tty`), which the mouse split and the side channels are gated on. | ||
| 560 | var core = interact.Core.initSized( | 444 | var core = interact.Core.initSized( |
| 561 | alloc, | 445 | alloc, |
| 562 | std.posix.STDIN_FILENO, | 446 | std.posix.STDIN_FILENO, |
| @@ -564,18 +448,14 @@ pub fn pumpTile(t: *Tile) void { | |||
| 564 | t.shared.size, | 448 | t.shared.size, |
| 565 | ) catch return; | 449 | ) catch return; |
| 566 | defer core.deinit(); | 450 | defer core.deinit(); |
| 567 | // Whether this tile has EVER held the terminal, which is the only | 451 | // Whether this tile has EVER held the terminal: a tile that never was |
| 568 | // question the publish below is gated on: a tile that was never focused | 452 | // focused must not overwrite the stats of the tile that was. |
| 569 | // has nothing to say about prediction and must not overwrite what the | ||
| 570 | // tile that was does. | ||
| 571 | var ever_focused = false; | 453 | var ever_focused = false; |
| 572 | // Focus, as this pump knows it. Not `core.claim`: a claim needs a | 454 | // Focus as this pump knows it, not `core.claim`: `mux` on a pipe has no |
| 573 | // terminal, and `mux` on a pipe has none, yet its one tile is focused | 455 | // terminal to claim, yet its one tile is focused. |
| 574 | // and its predictions still expire and still get counted. | ||
| 575 | var focused = false; | 456 | var focused = false; |
| 576 | // The last word on this tile's prediction, whichever way the pump ends | 457 | // The last word on this tile's prediction: an `exit_status` RETURNS, so |
| 577 | // — an exit_status arrives and RETURNS, so the per-pass publish inside | 458 | // the per-pass publish is always one pass stale by then. |
| 578 | // the loop is always one pass stale by then. | ||
| 579 | defer if (ever_focused) publishStats(t.shared, core.overlay.counters); | 459 | defer if (ever_focused) publishStats(t.shared, core.overlay.counters); |
| 580 | // Where this Core's paints land: this tile's rect. The sink admits a | 460 | // Where this Core's paints land: this tile's rect. The sink admits a |
| 581 | // paint whenever the tile has not been forgotten; see `tilePaintBegin`. | 461 | // paint whenever the tile has not been forgotten; see `tilePaintBegin`. |
| @@ -588,9 +468,8 @@ pub fn pumpTile(t: *Tile) void { | |||
| 588 | core.owns_stats = false; | 468 | core.owns_stats = false; |
| 589 | 469 | ||
| 590 | wv.paintLabel(t, .connecting); | 470 | wv.paintLabel(t, .connecting); |
| 591 | // The ENTRY tile arrives with its link already up — dialled on the main | 471 | // The ENTRY tile arrives with its link already up, dialled where the tty |
| 592 | // thread, where the tty was, so ssh could prompt. `adopt` is what moves | 472 | // was so ssh could prompt; `adopt` moves its QUIC out-queue onto us. |
| 593 | // its QUIC out-queue onto this thread's allocator; see there. | ||
| 594 | var transport = if (t.pre) |pre| blk: { | 473 | var transport = if (t.pre) |pre| blk: { |
| 595 | var tr = pre; | 474 | var tr = pre; |
| 596 | t.pre = null; | 475 | t.pre = null; |
| @@ -598,61 +477,40 @@ pub fn pumpTile(t: *Tile) void { | |||
| 598 | break :blk tr; | 477 | break :blk tr; |
| 599 | } else dial(alloc, t, target) orelse return; | 478 | } else dial(alloc, t, target) orelse return; |
| 600 | defer transport.close(); | 479 | defer transport.close(); |
| 601 | // The ask is SPENT, on whichever of the two branches above got the | 480 | // The ask is SPENT on whichever branch got the link, so no reconnect can |
| 602 | // link: the entry tile's dial happened on the main thread, a picker | 481 | // start a daemon or print the fallback line onto the alternate screen. |
| 603 | // birth's just happened here. Every `redial` below is handed this | ||
| 604 | // copy, so a reconnect can neither start a daemon — `mux d stop` typed | ||
| 605 | // on that box would otherwise be undone by the next backoff, the | ||
| 606 | // poll's bug moved onto a tile — nor print the fallback line onto the | ||
| 607 | // alternate screen the tiles are painted on. | ||
| 608 | if (target == .hand) target.hand.asked = false; | 482 | if (target == .hand) target.hand.asked = false; |
| 609 | // The entry tile's attach carries its rect, so the session is sized to | 483 | // The entry tile's attach carries its rect, so no second resize follows: |
| 610 | // the terminal the tile claims and no second resize follows — | 484 | // re-asserting a size the daemon just heard costs a snapshot per `mux`. |
| 611 | // re-asserting a size the daemon just heard costs one more snapshot on | ||
| 612 | // every `mux`, which is exactly the round trip the convergence must not | ||
| 613 | // add. A view tile attaches at 0x0 (join-only) and `sendAttach` doorbells | ||
| 614 | // its rect behind the attach; that path is inside `sendAttach`. | ||
| 615 | sendAttach(t, &transport, 0, 0) catch { | 485 | sendAttach(t, &transport, 0, 0) catch { |
| 616 | endWith(t, .lost, 1); | 486 | endWith(t, .lost, 1); |
| 617 | return; | 487 | return; |
| 618 | }; | 488 | }; |
| 619 | // One question at a time, with the deadline that makes a daemon too old | 489 | // One question at a time, with the deadline that makes a daemon too old |
| 620 | // to have heard it (`sessions_req` is 0x0c) say so instead of swallowing | 490 | // for `sessions_req` say so instead of swallowing every chord. |
| 621 | // every chord for the rest of the session. `client.PendingSwitch` | ||
| 622 | // verbatim — the client asked the same question and this is the same | ||
| 623 | // answer, moved to the thread that owns the link. | ||
| 624 | var pending: client.PendingSwitch = .{}; | 491 | var pending: client.PendingSwitch = .{}; |
| 625 | // The ssh-agent channels this tile is serving, one open fd each to this | 492 | // The ssh-agent channels this tile serves, one open fd each. Table, fds |
| 626 | // machine's agent. Everything about them is thread-local: this table, | 493 | // and frames are all thread-local to this pump. |
| 627 | // the fds in it, and the frames that move them all live on this pump. | ||
| 628 | var agent_locals: [proto.agent_chans_max]?AgentLocal = @splat(null); | 494 | var agent_locals: [proto.agent_chans_max]?AgentLocal = @splat(null); |
| 629 | // Every `return` below is this tile going quiet with channels possibly | 495 | // Every `return` below is this tile going quiet with channels possibly |
| 630 | // still open, and the daemon's side of them dies with the transport the | 496 | // still open; the daemon's side dies with the transport. |
| 631 | // defer above closes. | ||
| 632 | defer dropLocals(&agent_locals); | 497 | defer dropLocals(&agent_locals); |
| 633 | 498 | ||
| 634 | var state: State = .connecting; | 499 | var state: State = .connecting; |
| 635 | // What this tile's paint is worth: while it matches the wall's | 500 | // What this tile's paint is worth: while it matches the wall's |
| 636 | // generation the terminal still holds what this thread drew. | 501 | // generation the terminal still holds what this thread drew. |
| 637 | var painted_gen = t.shared.repaint_gen.load(.acquire); | 502 | var painted_gen = t.shared.repaint_gen.load(.acquire); |
| 638 | // `gone` ends this thread exactly as `running` does — the defers close | 503 | // `gone` ends this thread exactly as `running` does: the defers free the |
| 639 | // the transport, which frees the daemon slot and NOTHING else. The | 504 | // daemon slot and nothing else. The session goes on running. |
| 640 | // session goes on running: "remove is detach". | ||
| 641 | outer: while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | 505 | outer: while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { |
| 642 | // The link and the doorbell, then one fd per live agent channel — | 506 | // The link, the doorbell, then one fd per live agent channel — one |
| 643 | // joined into the pump's own poll rather than given a thread each, | 507 | // poll, because the transport has exactly one owning thread. |
| 644 | // because a Transport has exactly one owning thread and these bytes | ||
| 645 | // leave through it. `at` remembers which table slot each of those | ||
| 646 | // trailing fds came from, so a readable one can be traced back to | ||
| 647 | // its channel without a second search. | ||
| 648 | var fdbuf: [3 + agent_locals.len]std.posix.pollfd = undefined; | 508 | var fdbuf: [3 + agent_locals.len]std.posix.pollfd = undefined; |
| 649 | fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }; | 509 | fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }; |
| 650 | fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 }; | 510 | fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 }; |
| 651 | var nfds: usize = 2; | 511 | var nfds: usize = 2; |
| 652 | // A handoff that stayed on the ssh pipe has a third fd: ssh's | 512 | // Unread, ssh's stderr fills at 64k and ssh stops talking to the far |
| 653 | // stderr. Unread it fills at 64k and ssh stops talking to the far | 513 | // end — this tile going silent for a reason no frame can explain. |
| 654 | // end at all, which is this tile going silent for a reason no | ||
| 655 | // frame can explain. | ||
| 656 | const err_slot: ?usize = if (transport.errFd()) |efd| blk: { | 514 | const err_slot: ?usize = if (transport.errFd()) |efd| blk: { |
| 657 | fdbuf[nfds] = .{ .fd = efd, .events = std.posix.POLL.IN, .revents = 0 }; | 515 | fdbuf[nfds] = .{ .fd = efd, .events = std.posix.POLL.IN, .revents = 0 }; |
| 658 | nfds += 1; | 516 | nfds += 1; |
| @@ -672,11 +530,8 @@ pub fn pumpTile(t: *Tile) void { | |||
| 672 | if (fdbuf[1].revents != 0) drainWake(t); | 530 | if (fdbuf[1].revents != 0) drainWake(t); |
| 673 | if (err_slot) |s| if (fdbuf[s].revents != 0) transport.drainErr(); | 531 | if (err_slot) |s| if (fdbuf[s].revents != 0) transport.drainErr(); |
| 674 | 532 | ||
| 675 | // The paint offset for this pass: a relayout may have re-cut this | 533 | // The paint offset for this pass. Snapshot under `paint_mu`, which the |
| 676 | // tile's rect, and every paint the pass drives through the Core | 534 | // keyboard writes `rect` and `label_rows` under, and use it throughout. |
| 677 | // has to land in the rect the tile currently owns. Snapshot under | ||
| 678 | // `paint_mu` — the keyboard writes `rect` and `label_rows` under | ||
| 679 | // it — and use the snapshot for the whole pass. | ||
| 680 | const snap = takePass(t); | 535 | const snap = takePass(t); |
| 681 | core.row_off = snap.top + snap.label_rows; | 536 | core.row_off = snap.top + snap.label_rows; |
| 682 | core.col_off = snap.left; | 537 | core.col_off = snap.left; |
| @@ -686,57 +541,31 @@ pub fn pumpTile(t: *Tile) void { | |||
| 686 | const snap_view_rows: u16 = snap.rows -| snap.label_rows; | 541 | const snap_view_rows: u16 = snap.rows -| snap.label_rows; |
| 687 | const snap_view_cols: u16 = snap.cols; | 542 | const snap_view_cols: u16 = snap.cols; |
| 688 | 543 | ||
| 689 | // FOCUS CLAIM. The keyboard moved the focus onto this tile; the | 544 | // FOCUS CLAIM: the session's mouse modes and side channels go on here, |
| 690 | // session's mouse modes and side channels go on here, on the thread | 545 | // on the thread that owns the transport and the Core. |
| 691 | // that owns the transport and the Core. The resize the claim used | ||
| 692 | // to send is gone — the attach already carried the rect, and a | ||
| 693 | // relayout doorbells `resize_pending` for any later change. | ||
| 694 | if (t.claim_pending.swap(false, .acq_rel)) { | 546 | if (t.claim_pending.swap(false, .acq_rel)) { |
| 695 | // A refused claim is re-armed rather than lost: the host | 547 | // A refused claim is re-armed rather than lost, or the tile stays |
| 696 | // picker's popup admits no paint, and a claim writes the | 548 | // focused holding no terminal. The rest of the PASS still runs: |
| 697 | // session's modes through the paint sink, so a claim dropped | 549 | // `takePass` already cleared the resize this tile owes. |
| 698 | // under it leaves this pump focused holding no terminal — no | ||
| 699 | // mouse modes, no side channels, the notice below eaten — until | ||
| 700 | // the user moves the focus away and back. The picker's close | ||
| 701 | // rings this pump, so the retry is a keystroke away. | ||
| 702 | // | ||
| 703 | // The rest of the PASS still runs whatever comes back: | ||
| 704 | // `takePass` has already cleared `resize_pending`, so skipping | ||
| 705 | // out here would drop a relayout this tile owes the daemon. | ||
| 706 | const step = claimFocus(t, &core, .{ .cols = snap_view_cols, .rows = snap_view_rows }); | 550 | const step = claimFocus(t, &core, .{ .cols = snap_view_cols, .rows = snap_view_rows }); |
| 707 | // A stale arm — the keyboard moved the focus between arming | 551 | // A stale arm takes nothing: no claim, no modes, no notice, and |
| 708 | // this and this pass — takes nothing: no claim, no modes, no | 552 | // these predictions are not the focus's to publish. |
| 709 | // notice, and these predictions are not the focus's to publish | ||
| 710 | // either. The tile that DOES hold the focus was armed by the | ||
| 711 | // same `setFocus` that took it from this one. | ||
| 712 | focused = step != .dropped; | 553 | focused = step != .dropped; |
| 713 | ever_focused = ever_focused or focused; | 554 | ever_focused = ever_focused or focused; |
| 714 | if (step == .done) { | 555 | if (step == .done) { |
| 715 | // A sentence the keyboard left for whoever owns the terminal | 556 | // A sentence the keyboard left for whoever owns the terminal |
| 716 | // next — a refused `Ctrl-\ c`, so far. Painted here because | 557 | // next. Taken now, shown once the grid below is up. |
| 717 | // a banner belongs to a Core and this is the Core that has | ||
| 718 | // just taken the screen; painted AFTER the repaint below | ||
| 719 | // would be wrong, so it is taken now and shown once the grid | ||
| 720 | // is up. | ||
| 721 | var notice_buf: [96]u8 = undefined; | 558 | var notice_buf: [96]u8 = undefined; |
| 722 | const notice = wv.takeNotice(t.shared, ¬ice_buf); | 559 | const notice = wv.takeNotice(t.shared, ¬ice_buf); |
| 723 | // The replica has been hot the whole time, so a claim paints | 560 | // The replica has been hot, so a claim paints from it NOW: |
| 724 | // from it NOW rather than waiting for the daemon's answering | 561 | // moving the focus costs a local repaint, never a wire frame. |
| 725 | // snapshot. That is the headline: moving the focus costs a | 562 | // Only when there IS one — a blank grid is a screen mux never drew. |
| 726 | // local repaint, never a wire frame. | ||
| 727 | // ...but only when there IS one. A tile focused before its | ||
| 728 | // first snapshot — the entry tile, on every `mux` — would | ||
| 729 | // otherwise paint a blank grid over the terminal before the | ||
| 730 | // session has said anything, which is a screen the plain | ||
| 731 | // client never drew and bytes a capture never held. | ||
| 732 | if (core.rep.session_epoch != 0) core.repaint() catch {}; | 563 | if (core.rep.session_epoch != 0) core.repaint() catch {}; |
| 733 | if (notice.len > 0) core.banner(notice); | 564 | if (notice.len > 0) core.banner(notice); |
| 734 | } | 565 | } |
| 735 | } | 566 | } |
| 736 | // FOCUS RELEASE. The keyboard moved the focus off this tile and | 567 | // FOCUS RELEASE: the keyboard wrote the session's release itself under |
| 737 | // wrote the session's release itself, under `paint_mu`, before | 568 | // `paint_mu` before doorbelling, so this pump owes only its own state. |
| 738 | // doorbelling — so the handover is ordered and this pump owes only | ||
| 739 | // its own state. `.already_written` is that discipline. | ||
| 740 | if (t.release_pending.swap(false, .acq_rel)) { | 569 | if (t.release_pending.swap(false, .acq_rel)) { |
| 741 | focused = false; | 570 | focused = false; |
| 742 | core.releaseTerminal(.already_written); | 571 | core.releaseTerminal(.already_written); |
| @@ -745,14 +574,12 @@ pub fn pumpTile(t: *Tile) void { | |||
| 745 | core.overlay.flush(); | 574 | core.overlay.flush(); |
| 746 | } | 575 | } |
| 747 | 576 | ||
| 748 | // RELAYOUT DOORBELL: this tile's rect changed. The pump is the | 577 | // RELAYOUT DOORBELL: the pump is the transport's only writer, so |
| 749 | // transport's only writer, so relayout sets the flag and the pump | 578 | // relayout sets the flag and the `.resize` goes out from here. |
| 750 | // sends the `.resize` from here. | ||
| 751 | if (snap.resize) { | 579 | if (snap.resize) { |
| 752 | core.overlay.setResizePending(true); | 580 | core.overlay.setResizePending(true); |
| 753 | // The Core clips every paint to its size; a resize the daemon | 581 | // The Core clips every paint to its size: a resize the daemon hears |
| 754 | // hears but the Core does not leaves the bottom of the new | 582 | // but the Core does not cuts the new grid's bottom off forever. |
| 755 | // grid cut off on screen forever. | ||
| 756 | core.adoptSize(.{ .cols = snap_view_cols, .rows = snap_view_rows }); | 583 | core.adoptSize(.{ .cols = snap_view_cols, .rows = snap_view_rows }); |
| 757 | transport.writeFrame( | 584 | transport.writeFrame( |
| 758 | .resize, | 585 | .resize, |
| @@ -763,26 +590,20 @@ pub fn pumpTile(t: *Tile) void { | |||
| 763 | }; | 590 | }; |
| 764 | } | 591 | } |
| 765 | 592 | ||
| 766 | // `Ctrl-\ d`: hand the daemon its slot back before the process | 593 | // `Ctrl-\ d`: hand the daemon its slot back before the process dies. |
| 767 | // dies, rather than leaving it for the socket's death to be | 594 | // Only this thread may write the frame, so the keyboard is waiting. |
| 768 | // noticed. Only this thread may write the frame, so the keyboard | ||
| 769 | // asked and is waiting for the ack. | ||
| 770 | if (t.detach_req.swap(false, .acq_rel)) { | 595 | if (t.detach_req.swap(false, .acq_rel)) { |
| 771 | transport.writeFrame(.detach, "") catch {}; | 596 | transport.writeFrame(.detach, "") catch {}; |
| 772 | t.detach_ack.store(true, .release); | 597 | t.detach_ack.store(true, .release); |
| 773 | wv.ringKeyboard(t.shared); | 598 | wv.ringKeyboard(t.shared); |
| 774 | } | 599 | } |
| 775 | 600 | ||
| 776 | // A focus chord's question, put on the wire from the thread that | 601 | // A focus chord's question, from the thread that owns the link. The |
| 777 | // owns the link. The ANSWER is read out of the `.sessions_reply` | 602 | // ANSWER goes to the keyboard, the only thread that may move focus. |
| 778 | // arm below and handed to the keyboard, which is the only thread | ||
| 779 | // that may move the focus or grow the wall. | ||
| 780 | const asked: client.SwitchIntent = @enumFromInt(t.ask.swap(0, .acq_rel)); | 603 | const asked: client.SwitchIntent = @enumFromInt(t.ask.swap(0, .acq_rel)); |
| 781 | if (asked != .none) { | 604 | if (asked != .none) { |
| 782 | // The deadline starts HERE, when the question goes on the wire, | 605 | // The deadline starts when the question goes on the wire: it |
| 783 | // not when the key was typed: it exists to catch a daemon too | 606 | // catches a daemon too old to have heard `sessions_req`. |
| 784 | // old to have heard `sessions_req`, and the time a chord spent | ||
| 785 | // in the mailbox is not that daemon's silence. | ||
| 786 | pending.arm(asked, std.time.milliTimestamp()); | 607 | pending.arm(asked, std.time.milliTimestamp()); |
| 787 | var end_buf: [proto.end_req_max_len]u8 = undefined; | 608 | var end_buf: [proto.end_req_max_len]u8 = undefined; |
| 788 | const sent = switch (asked) { | 609 | const sent = switch (asked) { |
| @@ -802,14 +623,8 @@ pub fn pumpTile(t: *Tile) void { | |||
| 802 | }; | 623 | }; |
| 803 | } | 624 | } |
| 804 | 625 | ||
| 805 | // A chord that was never answered. Said with the banner rather than | 626 | // Said with a banner, not stderr: the terminal is raw on the alternate |
| 806 | // stderr for the client's reason: the terminal is in raw mode on | 627 | // screen. Read before `expired` spends it, so the verb is named right. |
| 807 | // the alternate screen and a print there lands mid-grid. Reachable | ||
| 808 | // at all because the poll below is capped at 100ms. | ||
| 809 | // | ||
| 810 | // Read before `expired` spends it: the sentence names the verb the | ||
| 811 | // daemon did not know, and "no session list" over an `x` sends the | ||
| 812 | // user looking at the wrong end of the wire. | ||
| 813 | const waiting = pending.intent; | 628 | const waiting = pending.intent; |
| 814 | if (pending.expired(std.time.milliTimestamp())) | 629 | if (pending.expired(std.time.milliTimestamp())) |
| 815 | core.banner(switch (waiting) { | 630 | core.banner(switch (waiting) { |
| @@ -817,22 +632,9 @@ pub fn pumpTile(t: *Tile) void { | |||
| 817 | else => "[no session list: upgrade that daemon]", | 632 | else => "[no session list: upgrade that daemon]", |
| 818 | }); | 633 | }); |
| 819 | 634 | ||
| 820 | // FRAMES BEFORE KEYS, and that order is load-bearing rather than | 635 | // FRAMES BEFORE KEYS: `Core.forward` splits a wheel notch by the mouse |
| 821 | // arbitrary. What the session has already said must be known before | 636 | // mode a `term_modes` frame carries, so keys judged first read it stale. |
| 822 | // what the user is saying is interpreted, because the interpreting | 637 | // QUIC frames can arrive with the socket never going readable. |
| 823 | // depends on it: `Core.forward` splits a wheel notch by whether the | ||
| 824 | // session's application asked for the mouse, and that fact arrives | ||
| 825 | // in a `term_modes` frame. A pass that holds both a readable frame | ||
| 826 | // and a mailbox of keystrokes and drains the mailbox first judges | ||
| 827 | // the keystrokes against a session it has not finished listening | ||
| 828 | // to — the plain client's loop read frames first, and an | ||
| 829 | // application holding the mouse lost its first wheel notch to this | ||
| 830 | // client's scrollback the moment the order was reversed. Found by | ||
| 831 | // the suite, not by reading. | ||
| 832 | // | ||
| 833 | // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames | ||
| 834 | // can arrive from the stream layer with the socket never going | ||
| 835 | // readable. | ||
| 836 | if (fdbuf[0].revents != 0 or transport.link == .quic) frames: { | 638 | if (fdbuf[0].revents != 0 or transport.link == .quic) frames: { |
| 837 | while (true) { | 639 | while (true) { |
| 838 | const incoming = transport.readFrame(alloc) catch return; | 640 | const incoming = transport.readFrame(alloc) catch return; |
| @@ -845,20 +647,9 @@ pub fn pumpTile(t: *Tile) void { | |||
| 845 | }, | 647 | }, |
| 846 | }; | 648 | }; |
| 847 | defer frame.deinit(alloc); | 649 | defer frame.deinit(alloc); |
| 848 | // Everything the frame means to the replica and the screen, | 650 | // The Core paints at this tile's offset and writes side |
| 849 | // in the one place that switch lives. The Core paints at | 651 | // channels only while it holds the claim — except `.pty_mode`, |
| 850 | // this tile's offset through the sink, and writes side | 652 | // which gates speculation and must be true before any claim. |
| 851 | // channels only while it holds the claim — so a tile that | ||
| 852 | // is not focused still keeps its replica hot and its grid | ||
| 853 | // on its rect, but its title and mouse modes reach no | ||
| 854 | // terminal until the focus comes to it. | ||
| 855 | // | ||
| 856 | // The one exception is deliberate and is `.pty_mode`: the | ||
| 857 | // Core feeds the overlay's mode whatever the claim, because | ||
| 858 | // the pty's line discipline is the entire gate on | ||
| 859 | // speculation — a password prompt must never be predicted — | ||
| 860 | // and a claim has to start from the truth rather than | ||
| 861 | // from `.never` and a round trip. | ||
| 862 | const routed = core.frame(frame.type, frame.payload) catch break :frames; | 653 | const routed = core.frame(frame.type, frame.payload) catch break :frames; |
| 863 | switch (routed) { | 654 | switch (routed) { |
| 864 | .skip, .handled => {}, | 655 | .skip, .handled => {}, |
| @@ -869,17 +660,12 @@ pub fn pumpTile(t: *Tile) void { | |||
| 869 | wv.paintLabel(t, state); | 660 | wv.paintLabel(t, state); |
| 870 | } | 661 | } |
| 871 | }, | 662 | }, |
| 872 | // The replica is suspect, not the transport: re-attach | 663 | // The replica is suspect, not the transport: re-attach at |
| 873 | // quoting (0,0) explicitly — a quoted seq would invite | 664 | // (0,0), since a quoted seq invites an unfixable delta. |
| 874 | // the delta that cannot fix us. | ||
| 875 | .resync => { | 665 | .resync => { |
| 876 | core.rep.state_since_attach = false; | 666 | core.rep.state_since_attach = false; |
| 877 | // A resync renames the absolute row space, so a | 667 | // A resync renames the absolute row space, so a held |
| 878 | // held drag now names rows nobody selected — and | 668 | // drag would copy rows nobody selected. |
| 879 | // on an EPOCH change `sel_range` still matches it, | ||
| 880 | // so an in-flight reply would copy the new session's | ||
| 881 | // text. `redial` drops it for this reason; this path | ||
| 882 | // re-attaches without going through it. | ||
| 883 | core.drag.clear(); | 669 | core.drag.clear(); |
| 884 | sendAttach(t, &transport, 0, 0) catch return; | 670 | sendAttach(t, &transport, 0, 0) catch return; |
| 885 | }, | 671 | }, |
| @@ -898,28 +684,19 @@ pub fn pumpTile(t: *Tile) void { | |||
| 898 | return; | 684 | return; |
| 899 | }, | 685 | }, |
| 900 | .taken_over => { | 686 | .taken_over => { |
| 901 | // Unsent by this daemon (wire-compat only), but | 687 | // Unsent by this daemon (wire-compat): somebody took |
| 902 | // the plain client had an answer for it and the | 688 | // the session, so this tile is finished, not redialing. |
| 903 | // converged one keeps it: somebody else took the | ||
| 904 | // session, so this tile is finished rather than | ||
| 905 | // reconnecting into a fight over the grid. | ||
| 906 | state = .exited; | 689 | state = .exited; |
| 907 | wv.paintLabel(t, state); | 690 | wv.paintLabel(t, state); |
| 908 | endWith(t, .taken, 0); | 691 | endWith(t, .taken, 0); |
| 909 | return; | 692 | return; |
| 910 | }, | 693 | }, |
| 911 | .sessions_reply => { | 694 | .sessions_reply => { |
| 912 | // Gated on the intent this tile's own chord | 695 | // Gated on the intent this tile's chord armed, and |
| 913 | // armed: only a question we asked may move the | 696 | // SPENT here: one question, one answer. |
| 914 | // focus, so an unasked-for reply is ignored. The | ||
| 915 | // intent is SPENT here whichever name it picks | ||
| 916 | // — one question, one answer. | ||
| 917 | var name_buf: [proto.session_name_max]u8 = undefined; | 697 | var name_buf: [proto.session_name_max]u8 = undefined; |
| 918 | // A list is only ever asked for to NAME a new | 698 | // A list is only asked for to NAME a new session; |
| 919 | // session: `n`/`p` walk the wall's own tiles and | 699 | // `n`/`p` walk the wall's own tiles and ask nothing. |
| 920 | // ask nothing, and the end verbs are answered by | ||
| 921 | // `end_reply`. Anything else here is a reply to | ||
| 922 | // a question this tile did not put. | ||
| 923 | const pick: ?[]const u8 = switch (pending.take()) { | 700 | const pick: ?[]const u8 = switch (pending.take()) { |
| 924 | .new => client.nextFreeName(&name_buf, frame.payload), | 701 | .new => client.nextFreeName(&name_buf, frame.payload), |
| 925 | else => null, | 702 | else => null, |
| @@ -931,13 +708,7 @@ pub fn pumpTile(t: *Tile) void { | |||
| 931 | _ = pending.take(); | 708 | _ = pending.take(); |
| 932 | wv.onEndReply(t, r, std.time.milliTimestamp()); | 709 | wv.onEndReply(t, r, std.time.milliTimestamp()); |
| 933 | // An ACCEPTED end says nothing: the hangup's | 710 | // An ACCEPTED end says nothing: the hangup's |
| 934 | // `exit_status` is on its way and the `.exited` | 711 | // `exit_status` is next, very likely this same pass. |
| 935 | // path narrates it, so a banner here would be | ||
| 936 | // the client congratulating itself ahead of the | ||
| 937 | // daemon. Not a `break` either — that exit | ||
| 938 | // status is very likely the next frame in this | ||
| 939 | // same pass, and leaving it for the next poll | ||
| 940 | // is 100ms of a tile that is already gone. | ||
| 941 | if (!r.accepted) { | 712 | if (!r.accepted) { |
| 942 | var b: [96]u8 = undefined; | 713 | var b: [96]u8 = undefined; |
| 943 | core.banner(if (r.others == 0) | 714 | core.banner(if (r.others == 0) |
| @@ -952,13 +723,8 @@ pub fn pumpTile(t: *Tile) void { | |||
| 952 | .selection_reply => copySelection(t, alloc, &core, frame.payload), | 723 | .selection_reply => copySelection(t, alloc, &core, frame.payload), |
| 953 | .agent_open => { | 724 | .agent_open => { |
| 954 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | 725 | const id = proto.decodeAgentId(frame.payload) catch break :frames; |
| 955 | // Every refusal is the same answer on the wire — | 726 | // Every refusal is the same answer on the wire: a |
| 956 | // a channel that closes without a byte, which the | 727 | // channel that closes unspoken, read as "no agent". |
| 957 | // far side's ssh reads as "no agent" and gives up | ||
| 958 | // on rather than hanging. Only one of the four | ||
| 959 | // reasons is ordinary — no agent on this machine; | ||
| 960 | // the rest are a daemon asking for something it | ||
| 961 | // should not. | ||
| 962 | const opened = openAgentChan( | 728 | const opened = openAgentChan( |
| 963 | &agent_locals, | 729 | &agent_locals, |
| 964 | id, | 730 | id, |
| @@ -978,9 +744,8 @@ pub fn pumpTile(t: *Tile) void { | |||
| 978 | )) break :frames, | 744 | )) break :frames, |
| 979 | .agent_close => { | 745 | .agent_close => { |
| 980 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | 746 | const id = proto.decodeAgentId(frame.payload) catch break :frames; |
| 981 | // Silent, mirroring the daemon: it has already | 747 | // Silent, mirroring the daemon: it has retired this |
| 982 | // retired this id, so an `agent_close` back would | 748 | // id, so a close back would be an echo. |
| 983 | // be an echo it has to learn to ignore. | ||
| 984 | if (findLocal(&agent_locals, id)) |s| { | 749 | if (findLocal(&agent_locals, id)) |s| { |
| 985 | const ch = agent_locals[s].?; | 750 | const ch = agent_locals[s].?; |
| 986 | agent_locals[s] = null; | 751 | agent_locals[s] = null; |
| @@ -992,37 +757,10 @@ pub fn pumpTile(t: *Tile) void { | |||
| 992 | else => {}, | 757 | else => {}, |
| 993 | }, | 758 | }, |
| 994 | } | 759 | } |
| 995 | // Only the socket link guarantees one readable event is | 760 | // One event is one frame only on the socket link, and one PASS |
| 996 | // one frame; QUIC may have buffered more. | 761 | // is not one frame: a resync is a burst, and a wheel notch |
| 997 | // | 762 | // judged before its trailing `term_modes` is misread. A zero |
| 998 | // But "one event, one frame" is not "one pass, one frame". | 763 | // timeout drains what has ARRIVED and never blocks. |
| 999 | // A resync is a BURST — snapshot, pty mode, title, terminal | ||
| 1000 | // modes — and stopping after the first leaves the rest to be | ||
| 1001 | // read a pass at a time, with the user's keystrokes | ||
| 1002 | // interleaved between them. `Core.forward` decides what a | ||
| 1003 | // wheel notch MEANS from the session's terminal modes, so a | ||
| 1004 | // notch judged before the `term_modes` at the END of that | ||
| 1005 | // burst is stolen for this client's scrollback instead of | ||
| 1006 | // reaching the application that asked for the mouse. A | ||
| 1007 | // single-threaded client hid the hazard by being busy | ||
| 1008 | // painting the snapshot when the notch arrived; a keyboard | ||
| 1009 | // on its own thread notices it immediately, and the suite | ||
| 1010 | // caught it two runs out of three. | ||
| 1011 | // | ||
| 1012 | // So: drain what has already ARRIVED. A zero timeout waits | ||
| 1013 | // for nothing, which is what keeps this a drain and not a | ||
| 1014 | // second blocking read. | ||
| 1015 | // | ||
| 1016 | // It cannot starve the rest of the pass, and two measured | ||
| 1017 | // facts are why. The daemon coalesces its deltas, so a | ||
| 1018 | // loud session hands over a bounded burst and `poll(0)` | ||
| 1019 | // finds the socket empty within it rather than a stream | ||
| 1020 | // that refills as fast as it is read. And the keyboard is | ||
| 1021 | // a different thread with its own 400ms bound on the one | ||
| 1022 | // thing it ever waits for a pump to do (`awaitDetach`), so | ||
| 1023 | // even a pump that did stall here could not hold the | ||
| 1024 | // terminal hostage — which is the failure this would | ||
| 1025 | // otherwise have to be argued safe against. | ||
| 1026 | if (transport.link != .quic) { | 764 | if (transport.link != .quic) { |
| 1027 | var more = [_]std.posix.pollfd{ | 765 | var more = [_]std.posix.pollfd{ |
| 1028 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | 766 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, |
| @@ -1033,34 +771,19 @@ pub fn pumpTile(t: *Tile) void { | |||
| 1033 | } | 771 | } |
| 1034 | } | 772 | } |
| 1035 | 773 | ||
| 1036 | // The other direction: what this machine's agent answered, back out | 774 | // What this machine's agent answered, back out as `agent_data`. AFTER |
| 1037 | // as `agent_data`. AFTER the frame drain, deliberately — the table | 775 | // the frame drain, so a channel just closed is already gone. |
| 1038 | // this walks is then the one the daemon's latest word left behind, | ||
| 1039 | // so a channel it has just closed is already gone rather than read | ||
| 1040 | // once more on its way out. | ||
| 1041 | for (agent_base..nfds) |i| { | 776 | for (agent_base..nfds) |i| { |
| 1042 | if (fdbuf[i].revents == 0) continue; | 777 | if (fdbuf[i].revents == 0) continue; |
| 1043 | const s = at[i - agent_base]; | 778 | const s = at[i - agent_base]; |
| 1044 | const ch = agent_locals[s] orelse continue; | 779 | const ch = agent_locals[s] orelse continue; |
| 1045 | // The id is written into the head of the very buffer the read | 780 | // The id goes into the head of the read's own buffer, so a frame |
| 1046 | // fills, so a frame costs no second copy. The read is capped at | 781 | // costs no second copy. Capped at the wire's `agent_data_max`. |
| 1047 | // the wire's `agent_data_max` because that cap is what keeps one | ||
| 1048 | // busy channel from holding the link against the session's own | ||
| 1049 | // bytes — the same bound the daemon reads its end with. | ||
| 1050 | var buf: [proto.agent_id_len + proto.agent_data_max]u8 = undefined; | 782 | var buf: [proto.agent_id_len + proto.agent_data_max]u8 = undefined; |
| 1051 | buf[0..proto.agent_id_len].* = proto.encodeAgentId(ch.id); | 783 | buf[0..proto.agent_id_len].* = proto.encodeAgentId(ch.id); |
| 1052 | // DONTWAIT, and it is load-bearing rather than belt-and-braces: | 784 | // DONTWAIT: the poll ran before the frame drain, so this slot may |
| 1053 | // the poll above happened before the frame drain, so the daemon | 785 | // since have been refilled and be merely idle. EOF and a broken |
| 1054 | // may since have closed this slot's channel and an `agent_open` | 786 | // connection are one case — the far side needs to hear either. |
| 1055 | // in the same burst may have refilled the slot with a fresh | ||
| 1056 | // connection that has said nothing yet. A blocking read there | ||
| 1057 | // would stop the whole tile — its keystrokes, its paints — on a | ||
| 1058 | // socket that is merely idle. `agent_locals[s]` is re-read above, | ||
| 1059 | // so whatever this does return is attributed to the id that | ||
| 1060 | // actually owns the fd. | ||
| 1061 | // | ||
| 1062 | // EOF and a broken connection are one case: the agent is done | ||
| 1063 | // with this channel either way, and the far side needs to hear so. | ||
| 1064 | const n = std.posix.recv( | 787 | const n = std.posix.recv( |
| 1065 | ch.fd, | 788 | ch.fd, |
| 1066 | buf[proto.agent_id_len..], | 789 | buf[proto.agent_id_len..], |
| @@ -1080,10 +803,7 @@ pub fn pumpTile(t: *Tile) void { | |||
| 1080 | } | 803 | } |
| 1081 | 804 | ||
| 1082 | // Whatever the keyboard left, through everything a plain client's | 805 | // Whatever the keyboard left, through everything a plain client's |
| 1083 | // keystrokes go through: the mouse split, the wheel, alternate | 806 | // keystrokes go through. Every pump drains its own mailbox. |
| 1084 | // scroll, the scrollback view, the prediction and the input frame. | ||
| 1085 | // Only the focused tile's mailbox is ever written, but every pump | ||
| 1086 | // drains its own — an empty mailbox is the common, cheap answer. | ||
| 1087 | var keys_buf: [wv.mailbox_max]u8 = undefined; | 807 | var keys_buf: [wv.mailbox_max]u8 = undefined; |
| 1088 | const keys = takeKeys(t, &keys_buf); | 808 | const keys = takeKeys(t, &keys_buf); |
| 1089 | if (keys.len > 0) { | 809 | if (keys.len > 0) { |
| @@ -1094,30 +814,21 @@ pub fn pumpTile(t: *Tile) void { | |||
| 1094 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | 814 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; |
| 1095 | continue :outer; | 815 | continue :outer; |
| 1096 | } | 816 | } |
| 1097 | // Input is moving again, so "input dropped" has stopped being | 817 | // Input is moving again, so "input dropped" has stopped being news. |
| 1098 | // news. Repainted rather than merely cleared, because the bar | 818 | // Repainted, because the bar still carries the old sentence. |
| 1099 | // is still carrying the old sentence until something draws | ||
| 1100 | // over it — and only when there is a bar, which `paintLabel` | ||
| 1101 | // already decides. | ||
| 1102 | if (t.in_dropped.swap(false, .acq_rel)) wv.paintLabel(t, state); | 819 | if (t.in_dropped.swap(false, .acq_rel)) wv.paintLabel(t, state); |
| 1103 | } | 820 | } |
| 1104 | 821 | ||
| 1105 | // A prediction the daemon never answered must not sit on the | 822 | // A prediction the daemon never answered must not sit on screen |
| 1106 | // screen forever, and only the clock can say so — no frame will. | 823 | // forever, and only the clock can say so. Focused tiles only. |
| 1107 | // Only while focused: an unfocused tile's rect shows no prediction | ||
| 1108 | // to retire. | ||
| 1109 | if (focused) { | 824 | if (focused) { |
| 1110 | core.idle() catch {}; | 825 | core.idle() catch {}; |
| 1111 | publishStats(t.shared, core.overlay.counters); | 826 | publishStats(t.shared, core.overlay.counters); |
| 1112 | } | 827 | } |
| 1113 | 828 | ||
| 1114 | // A relayout re-cut the stripes (or cleared the screen for an empty | 829 | // A relayout re-cut the stripes and a quiet session sends nothing to |
| 1115 | // wall), and a quiet session sends nothing to trigger a repaint. | 830 | // trigger a repaint: the generation is what puts the SCREEN back. The |
| 1116 | // The replica is current — the SCREEN is not — so the generation is | 831 | // drag clears too — the rect its anchor was resolved against moved. |
| 1117 | // what puts it back. Checked on the poll timeout, so a tile comes | ||
| 1118 | // back within ~100ms of a relayout whether or not its session ever | ||
| 1119 | // speaks again. The drag clears here too: a relayout moved the rect | ||
| 1120 | // a held drag's anchor was resolved against. | ||
| 1121 | if (snap.gen != painted_gen) { | 832 | if (snap.gen != painted_gen) { |
| 1122 | core.drag.clear(); | 833 | core.drag.clear(); |
| 1123 | wv.paintLabel(t, state); | 834 | wv.paintLabel(t, state); |
| @@ -1129,9 +840,8 @@ pub fn pumpTile(t: *Tile) void { | |||
| 1129 | } | 840 | } |
| 1130 | } | 841 | } |
| 1131 | 842 | ||
| 1132 | /// A refusal in THIS client's words. `parseEndReply` hands back the frame's | 843 | /// A refusal in THIS client's words: `parseEndReply` hands back the frame's |
| 1133 | /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's | 844 | /// tail unfiltered, so a peer's escape would run outside the replica. |
| 1134 | /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants. | ||
| 1135 | pub fn endRefusal(reason: []const u8) []const u8 { | 845 | pub fn endRefusal(reason: []const u8) []const u8 { |
| 1136 | if (std.mem.eql(u8, reason, proto.end_reason.no_session)) | 846 | if (std.mem.eql(u8, reason, proto.end_reason.no_session)) |
| 1137 | return "[no such session on that daemon]"; | 847 | return "[no such session on that daemon]"; |