efa56bfd
refactor: one tile's thread is wall_pump.zig
a73x 2026-08-28 20:50
Commit message
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -50,3 +50,4 @@ server_agent.zig 0 | |||
| 50 | server_sessions.zig 0 | 50 | server_sessions.zig 0 |
| 51 | wall_host.zig 0 | 51 | wall_host.zig 0 |
| 52 | wall_picker.zig 0 | 52 | wall_picker.zig 0 |
| 53 | wall_pump.zig 0 | ||
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1073 @@ | |||
| 1 | //! One tile's thread: the transport it owns in BOTH directions, its | ||
| 2 | //! attach, its claims and releases, its agent channels, its redials, and | ||
| 3 | //! the paint hooks the interaction core calls back on. The keyboard hands | ||
| 4 | //! this thread work through the tile's mailbox and doorbell and reads its | ||
| 5 | //! answers back the same way; nothing here touches another tile. | ||
| 6 | const std = @import("std"); | ||
| 7 | const proto = @import("protocol"); | ||
| 8 | const client = @import("client"); | ||
| 9 | const interact = @import("interact"); | ||
| 10 | const wv = @import("wallview.zig"); | ||
| 11 | const EndReason = wv.EndReason; | ||
| 12 | const Shared = wv.Shared; | ||
| 13 | const State = wv.State; | ||
| 14 | const Tile = wv.Tile; | ||
| 15 | |||
| 16 | /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the | ||
| 17 | /// paint after it. | ||
| 18 | pub fn copySelection( | ||
| 19 | t: *Tile, | ||
| 20 | alloc: std.mem.Allocator, | ||
| 21 | core: *interact.Core, | ||
| 22 | payload: []const u8, | ||
| 23 | ) void { | ||
| 24 | var answer: interact.Copy = .none; | ||
| 25 | { | ||
| 26 | t.shared.paint_mu.lock(); | ||
| 27 | defer t.shared.paint_mu.unlock(); | ||
| 28 | // The drag is this tile's own Core's now: a drag is per tile, and | ||
| 29 | // the pump that owns the link is the one whose Core holds it. | ||
| 30 | const held = core.drag.range(); | ||
| 31 | answer = core.selectionCopy(payload, held); | ||
| 32 | switch (answer) { | ||
| 33 | // `is_tty` and not the tile's claim: a tile that has released | ||
| 34 | // the terminal can still be the one whose finished drag the | ||
| 35 | // answer reaches, and the copy is still the user's. A piped | ||
| 36 | // `mux` asks its terminal for no mouse modes, so it can have no | ||
| 37 | // drag to copy in the first place. | ||
| 38 | .text => |text| if (t.shared.is_tty) | ||
| 39 | interact.writeSelectionCopy(alloc, t.shared.out_fd, text) catch {}, | ||
| 40 | .none, .too_large => {}, | ||
| 41 | } | ||
| 42 | } | ||
| 43 | // Outside the hold: `tileBanner` takes the same lock, which is not | ||
| 44 | // reentrant. The banner lands in this tile's own rect. | ||
| 45 | if (answer == .too_large) wv.tileBanner(t, "[selection too large to copy]"); | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Every live tile paints its own rect, so the sink admits a paint whenever | ||
| 49 | /// this tile has not been forgotten. `paint_mu` is held for the whole paint. | ||
| 50 | pub fn tilePaintBegin(ctx: ?*anyopaque) bool { | ||
| 51 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | ||
| 52 | if (t.gone.load(.acquire)) return false; | ||
| 53 | t.shared.paint_mu.lock(); | ||
| 54 | // Read UNDER the lock, not before it: a pump that tested the flag and | ||
| 55 | // then lost the race for `paint_mu` would paint its rect on top of the | ||
| 56 | // box the keyboard had just drawn — and `picker_stamp` suppresses the | ||
| 57 | // identical repaint that would have repaired it, so the damage sticks | ||
| 58 | // until a key changes the frame. | ||
| 59 | if (t.shared.picker_open.load(.acquire)) { | ||
| 60 | t.shared.paint_mu.unlock(); | ||
| 61 | return false; | ||
| 62 | } | ||
| 63 | return true; | ||
| 64 | } | ||
| 65 | |||
| 66 | pub fn tilePaintEnd(ctx: ?*anyopaque) void { | ||
| 67 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | ||
| 68 | defer t.shared.paint_mu.unlock(); | ||
| 69 | // Before releasing the terminal: the cursor belongs to the FOCUSED tile. | ||
| 70 | // A focused paint records where its cursor landed; an unfocused paint's | ||
| 71 | // last act is to put the cursor back there, so a redraw in tile 2 cannot | ||
| 72 | // steal the eye while the keys go to tile 1. The cursor is hidden for | ||
| 73 | // the move so the show after it does not flash it at the unfocused | ||
| 74 | // tile's final position before the CUP lands — then re-shown so the | ||
| 75 | // focused tile's cursor rests visible until its next paint. | ||
| 76 | if (t.idx == t.shared.sel) { | ||
| 77 | if (t.core) |core| t.shared.cursor = core.screenCursor(); | ||
| 78 | } else { | ||
| 79 | var cbuf: [26]u8 = undefined; | ||
| 80 | const cup = std.fmt.bufPrint(&cbuf, "\x1b[?25l\x1b[{d};{d}H\x1b[?25h", .{ t.shared.cursor.y + 1, t.shared.cursor.x + 1 }) catch return; | ||
| 81 | proto.writeAllFd(t.shared.out_fd, cup) catch {}; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | /// The one place a wall tile puts an attach on the wire. A tile the user | ||
| 86 | /// asked for — the entry tile, a chord-born tile, a local line off the | ||
| 87 | /// saved wall — claims its rect on attach, and that size is what lets the | ||
| 88 | /// daemon create the session. A view tile (wall argv, a remote saved | ||
| 89 | /// line) attaches at 0x0 so it can only JOIN, then takes its rect with the | ||
| 90 | /// resize doorbell one frame later — every tile still claims its | ||
| 91 | /// rectangle, and a redial comes back claiming what the tile claimed. | ||
| 92 | pub fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void { | ||
| 93 | // Snapshot under `paint_mu`: the keyboard may relayout (re-cut stripes, | ||
| 94 | // resize) concurrently with the pump's first attach. | ||
| 95 | const snap = blk: { | ||
| 96 | t.shared.paint_mu.lock(); | ||
| 97 | defer t.shared.paint_mu.unlock(); | ||
| 98 | break :blk .{ | ||
| 99 | .cols = t.viewCols(), | ||
| 100 | .view_rows = t.viewRows(), | ||
| 101 | }; | ||
| 102 | }; | ||
| 103 | const cols: u16 = if (t.creates) snap.cols else 0; | ||
| 104 | const rows: u16 = if (t.creates) snap.view_rows else 0; | ||
| 105 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 106 | try tr.writeFrame(.attach, proto.encodeAttachNamed( | ||
| 107 | &buf, | ||
| 108 | cols, | ||
| 109 | rows, | ||
| 110 | have_seq, | ||
| 111 | have_epoch, | ||
| 112 | proto.wireName(t.r.session), | ||
| 113 | )); | ||
| 114 | // A view tile made no size claim, so it owes its rect now: the same | ||
| 115 | // doorbell path a relayout takes (adoptSize + .resize), run on this | ||
| 116 | // pump thread which is the transport's only writer. | ||
| 117 | if (!t.creates) { | ||
| 118 | t.shared.paint_mu.lock(); | ||
| 119 | t.resize_pending = true; | ||
| 120 | t.shared.paint_mu.unlock(); | ||
| 121 | wv.ring(t); | ||
| 122 | } | ||
| 123 | // Re-armed on EVERY attach, not once per process: a redial is a fresh | ||
| 124 | // attach onto a fresh daemon-side slot, which remembers no offer. Empty | ||
| 125 | // payload, and a daemon too old to know the frame skips it. | ||
| 126 | if (t.r.agent) try tr.writeFrame(.agent_offer, ""); | ||
| 127 | } | ||
| 128 | |||
| 129 | /// What a pump owes itself after asking for its focus claim. | ||
| 130 | pub const ClaimStep = enum { | ||
| 131 | /// The claim landed, or the Core already held it: finish the pass. | ||
| 132 | done, | ||
| 133 | /// The sink refused and this tile still has the focus: try next pass. | ||
| 134 | rearmed, | ||
| 135 | /// The focus moved on while the popup was up: this arm is stale. | ||
| 136 | dropped, | ||
| 137 | }; | ||
| 138 | |||
| 139 | /// May this tile take the terminal for the arm it is holding? | ||
| 140 | fn claimAllowed(t: *Tile) bool { | ||
| 141 | // BEFORE the claim, never after. The keyboard arms `claim_pending` and | ||
| 142 | // the pump reads it a pass later, and the focus can move in between — | ||
| 143 | // a poller's tile arriving, the birth an Enter makes, all of it under | ||
| 144 | // the host picker's popup. An arm that outlives its focus and then | ||
| 145 | // SUCCEEDS puts two tiles' modes on one terminal, rests the cursor on | ||
| 146 | // the loser, and takes the focus notice with it; the outgoing tile's | ||
| 147 | // release was consumed a pass earlier, so nothing undoes any of it. | ||
| 148 | // | ||
| 149 | // Judging the claim's REFUSAL cannot cover this: the arm that matters | ||
| 150 | // is the one retried after the popup closed, which the sink admits. | ||
| 151 | t.shared.paint_mu.lock(); | ||
| 152 | defer t.shared.paint_mu.unlock(); | ||
| 153 | return t.shared.sel == t.idx; | ||
| 154 | } | ||
| 155 | |||
| 156 | /// The wall's ONLY door to `Core.claimTerminal`: the focus test, the size | ||
| 157 | /// adopt, and the claim, in that order and never apart. A caller that could | ||
| 158 | /// reach the claim around this is a caller that can mint a stale one. | ||
| 159 | pub fn claimFocus(t: *Tile, core: *interact.Core, rect: proto.Size) ClaimStep { | ||
| 160 | if (!claimAllowed(t)) return .dropped; | ||
| 161 | // A tile born before somebody resized the terminal clips its paints to | ||
| 162 | // a screen that is gone; the tile's current RECT, never the whole | ||
| 163 | // terminal, which on a wall of two would let it paint over a neighbour. | ||
| 164 | if (core.size.cols != rect.cols or core.size.rows != rect.rows) | ||
| 165 | core.adoptSize(rect); | ||
| 166 | return afterClaim(t, core.claimTerminal(), core.claim != .none, core.is_tty); | ||
| 167 | } | ||
| 168 | |||
| 169 | /// A refused focus claim, judged. | ||
| 170 | pub fn afterClaim(t: *Tile, claimed: bool, held: bool, is_tty: bool) ClaimStep { | ||
| 171 | // `held` is `Core.claim != .none`. A claim answers false for three | ||
| 172 | // reasons and only ONE of them is worth another pass: not a tty (there | ||
| 173 | // is no terminal to hold), already held (re-arming would re-take the | ||
| 174 | // focus notice every pass, forever), and the SINK refused — which is | ||
| 175 | // the host picker, whose popup admits no paint and a claim writes the | ||
| 176 | // session's modes through one. Read off the Core, never off | ||
| 177 | // `picker_open` a second time: the keyboard can clear that flag between | ||
| 178 | // the two loads, and then the refusal is dropped exactly as before. | ||
| 179 | if (claimed or held or !is_tty) return .done; | ||
| 180 | t.shared.paint_mu.lock(); | ||
| 181 | defer t.shared.paint_mu.unlock(); | ||
| 182 | // ...and only while this tile is STILL the focus. `claimAllowed` asked | ||
| 183 | // the same question before the claim; the keyboard can answer it | ||
| 184 | // differently in between, and re-arming then would mint the very stale | ||
| 185 | // arm that check exists to stop. Left CLEAR rather than cleared — the | ||
| 186 | // caller's `swap` did that — because a focus that came back inside this | ||
| 187 | // window re-armed it legitimately. | ||
| 188 | if (t.shared.sel != t.idx) return .dropped; | ||
| 189 | t.claim_pending.store(true, .release); | ||
| 190 | return .rearmed; | ||
| 191 | } | ||
| 192 | |||
| 193 | /// FOCUSED pumps only: a tile that never held the terminal has zero | ||
| 194 | /// counters, and publishing them would clobber the tile the user typed at. | ||
| 195 | fn publishStats(shared: *Shared, c: interact.PredictCounters) void { | ||
| 196 | shared.paint_mu.lock(); | ||
| 197 | defer shared.paint_mu.unlock(); | ||
| 198 | shared.stats = c; | ||
| 199 | } | ||
| 200 | |||
| 201 | /// Validated first: these bytes came out of a peer's `sessions_reply` and | ||
| 202 | /// `SessionName.of` memcpys with no bound of its own (`client.validPick`). | ||
| 203 | /// A name it refuses is a name nobody is moved to. | ||
| 204 | fn postAnswer(t: *Tile, pick: []const u8) void { | ||
| 205 | const name = client.validPick(pick) orelse return; | ||
| 206 | { | ||
| 207 | t.ans_mu.lock(); | ||
| 208 | defer t.ans_mu.unlock(); | ||
| 209 | t.ans = name; | ||
| 210 | } | ||
| 211 | t.ans_ready.store(true, .release); | ||
| 212 | wv.ringKeyboard(t.shared); | ||
| 213 | } | ||
| 214 | |||
| 215 | /// Written before `alive` clears, so no dead tile is ever seen without a | ||
| 216 | /// reason. Rings NOTHING — the keyboard's test is `!alive`. | ||
| 217 | fn endWith(t: *Tile, reason: EndReason, code: u8) void { | ||
| 218 | t.code.store(code, .release); | ||
| 219 | t.end.store(@intFromEnum(reason), .release); | ||
| 220 | } | ||
| 221 | |||
| 222 | /// Whole-mailbox chunking, so `offerKeystroke` (one-byte chunks only) counts | ||
| 223 | /// keystrokes that arrive between polls as suppressed: a wall predicts a | ||
| 224 | /// little less. Splitting would speculate against a stale replica. | ||
| 225 | pub fn takeKeys(t: *Tile, out: []u8) []u8 { | ||
| 226 | t.in_mu.lock(); | ||
| 227 | defer t.in_mu.unlock(); | ||
| 228 | const n = @min(out.len, t.in_len); | ||
| 229 | @memcpy(out[0..n], t.in[0..n]); | ||
| 230 | std.mem.copyForwards(u8, t.in[0 .. t.in_len - n], t.in[n..t.in_len]); | ||
| 231 | t.in_len -= n; | ||
| 232 | return out[0..n]; | ||
| 233 | } | ||
| 234 | |||
| 235 | fn drainWake(t: *const Tile) void { | ||
| 236 | wv.drainBell(t.wake_r); | ||
| 237 | } | ||
| 238 | |||
| 239 | fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { | ||
| 240 | var target = target_in; | ||
| 241 | var backoff_ms: u64 = 0; | ||
| 242 | // `gone` as well as `running`: a tile forgotten while it is retrying a | ||
| 243 | // dead host must stop retrying, not keep a thread and a backoff alive | ||
| 244 | // for a tile that is no longer on the wall. | ||
| 245 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | ||
| 246 | if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {} | ||
| 247 | // An ask buys ONE attempt. Every retry below is the wall's own | ||
| 248 | // idea: a `muxd start` per backoff would restart a daemon for as | ||
| 249 | // long as the tile lives, and a fallback line per backoff would | ||
| 250 | // scroll the alternate screen the tiles are painted on. | ||
| 251 | if (target == .hand) target.hand.asked = false; | ||
| 252 | backoff_ms = client.nextBackoffMs(backoff_ms); | ||
| 253 | // Sliced sleep so quit is never behind a full backoff. | ||
| 254 | var slept: u64 = 0; | ||
| 255 | while (slept < backoff_ms and t.shared.running.load(.acquire) and | ||
| 256 | !t.gone.load(.acquire)) : (slept += 50) | ||
| 257 | { | ||
| 258 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 259 | } | ||
| 260 | } | ||
| 261 | return null; | ||
| 262 | } | ||
| 263 | |||
| 264 | /// One forwarded ssh-agent channel, this end of it: the id the daemon | ||
| 265 | /// allocated, and an fd to THIS machine's agent. | ||
| 266 | /// | ||
| 267 | /// Thread-local by construction — the table lives in `pumpTile`'s frame and | ||
| 268 | /// no other thread can see it, which is why nothing here takes a lock and | ||
| 269 | /// why these helpers take the table as a slice rather than reaching for one. | ||
| 270 | pub const AgentLocal = struct { id: u32, fd: std.posix.fd_t }; | ||
| 271 | |||
| 272 | /// Fixed at `proto.agent_chans_max`, which is what the daemon opens anyway: | ||
| 273 | /// a full table costs one failed lookup, not the pump's hot path an alloc. | ||
| 274 | pub fn storeLocal(locals: []?AgentLocal, id: u32, fd: std.posix.fd_t) ?usize { | ||
| 275 | for (locals, 0..) |c, s| { | ||
| 276 | if (c != null) continue; | ||
| 277 | locals[s] = .{ .id = id, .fd = fd }; | ||
| 278 | return s; | ||
| 279 | } | ||
| 280 | return null; | ||
| 281 | } | ||
| 282 | |||
| 283 | pub fn findLocal(locals: []?AgentLocal, id: u32) ?usize { | ||
| 284 | for (locals, 0..) |c, s| if (c) |ch| { | ||
| 285 | if (ch.id == id) return s; | ||
| 286 | }; | ||
| 287 | return null; | ||
| 288 | } | ||
| 289 | |||
| 290 | /// Hang one channel up from this end and say so, because the daemon is | ||
| 291 | /// holding the far socket open waiting for bytes that are not coming. A | ||
| 292 | /// failed write is the transport itself being gone, which the caller's next | ||
| 293 | /// pass turns into a redial. | ||
| 294 | pub fn closeLocal(locals: []?AgentLocal, slot: usize, transport: *client.Transport) void { | ||
| 295 | const ch = locals[slot] orelse return; | ||
| 296 | locals[slot] = null; | ||
| 297 | std.posix.close(ch.fd); | ||
| 298 | transport.writeFrame(.agent_close, &proto.encodeAgentId(ch.id)) catch {}; | ||
| 299 | } | ||
| 300 | |||
| 301 | /// Lifted out of the pump so the `offered` gate has a seam a test can watch. | ||
| 302 | pub fn openAgentChan( | ||
| 303 | locals: []?AgentLocal, | ||
| 304 | id: u32, | ||
| 305 | offered: bool, | ||
| 306 | sock: []const u8, | ||
| 307 | ) bool { | ||
| 308 | // The OFFER is the consent, and it is per TILE: a wall where one tile | ||
| 309 | // was typed with `-A` must not hand another tile's host the keys, | ||
| 310 | // whoever asks. | ||
| 311 | if (!offered) return false; | ||
| 312 | // A live id reused. Refusing keeps the channel already on that id | ||
| 313 | // intact, which is the half of the collision that has real bytes moving | ||
| 314 | // through it. | ||
| 315 | if (findLocal(locals, id) != null) return false; | ||
| 316 | const fd = client.connectAgent(sock) orelse return false; | ||
| 317 | if (storeLocal(locals, id, fd) == null) { | ||
| 318 | std.posix.close(fd); | ||
| 319 | return false; | ||
| 320 | } | ||
| 321 | return true; | ||
| 322 | } | ||
| 323 | |||
| 324 | /// Lifted out of the pump so the length cap has a seam a test can reach. | ||
| 325 | pub fn deliverAgentData( | ||
| 326 | locals: []?AgentLocal, | ||
| 327 | payload: []const u8, | ||
| 328 | transport: *client.Transport, | ||
| 329 | ) bool { | ||
| 330 | const id = proto.decodeAgentId(payload) catch return false; | ||
| 331 | const s = findLocal(locals, id) orelse return true; | ||
| 332 | if (proto.agentDataOversize(payload)) { | ||
| 333 | closeLocal(locals, s, transport); | ||
| 334 | return true; | ||
| 335 | } | ||
| 336 | // Bytes onto the fd in order, never parsed and never reassembled: the | ||
| 337 | // daemon reads the far end in `agent_data_max` bites, so one agent | ||
| 338 | // message can arrive as several frames and several messages as one. The | ||
| 339 | // agent protocol delimits itself over a stream, and this end is a pipe. | ||
| 340 | proto.writeAllFd(locals[s].?.fd, payload[proto.agent_id_len..]) catch | ||
| 341 | closeLocal(locals, s, transport); | ||
| 342 | return true; | ||
| 343 | } | ||
| 344 | |||
| 345 | /// Redial only: these belonged to the dead connection, whose `dropClient` | ||
| 346 | /// already reaped the server side. | ||
| 347 | pub fn dropLocals(locals: []?AgentLocal) void { | ||
| 348 | for (locals, 0..) |c, s| if (c) |ch| { | ||
| 349 | locals[s] = null; | ||
| 350 | std.posix.close(ch.fd); | ||
| 351 | }; | ||
| 352 | } | ||
| 353 | |||
| 354 | /// The transport died, or the dial has to be redone: rebuild it on the CLI's | ||
| 355 | /// backoff and re-attach quoting what this tile holds. False means the pump | ||
| 356 | /// is finished — the wall quit, or the tile was forgotten while retrying. | ||
| 357 | /// | ||
| 358 | /// One function for what were four copies of five steps, which had begun to | ||
| 359 | /// differ: only some dropped a scroll view a resync was about to invalidate. | ||
| 360 | fn redial( | ||
| 361 | t: *Tile, | ||
| 362 | alloc: std.mem.Allocator, | ||
| 363 | core: *interact.Core, | ||
| 364 | transport: *client.Transport, | ||
| 365 | target: client.Target, | ||
| 366 | state: *State, | ||
| 367 | /// This tile's agent channels, which the dying connection owned. Dropped | ||
| 368 | /// HERE, and here only, for the reason this function exists at all: | ||
| 369 | /// every call site that had to remember would be one more chance to | ||
| 370 | /// strand a channel on a connection that cannot close it. | ||
| 371 | agents: []?AgentLocal, | ||
| 372 | ) bool { | ||
| 373 | // A close that follows our own detach is the daemon saying goodbye back, | ||
| 374 | // not a tear to heal: the pump wrote the .detach frame and set | ||
| 375 | // `detach_ack` before the save's file I/O window let readFrame see the | ||
| 376 | // daemon's side. Redialing here would re-attach a slot the user just | ||
| 377 | // released. | ||
| 378 | if (t.detach_ack.load(.acquire)) return false; | ||
| 379 | // Before the cold-dial refusal below, which returns without reconnecting | ||
| 380 | // — a pump that ends still owes these fds. | ||
| 381 | dropLocals(agents); | ||
| 382 | // The plain client's rule, kept for the tile a `mux TARGET` is: a | ||
| 383 | // transport that died before a single frame of state carried no | ||
| 384 | // session, so there is nothing to resume and retrying a bad host or a | ||
| 385 | // typo'd `--via` only makes an unkillable client. `session_epoch` is | ||
| 386 | // the right signal because it is set from the first snapshot and never | ||
| 387 | // reset. Wall tiles do the opposite deliberately — they retry forever, | ||
| 388 | // because a wall is a thing you leave up while a box reboots. | ||
| 389 | if (!t.retry_cold and core.rep.session_epoch == 0) { | ||
| 390 | endWith(t, .lost, 1); | ||
| 391 | return false; | ||
| 392 | } | ||
| 393 | transport.close(); | ||
| 394 | state.* = .reconnecting; | ||
| 395 | wv.paintLabel(t, state.*); | ||
| 396 | // ...and the same news for a one-tile wall, which has no label | ||
| 397 | // bar on screen to read it off. The corner banner is the plain client's | ||
| 398 | // own, said before the dial rather than inside it for its reason: it is | ||
| 399 | // a PAINT on the session's screen, and the Core is what paints. `banner` | ||
| 400 | // is gated on the sink, so a stripe's re-dial writes nothing here. | ||
| 401 | core.banner("[reconnecting]"); | ||
| 402 | // The resync's own paint is what will arrive, so a history page held | ||
| 403 | // here would be silently replaced a moment later. | ||
| 404 | core.dropScrollView(); | ||
| 405 | transport.* = dial(alloc, t, target) orelse return false; | ||
| 406 | // Clears `state_since_attach` (so the next exit_status is read as a | ||
| 407 | // refusal again) and drops speculation made against a connection that | ||
| 408 | // no longer exists — the Core's own highlight with it. | ||
| 409 | core.reattached(); | ||
| 410 | const have = core.rep.attachArgs(); | ||
| 411 | sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; | ||
| 412 | return true; | ||
| 413 | } | ||
| 414 | |||
| 415 | /// One pump pass's geometry, plus the `.resize` that pass owes the daemon. | ||
| 416 | const Pass = struct { | ||
| 417 | top: u16, | ||
| 418 | left: u16, | ||
| 419 | rows: u16, | ||
| 420 | cols: u16, | ||
| 421 | label_rows: u16, | ||
| 422 | term_cols: u16, | ||
| 423 | term_rows: u16, | ||
| 424 | // A relayout re-cut this tile: the pump owes the daemon THIS pass's | ||
| 425 | // content size. | ||
| 426 | resize: bool, | ||
| 427 | }; | ||
| 428 | |||
| 429 | pub fn takePass(t: *Tile) Pass { | ||
| 430 | t.shared.paint_mu.lock(); | ||
| 431 | defer t.shared.paint_mu.unlock(); | ||
| 432 | // The flag comes out of the SAME hold as the rect it describes. Read a | ||
| 433 | // pass apart from its doorbell and a relayout landing between the two | ||
| 434 | // is swallowed: the pump sends the rect it snapshotted first, clears | ||
| 435 | // the flag, and the daemon keeps a grid the tile has already stopped | ||
| 436 | // painting at — nothing re-sends, because the claim path does not. | ||
| 437 | const owed = t.resize_pending; | ||
| 438 | t.resize_pending = false; | ||
| 439 | return .{ | ||
| 440 | .top = t.rect.top, | ||
| 441 | .left = t.rect.left, | ||
| 442 | .rows = t.rect.rows, | ||
| 443 | .cols = t.rect.cols, | ||
| 444 | .label_rows = t.shared.label_rows, | ||
| 445 | .term_cols = t.shared.size.cols, | ||
| 446 | .term_rows = t.shared.size.rows, | ||
| 447 | .resize = owed, | ||
| 448 | }; | ||
| 449 | } | ||
| 450 | |||
| 451 | /// Absolute rows count from the oldest row the daemon keeps, and a resync | ||
| 452 | /// renames that space: a kept highlight inverts rows nobody selected. | ||
| 453 | /// One tile's life: dial → attach → replay frames into its Core → repaint | ||
| 454 | /// at its rect. Runs on its own thread (see module header). On transport | ||
| 455 | /// death: reconnect on the CLI's backoff schedule, quoting | ||
| 456 | /// have_seq/have_epoch, and the snapshot-vs-delta resolution does the rest. | ||
| 457 | /// Ends when `running` clears, the session exits, or the attach is refused. | ||
| 458 | /// | ||
| 459 | /// This thread is also the tile's WRITER: every frame the keyboard doorbells | ||
| 460 | /// for — a resize, a detach, a focus claim or release — goes out from here, | ||
| 461 | /// because a Transport has exactly one owning thread (module header). | ||
| 462 | pub fn pumpTile(t: *Tile) void { | ||
| 463 | // FIRST defer, so it runs LAST: every `return` below — a refused | ||
| 464 | // attach, an exited session, a dial the quit interrupted, a Core that | ||
| 465 | // would not initialise — is this tile going quiet for good, and the | ||
| 466 | // keyboard needs to know which tiles it has to paint for. Declared | ||
| 467 | // before the allocator's own defer so nothing can end this thread | ||
| 468 | // without it running. | ||
| 469 | // The bell goes with the store and after it, `endWith`'s reason: the | ||
| 470 | // keyboard's test is `!alive`, so a ring that precedes the store is a | ||
| 471 | // wake-up that finds nothing. | ||
| 472 | defer { | ||
| 473 | t.alive.store(false, .release); | ||
| 474 | wv.ringKeyboard(t.shared); | ||
| 475 | // LAST, after the bell above has finished reading `t.shared`: this | ||
| 476 | // is what hands the slot to `birthTile`, and nothing may touch the | ||
| 477 | // tile after it. | ||
| 478 | t.pump_done.store(true, .release); | ||
| 479 | } | ||
| 480 | |||
| 481 | // Per-thread allocator: nothing allocated here crosses threads except | ||
| 482 | // painted bytes, which go out under the paint mutex. | ||
| 483 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 484 | defer _ = gpa.deinit(); | ||
| 485 | const alloc = gpa.allocator(); | ||
| 486 | |||
| 487 | // Whether this tile may narrate and may start a daemon travels IN its | ||
| 488 | // target, set once by whoever made the tile: a picker Enter is an ask, | ||
| 489 | // a poll's list is not. This is the pump's own copy — `t.r.target` is | ||
| 490 | // read by the KEYBOARD thread under `paint_mu` for chord births and is | ||
| 491 | // never written from here — and it is spent below, once. | ||
| 492 | var target = t.r.target; | ||
| 493 | |||
| 494 | // ONE Core per tile, from birth. It owns this tile's replica, its | ||
| 495 | // prediction overlay and its drag for the tile's whole life: the tile | ||
| 496 | // paints from that replica at its own offset, and what decides whether | ||
| 497 | // a keystroke may be speculated at all is the pty's line discipline, | ||
| 498 | // which arrives in `.pty_mode` frames long before the tile is focused. | ||
| 499 | // | ||
| 500 | // `in_fd` is the wall's stdin and this Core never reads it — the | ||
| 501 | // keyboard thread does, on the far side of the mailbox. It is passed | ||
| 502 | // because it is the truth about whether there is a terminal here at all | ||
| 503 | // (`is_tty`), which the mouse split and the side channels are gated on. | ||
| 504 | var core = interact.Core.initSized( | ||
| 505 | alloc, | ||
| 506 | std.posix.STDIN_FILENO, | ||
| 507 | t.shared.out_fd, | ||
| 508 | t.shared.size, | ||
| 509 | ) catch return; | ||
| 510 | defer core.deinit(); | ||
| 511 | // Whether this tile has EVER held the terminal, which is the only | ||
| 512 | // question the publish below is gated on: a tile that was never focused | ||
| 513 | // has nothing to say about prediction and must not overwrite what the | ||
| 514 | // tile that was does. | ||
| 515 | var ever_focused = false; | ||
| 516 | // Focus, as this pump knows it. Not `core.claim`: a claim needs a | ||
| 517 | // terminal, and `mux` on a pipe has none, yet its one tile is focused | ||
| 518 | // and its predictions still expire and still get counted. | ||
| 519 | var focused = false; | ||
| 520 | // The last word on this tile's prediction, whichever way the pump ends | ||
| 521 | // — an exit_status arrives and RETURNS, so the per-pass publish inside | ||
| 522 | // the loop is always one pass stale by then. | ||
| 523 | defer if (ever_focused) publishStats(t.shared, core.overlay.counters); | ||
| 524 | // Where this Core's paints land: this tile's rect. The sink admits a | ||
| 525 | // paint whenever the tile has not been forgotten; see `tilePaintBegin`. | ||
| 526 | core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd }; | ||
| 527 | // The paint-end hook reads the core's screen cursor; only this pump | ||
| 528 | // thread dereferences it, and the core outlives the pump. | ||
| 529 | t.core = &core; | ||
| 530 | // This thread does not own the exit and cannot print the stats line — | ||
| 531 | // see `Shared.stats`. | ||
| 532 | core.owns_stats = false; | ||
| 533 | |||
| 534 | wv.paintLabel(t, .connecting); | ||
| 535 | // The ENTRY tile arrives with its link already up — dialled on the main | ||
| 536 | // thread, where the tty was, so ssh could prompt. `adopt` is what moves | ||
| 537 | // its QUIC out-queue onto this thread's allocator; see there. | ||
| 538 | var transport = if (t.pre) |pre| blk: { | ||
| 539 | var tr = pre; | ||
| 540 | t.pre = null; | ||
| 541 | tr.adopt(alloc); | ||
| 542 | break :blk tr; | ||
| 543 | } else dial(alloc, t, target) orelse return; | ||
| 544 | defer transport.close(); | ||
| 545 | // The ask is SPENT, on whichever of the two branches above got the | ||
| 546 | // link: the entry tile's dial happened on the main thread, a picker | ||
| 547 | // birth's just happened here. Every `redial` below is handed this | ||
| 548 | // copy, so a reconnect can neither start a daemon — `muxd stop` typed | ||
| 549 | // on that box would otherwise be undone by the next backoff, the | ||
| 550 | // poll's bug moved onto a tile — nor print the fallback line onto the | ||
| 551 | // alternate screen the tiles are painted on. | ||
| 552 | if (target == .hand) target.hand.asked = false; | ||
| 553 | // The entry tile's attach carries its rect, so the session is sized to | ||
| 554 | // the terminal the tile claims and no second resize follows — | ||
| 555 | // re-asserting a size the daemon just heard costs one more snapshot on | ||
| 556 | // every `mux`, which is exactly the round trip the convergence must not | ||
| 557 | // add. A view tile attaches at 0x0 (join-only) and `sendAttach` doorbells | ||
| 558 | // its rect behind the attach; that path is inside `sendAttach`. | ||
| 559 | sendAttach(t, &transport, 0, 0) catch { | ||
| 560 | endWith(t, .lost, 1); | ||
| 561 | return; | ||
| 562 | }; | ||
| 563 | // One question at a time, with the deadline that makes a daemon too old | ||
| 564 | // to have heard it (`sessions_req` is 0x0c) say so instead of swallowing | ||
| 565 | // every chord for the rest of the session. `client.PendingSwitch` | ||
| 566 | // verbatim — the client asked the same question and this is the same | ||
| 567 | // answer, moved to the thread that owns the link. | ||
| 568 | var pending: client.PendingSwitch = .{}; | ||
| 569 | // The ssh-agent channels this tile is serving, one open fd each to this | ||
| 570 | // machine's agent. Everything about them is thread-local: this table, | ||
| 571 | // the fds in it, and the frames that move them all live on this pump. | ||
| 572 | var agent_locals: [proto.agent_chans_max]?AgentLocal = @splat(null); | ||
| 573 | // Every `return` below is this tile going quiet with channels possibly | ||
| 574 | // still open, and the daemon's side of them dies with the transport the | ||
| 575 | // defer above closes. | ||
| 576 | defer dropLocals(&agent_locals); | ||
| 577 | |||
| 578 | var state: State = .connecting; | ||
| 579 | // What this tile's paint is worth: while it matches the wall's | ||
| 580 | // generation the terminal still holds what this thread drew. | ||
| 581 | var painted_gen = t.shared.repaint_gen.load(.acquire); | ||
| 582 | // `gone` ends this thread exactly as `running` does — the defers close | ||
| 583 | // the transport, which frees the daemon slot and NOTHING else. The | ||
| 584 | // session goes on running: "remove is detach". | ||
| 585 | outer: while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | ||
| 586 | // The link and the doorbell, then one fd per live agent channel — | ||
| 587 | // joined into the pump's own poll rather than given a thread each, | ||
| 588 | // because a Transport has exactly one owning thread and these bytes | ||
| 589 | // leave through it. `at` remembers which table slot each of those | ||
| 590 | // trailing fds came from, so a readable one can be traced back to | ||
| 591 | // its channel without a second search. | ||
| 592 | var fdbuf: [2 + agent_locals.len]std.posix.pollfd = undefined; | ||
| 593 | fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 594 | fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 595 | var nfds: usize = 2; | ||
| 596 | var at: [agent_locals.len]usize = undefined; | ||
| 597 | for (agent_locals, 0..) |c, s| if (c) |ch| { | ||
| 598 | at[nfds - 2] = s; | ||
| 599 | fdbuf[nfds] = .{ .fd = ch.fd, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 600 | nfds += 1; | ||
| 601 | }; | ||
| 602 | _ = std.posix.poll(fdbuf[0..nfds], transport.timeoutMs(100)) catch return; | ||
| 603 | transport.service(); | ||
| 604 | if (fdbuf[1].revents != 0) drainWake(t); | ||
| 605 | |||
| 606 | // The paint offset for this pass: a relayout may have re-cut this | ||
| 607 | // tile's rect, and every paint the pass drives through the Core | ||
| 608 | // has to land in the rect the tile currently owns. Snapshot under | ||
| 609 | // `paint_mu` — the keyboard writes `rect` and `label_rows` under | ||
| 610 | // it — and use the snapshot for the whole pass. | ||
| 611 | const snap = takePass(t); | ||
| 612 | core.row_off = snap.top + snap.label_rows; | ||
| 613 | core.col_off = snap.left; | ||
| 614 | core.owns_screen = snap.top == 0 and snap.left == 0 and | ||
| 615 | snap.label_rows == 0 and snap.cols == snap.term_cols and | ||
| 616 | snap.rows == snap.term_rows; | ||
| 617 | const snap_view_rows: u16 = snap.rows -| snap.label_rows; | ||
| 618 | const snap_view_cols: u16 = snap.cols; | ||
| 619 | |||
| 620 | // FOCUS CLAIM. The keyboard moved the focus onto this tile; the | ||
| 621 | // session's mouse modes and side channels go on here, on the thread | ||
| 622 | // that owns the transport and the Core. The resize the claim used | ||
| 623 | // to send is gone — the attach already carried the rect, and a | ||
| 624 | // relayout doorbells `resize_pending` for any later change. | ||
| 625 | if (t.claim_pending.swap(false, .acq_rel)) { | ||
| 626 | // A refused claim is re-armed rather than lost: the host | ||
| 627 | // picker's popup admits no paint, and a claim writes the | ||
| 628 | // session's modes through the paint sink, so a claim dropped | ||
| 629 | // under it leaves this pump focused holding no terminal — no | ||
| 630 | // mouse modes, no side channels, the notice below eaten — until | ||
| 631 | // the user moves the focus away and back. The picker's close | ||
| 632 | // rings this pump, so the retry is a keystroke away. | ||
| 633 | // | ||
| 634 | // The rest of the PASS still runs whatever comes back: | ||
| 635 | // `takePass` has already cleared `resize_pending`, so skipping | ||
| 636 | // out here would drop a relayout this tile owes the daemon. | ||
| 637 | const step = claimFocus(t, &core, .{ .cols = snap_view_cols, .rows = snap_view_rows }); | ||
| 638 | // A stale arm — the keyboard moved the focus between arming | ||
| 639 | // this and this pass — takes nothing: no claim, no modes, no | ||
| 640 | // notice, and these predictions are not the focus's to publish | ||
| 641 | // either. The tile that DOES hold the focus was armed by the | ||
| 642 | // same `setFocus` that took it from this one. | ||
| 643 | focused = step != .dropped; | ||
| 644 | ever_focused = ever_focused or focused; | ||
| 645 | if (step == .done) { | ||
| 646 | // A sentence the keyboard left for whoever owns the terminal | ||
| 647 | // next — a refused `Ctrl-\ c`, so far. Painted here because | ||
| 648 | // a banner belongs to a Core and this is the Core that has | ||
| 649 | // just taken the screen; painted AFTER the repaint below | ||
| 650 | // would be wrong, so it is taken now and shown once the grid | ||
| 651 | // is up. | ||
| 652 | var notice_buf: [96]u8 = undefined; | ||
| 653 | const notice = wv.takeNotice(t.shared, ¬ice_buf); | ||
| 654 | // The replica has been hot the whole time, so a claim paints | ||
| 655 | // from it NOW rather than waiting for the daemon's answering | ||
| 656 | // snapshot. That is the headline: moving the focus costs a | ||
| 657 | // local repaint, never a wire frame. | ||
| 658 | // ...but only when there IS one. A tile focused before its | ||
| 659 | // first snapshot — the entry tile, on every `mux` — would | ||
| 660 | // otherwise paint a blank grid over the terminal before the | ||
| 661 | // session has said anything, which is a screen the plain | ||
| 662 | // client never drew and bytes a capture never held. | ||
| 663 | if (core.rep.session_epoch != 0) core.repaint() catch {}; | ||
| 664 | if (notice.len > 0) core.banner(notice); | ||
| 665 | } | ||
| 666 | } | ||
| 667 | // FOCUS RELEASE. The keyboard moved the focus off this tile and | ||
| 668 | // wrote the session's release itself, under `paint_mu`, before | ||
| 669 | // doorbelling — so the handover is ordered and this pump owes only | ||
| 670 | // its own state. `.already_written` is that discipline. | ||
| 671 | if (t.release_pending.swap(false, .acq_rel)) { | ||
| 672 | focused = false; | ||
| 673 | core.releaseTerminal(.already_written); | ||
| 674 | // The speculation described a screen this terminal no longer | ||
| 675 | // shows. | ||
| 676 | core.overlay.flush(); | ||
| 677 | } | ||
| 678 | |||
| 679 | // RELAYOUT DOORBELL: this tile's rect changed. The pump is the | ||
| 680 | // transport's only writer, so relayout sets the flag and the pump | ||
| 681 | // sends the `.resize` from here. | ||
| 682 | if (snap.resize) { | ||
| 683 | core.overlay.setResizePending(true); | ||
| 684 | // The Core clips every paint to its size; a resize the daemon | ||
| 685 | // hears but the Core does not leaves the bottom of the new | ||
| 686 | // grid cut off on screen forever. | ||
| 687 | core.adoptSize(.{ .cols = snap_view_cols, .rows = snap_view_rows }); | ||
| 688 | transport.writeFrame( | ||
| 689 | .resize, | ||
| 690 | &proto.encodeSize(snap_view_cols, snap_view_rows), | ||
| 691 | ) catch { | ||
| 692 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 693 | continue :outer; | ||
| 694 | }; | ||
| 695 | } | ||
| 696 | |||
| 697 | // `Ctrl-\ d`: hand the daemon its slot back before the process | ||
| 698 | // dies, rather than leaving it for the socket's death to be | ||
| 699 | // noticed. Only this thread may write the frame, so the keyboard | ||
| 700 | // asked and is waiting for the ack. | ||
| 701 | if (t.detach_req.swap(false, .acq_rel)) { | ||
| 702 | transport.writeFrame(.detach, "") catch {}; | ||
| 703 | t.detach_ack.store(true, .release); | ||
| 704 | wv.ringKeyboard(t.shared); | ||
| 705 | } | ||
| 706 | |||
| 707 | // A focus chord's question, put on the wire from the thread that | ||
| 708 | // owns the link. The ANSWER is read out of the `.sessions_reply` | ||
| 709 | // arm below and handed to the keyboard, which is the only thread | ||
| 710 | // that may move the focus or grow the wall. | ||
| 711 | const asked: client.SwitchIntent = @enumFromInt(t.ask.swap(0, .acq_rel)); | ||
| 712 | if (asked != .none) { | ||
| 713 | // The deadline starts HERE, when the question goes on the wire, | ||
| 714 | // not when the key was typed: it exists to catch a daemon too | ||
| 715 | // old to have heard `sessions_req`, and the time a chord spent | ||
| 716 | // in the mailbox is not that daemon's silence. | ||
| 717 | pending.arm(asked, std.time.milliTimestamp()); | ||
| 718 | var end_buf: [proto.end_req_max_len]u8 = undefined; | ||
| 719 | const sent = switch (asked) { | ||
| 720 | // The FORCE is the second press, not a second frame: the | ||
| 721 | // daemon refused the first and this says the user meant it. | ||
| 722 | .end, .end_force => transport.writeFrame(.end_req, proto.encodeEndReq( | ||
| 723 | &end_buf, | ||
| 724 | asked == .end_force, | ||
| 725 | proto.wireName(t.r.session), | ||
| 726 | )), | ||
| 727 | else => transport.writeFrame(.sessions_req, ""), | ||
| 728 | }; | ||
| 729 | sent catch { | ||
| 730 | pending.clear(); | ||
| 731 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 732 | continue :outer; | ||
| 733 | }; | ||
| 734 | } | ||
| 735 | |||
| 736 | // A chord that was never answered. Said with the banner rather than | ||
| 737 | // stderr for the client's reason: the terminal is in raw mode on | ||
| 738 | // the alternate screen and a print there lands mid-grid. Reachable | ||
| 739 | // at all because the poll below is capped at 100ms. | ||
| 740 | // | ||
| 741 | // Read before `expired` spends it: the sentence names the verb the | ||
| 742 | // daemon did not know, and "no session list" over an `x` sends the | ||
| 743 | // user looking at the wrong end of the wire. | ||
| 744 | const waiting = pending.intent; | ||
| 745 | if (pending.expired(std.time.milliTimestamp())) | ||
| 746 | core.banner(switch (waiting) { | ||
| 747 | .end, .end_force => "[daemon too old to end a session]", | ||
| 748 | else => "[no session list: upgrade muxd]", | ||
| 749 | }); | ||
| 750 | |||
| 751 | // FRAMES BEFORE KEYS, and that order is load-bearing rather than | ||
| 752 | // arbitrary. What the session has already said must be known before | ||
| 753 | // what the user is saying is interpreted, because the interpreting | ||
| 754 | // depends on it: `Core.forward` splits a wheel notch by whether the | ||
| 755 | // session's application asked for the mouse, and that fact arrives | ||
| 756 | // in a `term_modes` frame. A pass that holds both a readable frame | ||
| 757 | // and a mailbox of keystrokes and drains the mailbox first judges | ||
| 758 | // the keystrokes against a session it has not finished listening | ||
| 759 | // to — the plain client's loop read frames first, and an | ||
| 760 | // application holding the mouse lost its first wheel notch to this | ||
| 761 | // client's scrollback the moment the order was reversed. Found by | ||
| 762 | // the suite, not by reading. | ||
| 763 | // | ||
| 764 | // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames | ||
| 765 | // can arrive from the stream layer with the socket never going | ||
| 766 | // readable. | ||
| 767 | if (fdbuf[0].revents != 0 or transport.link == .quic) frames: { | ||
| 768 | while (true) { | ||
| 769 | const incoming = transport.readFrame(alloc) catch return; | ||
| 770 | const frame = switch (incoming) { | ||
| 771 | .frame => |f| f, | ||
| 772 | .incomplete => break :frames, | ||
| 773 | .closed => { | ||
| 774 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 775 | continue :outer; | ||
| 776 | }, | ||
| 777 | }; | ||
| 778 | defer frame.deinit(alloc); | ||
| 779 | // Everything the frame means to the replica and the screen, | ||
| 780 | // in the one place that switch lives. The Core paints at | ||
| 781 | // this tile's offset through the sink, and writes side | ||
| 782 | // channels only while it holds the claim — so a tile that | ||
| 783 | // is not focused still keeps its replica hot and its grid | ||
| 784 | // on its rect, but its title and mouse modes reach no | ||
| 785 | // terminal until the focus comes to it. | ||
| 786 | // | ||
| 787 | // The one exception is deliberate and is `.pty_mode`: the | ||
| 788 | // Core feeds the overlay's mode whatever the claim, because | ||
| 789 | // the pty's line discipline is the entire gate on | ||
| 790 | // speculation — a password prompt must never be predicted — | ||
| 791 | // and a claim has to start from the truth rather than | ||
| 792 | // from `.never` and a round trip. | ||
| 793 | const routed = core.frame(frame.type, frame.payload) catch break :frames; | ||
| 794 | switch (routed) { | ||
| 795 | .skip, .handled => {}, | ||
| 796 | .state => { | ||
| 797 | if (state != .up) { | ||
| 798 | state = .up; | ||
| 799 | t.ever_up.store(true, .release); | ||
| 800 | wv.paintLabel(t, state); | ||
| 801 | } | ||
| 802 | }, | ||
| 803 | // The replica is suspect, not the transport: re-attach | ||
| 804 | // quoting (0,0) explicitly — a quoted seq would invite | ||
| 805 | // the delta that cannot fix us. | ||
| 806 | .resync => { | ||
| 807 | core.rep.state_since_attach = false; | ||
| 808 | // A resync renames the absolute row space, so a | ||
| 809 | // held drag now names rows nobody selected — and | ||
| 810 | // on an EPOCH change `sel_range` still matches it, | ||
| 811 | // so an in-flight reply would copy the new session's | ||
| 812 | // text. `redial` drops it for this reason; this path | ||
| 813 | // re-attaches without going through it. | ||
| 814 | core.drag.clear(); | ||
| 815 | sendAttach(t, &transport, 0, 0) catch return; | ||
| 816 | }, | ||
| 817 | .not_mine => switch (frame.type) { | ||
| 818 | .exit_status => { | ||
| 819 | // Before any replay frame this is the refusal | ||
| 820 | // path; after, the session really ended. | ||
| 821 | const landed = core.rep.state_since_attach; | ||
| 822 | state = if (landed) .exited else .refused; | ||
| 823 | wv.paintLabel(t, state); | ||
| 824 | endWith( | ||
| 825 | t, | ||
| 826 | if (landed) .exited else .refused, | ||
| 827 | if (landed and frame.payload.len >= 1) frame.payload[0] else 1, | ||
| 828 | ); | ||
| 829 | return; | ||
| 830 | }, | ||
| 831 | .taken_over => { | ||
| 832 | // Unsent by this daemon (wire-compat only), but | ||
| 833 | // the plain client had an answer for it and the | ||
| 834 | // converged one keeps it: somebody else took the | ||
| 835 | // session, so this tile is finished rather than | ||
| 836 | // reconnecting into a fight over the grid. | ||
| 837 | state = .exited; | ||
| 838 | wv.paintLabel(t, state); | ||
| 839 | endWith(t, .taken, 0); | ||
| 840 | return; | ||
| 841 | }, | ||
| 842 | .sessions_reply => { | ||
| 843 | // Gated on the intent this tile's own chord | ||
| 844 | // armed: only a question we asked may move the | ||
| 845 | // focus, so an unasked-for reply is ignored. The | ||
| 846 | // intent is SPENT here whichever name it picks | ||
| 847 | // — one question, one answer. | ||
| 848 | var name_buf: [proto.session_name_max]u8 = undefined; | ||
| 849 | // A list is only ever asked for to NAME a new | ||
| 850 | // session: `n`/`p` walk the wall's own tiles and | ||
| 851 | // ask nothing, and the end verbs are answered by | ||
| 852 | // `end_reply`. Anything else here is a reply to | ||
| 853 | // a question this tile did not put. | ||
| 854 | const pick: ?[]const u8 = switch (pending.take()) { | ||
| 855 | .new => client.nextFreeName(&name_buf, frame.payload), | ||
| 856 | else => null, | ||
| 857 | }; | ||
| 858 | if (pick) |p| postAnswer(t, p); | ||
| 859 | }, | ||
| 860 | .end_reply => { | ||
| 861 | const r = proto.parseEndReply(frame.payload) orelse break :frames; | ||
| 862 | _ = pending.take(); | ||
| 863 | wv.onEndReply(t, r, std.time.milliTimestamp()); | ||
| 864 | // An ACCEPTED end says nothing: the hangup's | ||
| 865 | // `exit_status` is on its way and the `.exited` | ||
| 866 | // path narrates it, so a banner here would be | ||
| 867 | // the client congratulating itself ahead of the | ||
| 868 | // daemon. Not a `break` either — that exit | ||
| 869 | // status is very likely the next frame in this | ||
| 870 | // same pass, and leaving it for the next poll | ||
| 871 | // is 100ms of a tile that is already gone. | ||
| 872 | if (!r.accepted) { | ||
| 873 | var b: [96]u8 = undefined; | ||
| 874 | core.banner(if (r.others == 0) | ||
| 875 | endRefusal(r.reason) | ||
| 876 | else | ||
| 877 | std.fmt.bufPrint(&b, "[{d} other{s} attached - x again to end]", .{ | ||
| 878 | r.others, | ||
| 879 | if (r.others == 1) "" else "s", | ||
| 880 | }) catch "[others attached - x again to end]"); | ||
| 881 | } | ||
| 882 | }, | ||
| 883 | .selection_reply => copySelection(t, alloc, &core, frame.payload), | ||
| 884 | .agent_open => { | ||
| 885 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | ||
| 886 | // Every refusal is the same answer on the wire — | ||
| 887 | // a channel that closes without a byte, which the | ||
| 888 | // far side's ssh reads as "no agent" and gives up | ||
| 889 | // on rather than hanging. Only one of the four | ||
| 890 | // reasons is ordinary — no agent on this machine; | ||
| 891 | // the rest are a daemon asking for something it | ||
| 892 | // should not. | ||
| 893 | const opened = openAgentChan( | ||
| 894 | &agent_locals, | ||
| 895 | id, | ||
| 896 | t.r.agent, | ||
| 897 | std.posix.getenv(proto.agent_sock_env) orelse "", | ||
| 898 | ); | ||
| 899 | if (!opened) | ||
| 900 | transport.writeFrame( | ||
| 901 | .agent_close, | ||
| 902 | &proto.encodeAgentId(id), | ||
| 903 | ) catch {}; | ||
| 904 | }, | ||
| 905 | .agent_data => if (!deliverAgentData( | ||
| 906 | &agent_locals, | ||
| 907 | frame.payload, | ||
| 908 | &transport, | ||
| 909 | )) break :frames, | ||
| 910 | .agent_close => { | ||
| 911 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | ||
| 912 | // Silent, mirroring the daemon: it has already | ||
| 913 | // retired this id, so an `agent_close` back would | ||
| 914 | // be an echo it has to learn to ignore. | ||
| 915 | if (findLocal(&agent_locals, id)) |s| { | ||
| 916 | const ch = agent_locals[s].?; | ||
| 917 | agent_locals[s] = null; | ||
| 918 | std.posix.close(ch.fd); | ||
| 919 | } | ||
| 920 | }, | ||
| 921 | // MsgType is an open enum, so the compiler still | ||
| 922 | // wants an arm for everything `.not_mine` cannot be. | ||
| 923 | else => {}, | ||
| 924 | }, | ||
| 925 | } | ||
| 926 | // Only the socket link guarantees one readable event is | ||
| 927 | // one frame; QUIC may have buffered more. | ||
| 928 | // | ||
| 929 | // But "one event, one frame" is not "one pass, one frame". | ||
| 930 | // A resync is a BURST — snapshot, pty mode, title, terminal | ||
| 931 | // modes — and stopping after the first leaves the rest to be | ||
| 932 | // read a pass at a time, with the user's keystrokes | ||
| 933 | // interleaved between them. `Core.forward` decides what a | ||
| 934 | // wheel notch MEANS from the session's terminal modes, so a | ||
| 935 | // notch judged before the `term_modes` at the END of that | ||
| 936 | // burst is stolen for this client's scrollback instead of | ||
| 937 | // reaching the application that asked for the mouse. A | ||
| 938 | // single-threaded client hid the hazard by being busy | ||
| 939 | // painting the snapshot when the notch arrived; a keyboard | ||
| 940 | // on its own thread notices it immediately, and the suite | ||
| 941 | // caught it two runs out of three. | ||
| 942 | // | ||
| 943 | // So: drain what has already ARRIVED. A zero timeout waits | ||
| 944 | // for nothing, which is what keeps this a drain and not a | ||
| 945 | // second blocking read. | ||
| 946 | // | ||
| 947 | // It cannot starve the rest of the pass, and two measured | ||
| 948 | // facts are why. The daemon coalesces its deltas, so a | ||
| 949 | // loud session hands over a bounded burst and `poll(0)` | ||
| 950 | // finds the socket empty within it rather than a stream | ||
| 951 | // that refills as fast as it is read. And the keyboard is | ||
| 952 | // a different thread with its own 400ms bound on the one | ||
| 953 | // thing it ever waits for a pump to do (`awaitDetach`), so | ||
| 954 | // even a pump that did stall here could not hold the | ||
| 955 | // terminal hostage — which is the failure this would | ||
| 956 | // otherwise have to be argued safe against. | ||
| 957 | if (transport.link != .quic) { | ||
| 958 | var more = [_]std.posix.pollfd{ | ||
| 959 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 960 | }; | ||
| 961 | const ready = std.posix.poll(&more, 0) catch 0; | ||
| 962 | if (ready == 0 or more[0].revents == 0) break :frames; | ||
| 963 | } | ||
| 964 | } | ||
| 965 | } | ||
| 966 | |||
| 967 | // The other direction: what this machine's agent answered, back out | ||
| 968 | // as `agent_data`. AFTER the frame drain, deliberately — the table | ||
| 969 | // this walks is then the one the daemon's latest word left behind, | ||
| 970 | // so a channel it has just closed is already gone rather than read | ||
| 971 | // once more on its way out. | ||
| 972 | for (2..nfds) |i| { | ||
| 973 | if (fdbuf[i].revents == 0) continue; | ||
| 974 | const s = at[i - 2]; | ||
| 975 | const ch = agent_locals[s] orelse continue; | ||
| 976 | // The id is written into the head of the very buffer the read | ||
| 977 | // fills, so a frame costs no second copy. The read is capped at | ||
| 978 | // the wire's `agent_data_max` because that cap is what keeps one | ||
| 979 | // busy channel from holding the link against the session's own | ||
| 980 | // bytes — the same bound the daemon reads its end with. | ||
| 981 | var buf: [proto.agent_id_len + proto.agent_data_max]u8 = undefined; | ||
| 982 | buf[0..proto.agent_id_len].* = proto.encodeAgentId(ch.id); | ||
| 983 | // DONTWAIT, and it is load-bearing rather than belt-and-braces: | ||
| 984 | // the poll above happened before the frame drain, so the daemon | ||
| 985 | // may since have closed this slot's channel and an `agent_open` | ||
| 986 | // in the same burst may have refilled the slot with a fresh | ||
| 987 | // connection that has said nothing yet. A blocking read there | ||
| 988 | // would stop the whole tile — its keystrokes, its paints — on a | ||
| 989 | // socket that is merely idle. `agent_locals[s]` is re-read above, | ||
| 990 | // so whatever this does return is attributed to the id that | ||
| 991 | // actually owns the fd. | ||
| 992 | // | ||
| 993 | // EOF and a broken connection are one case: the agent is done | ||
| 994 | // with this channel either way, and the far side needs to hear so. | ||
| 995 | const n = std.posix.recv( | ||
| 996 | ch.fd, | ||
| 997 | buf[proto.agent_id_len..], | ||
| 998 | std.posix.MSG.DONTWAIT, | ||
| 999 | ) catch |err| switch (err) { | ||
| 1000 | error.WouldBlock => continue, | ||
| 1001 | else => 0, | ||
| 1002 | }; | ||
| 1003 | if (n == 0) { | ||
| 1004 | closeLocal(&agent_locals, s, &transport); | ||
| 1005 | continue; | ||
| 1006 | } | ||
| 1007 | transport.writeFrame(.agent_data, buf[0 .. proto.agent_id_len + n]) catch { | ||
| 1008 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1009 | continue :outer; | ||
| 1010 | }; | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | // Whatever the keyboard left, through everything a plain client's | ||
| 1014 | // keystrokes go through: the mouse split, the wheel, alternate | ||
| 1015 | // scroll, the scrollback view, the prediction and the input frame. | ||
| 1016 | // Only the focused tile's mailbox is ever written, but every pump | ||
| 1017 | // drains its own — an empty mailbox is the common, cheap answer. | ||
| 1018 | var keys_buf: [wv.mailbox_max]u8 = undefined; | ||
| 1019 | const keys = takeKeys(t, &keys_buf); | ||
| 1020 | if (keys.len > 0) { | ||
| 1021 | // A paint that would not allocate is not a dead link: the | ||
| 1022 | // replica is untouched and the next frame redraws from it. | ||
| 1023 | const step = core.forward(&transport, keys) catch interact.Step.ok; | ||
| 1024 | if (step == .lost) { | ||
| 1025 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1026 | continue :outer; | ||
| 1027 | } | ||
| 1028 | // Input is moving again, so "input dropped" has stopped being | ||
| 1029 | // news. Repainted rather than merely cleared, because the bar | ||
| 1030 | // is still carrying the old sentence until something draws | ||
| 1031 | // over it — and only when there is a bar, which `paintLabel` | ||
| 1032 | // already decides. | ||
| 1033 | if (t.in_dropped.swap(false, .acq_rel)) wv.paintLabel(t, state); | ||
| 1034 | } | ||
| 1035 | |||
| 1036 | // A prediction the daemon never answered must not sit on the | ||
| 1037 | // screen forever, and only the clock can say so — no frame will. | ||
| 1038 | // Only while focused: an unfocused tile's rect shows no prediction | ||
| 1039 | // to retire. | ||
| 1040 | if (focused) { | ||
| 1041 | core.idle() catch {}; | ||
| 1042 | publishStats(t.shared, core.overlay.counters); | ||
| 1043 | } | ||
| 1044 | |||
| 1045 | // A relayout re-cut the stripes (or cleared the screen for an empty | ||
| 1046 | // wall), and a quiet session sends nothing to trigger a repaint. | ||
| 1047 | // The replica is current — the SCREEN is not — so the generation is | ||
| 1048 | // what puts it back. Checked on the poll timeout, so a tile comes | ||
| 1049 | // back within ~100ms of a relayout whether or not its session ever | ||
| 1050 | // speaks again. The drag clears here too: a relayout moved the rect | ||
| 1051 | // a held drag's anchor was resolved against. | ||
| 1052 | const gen = t.shared.repaint_gen.load(.acquire); | ||
| 1053 | if (gen != painted_gen) { | ||
| 1054 | core.drag.clear(); | ||
| 1055 | wv.paintLabel(t, state); | ||
| 1056 | // A 0-row rect (fullscreened out) paints nothing: the daemon | ||
| 1057 | // refused the 0×0 resize, so the session keeps its real grid. | ||
| 1058 | if (snap_view_rows > 0) core.repaint() catch {}; | ||
| 1059 | painted_gen = gen; | ||
| 1060 | } | ||
| 1061 | } | ||
| 1062 | } | ||
| 1063 | |||
| 1064 | /// A refusal in THIS client's words. `parseEndReply` hands back the frame's | ||
| 1065 | /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's | ||
| 1066 | /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants. | ||
| 1067 | pub fn endRefusal(reason: []const u8) []const u8 { | ||
| 1068 | if (std.mem.eql(u8, reason, proto.end_reason.no_session)) | ||
| 1069 | return "[no such session on that daemon]"; | ||
| 1070 | if (std.mem.eql(u8, reason, proto.end_reason.bad_frame)) | ||
| 1071 | return "[the daemon could not read the end request]"; | ||
| 1072 | return "[the daemon refused to end this session]"; | ||
| 1073 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -38,8 +38,11 @@ const layout = @import("layout"); | |||
| 38 | const TmpDir = @import("testtmp").TmpDir; | 38 | const TmpDir = @import("testtmp").TmpDir; |
| 39 | const wall_host = @import("wall_host.zig"); | 39 | const wall_host = @import("wall_host.zig"); |
| 40 | const wall_picker = @import("wall_picker.zig"); | 40 | const wall_picker = @import("wall_picker.zig"); |
| 41 | const wall_pump = @import("wall_pump.zig"); | ||
| 41 | const AddHost = wall_host.AddHost; | 42 | const AddHost = wall_host.AddHost; |
| 43 | const AgentLocal = wall_pump.AgentLocal; | ||
| 42 | const BirthNames = wall_host.BirthNames; | 44 | const BirthNames = wall_host.BirthNames; |
| 45 | const ClaimStep = wall_pump.ClaimStep; | ||
| 43 | const Host = wall_host.Host; | 46 | const Host = wall_host.Host; |
| 44 | const PickerAuto = wall_picker.PickerAuto; | 47 | const PickerAuto = wall_picker.PickerAuto; |
| 45 | const PickerBody = wall_picker.PickerBody; | 48 | const PickerBody = wall_picker.PickerBody; |
| @@ -149,14 +152,14 @@ fn doResize( | |||
| 149 | return moved; | 152 | return moved; |
| 150 | } | 153 | } |
| 151 | 154 | ||
| 152 | const State = enum { | 155 | pub const State = enum { |
| 153 | connecting, | 156 | connecting, |
| 154 | up, | 157 | up, |
| 155 | reconnecting, | 158 | reconnecting, |
| 156 | exited, | 159 | exited, |
| 157 | refused, | 160 | refused, |
| 158 | 161 | ||
| 159 | fn word(s: State) []const u8 { | 162 | pub fn word(s: State) []const u8 { |
| 160 | return switch (s) { | 163 | return switch (s) { |
| 161 | .connecting => "connecting", | 164 | .connecting => "connecting", |
| 162 | .up => "up", | 165 | .up => "up", |
| @@ -170,7 +173,7 @@ const State = enum { | |||
| 170 | /// The mailbox: how many typed bytes can wait for a pump to put them on | 173 | /// The mailbox: how many typed bytes can wait for a pump to put them on |
| 171 | /// the wire. One stdin read's worth, which is all a keyboard can produce | 174 | /// the wire. One stdin read's worth, which is all a keyboard can produce |
| 172 | /// between two turns of a pump's poll loop. | 175 | /// between two turns of a pump's poll loop. |
| 173 | const mailbox_max = 4096; | 176 | pub const mailbox_max = 4096; |
| 174 | 177 | ||
| 175 | /// The most tiles one wall can hold. | 178 | /// The most tiles one wall can hold. |
| 176 | /// | 179 | /// |
| @@ -468,11 +471,11 @@ pub const Tile = struct { | |||
| 468 | /// doorbelling — the handover is ordered, not eventual. | 471 | /// doorbelling — the handover is ordered, not eventual. |
| 469 | release_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | 472 | release_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), |
| 470 | 473 | ||
| 471 | fn viewRows(t: *const Tile) u16 { | 474 | pub fn viewRows(t: *const Tile) u16 { |
| 472 | return t.rect.rows -| t.shared.label_rows; | 475 | return t.rect.rows -| t.shared.label_rows; |
| 473 | } | 476 | } |
| 474 | 477 | ||
| 475 | fn viewCols(t: *const Tile) u16 { | 478 | pub fn viewCols(t: *const Tile) u16 { |
| 476 | return t.rect.cols; | 479 | return t.rect.cols; |
| 477 | } | 480 | } |
| 478 | }; | 481 | }; |
| @@ -551,7 +554,7 @@ fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent { | |||
| 551 | } | 554 | } |
| 552 | 555 | ||
| 553 | /// An accepted end DISARMS: the window must not outlive its session. | 556 | /// An accepted end DISARMS: the window must not outlive its session. |
| 554 | fn onEndReply(t: *Tile, r: proto.EndReply, now: i64) void { | 557 | pub fn onEndReply(t: *Tile, r: proto.EndReply, now: i64) void { |
| 555 | t.end_armed_until.store(if (r.accepted) 0 else now + end_arm_ms, .release); | 558 | t.end_armed_until.store(if (r.accepted) 0 else now + end_arm_ms, .release); |
| 556 | } | 559 | } |
| 557 | 560 | ||
| @@ -584,7 +587,7 @@ fn stepLive(tiles: []const Tile, present: []const bool, sel: usize, forward: boo | |||
| 584 | 587 | ||
| 585 | /// The state is remembered on the tile: the keyboard repaints this bar | 588 | /// The state is remembered on the tile: the keyboard repaints this bar |
| 586 | /// with no frame, and only when the wall shows more than one tile. | 589 | /// with no frame, and only when the wall shows more than one tile. |
| 587 | fn paintLabel(t: *Tile, state: State) void { | 590 | pub fn paintLabel(t: *Tile, state: State) void { |
| 588 | t.shared.paint_mu.lock(); | 591 | t.shared.paint_mu.lock(); |
| 589 | defer t.shared.paint_mu.unlock(); | 592 | defer t.shared.paint_mu.unlock(); |
| 590 | t.state = state; | 593 | t.state = state; |
| @@ -673,7 +676,7 @@ fn paintDeadBarsLocked(tiles: []Tile) void { | |||
| 673 | /// wider than a left-hand pane must not cross the rail into its neighbour, | 676 | /// wider than a left-hand pane must not cross the rail into its neighbour, |
| 674 | /// and one right-aligned to the SCREEN lands in whichever pane owns that | 677 | /// and one right-aligned to the SCREEN lands in whichever pane owns that |
| 675 | /// corner. The only owner of a per-tile banner, for exactly that reason. | 678 | /// corner. The only owner of a per-tile banner, for exactly that reason. |
| 676 | fn tileBanner(t: *Tile, text: []const u8) void { | 679 | pub fn tileBanner(t: *Tile, text: []const u8) void { |
| 677 | if (!t.shared.is_tty) return; | 680 | if (!t.shared.is_tty) return; |
| 678 | // The tail, so the cursor end of a long spelling is what is on screen. | 681 | // The tail, so the cursor end of a long spelling is what is on screen. |
| 679 | // Bounded by the paint's own cap as well as the rect: a label past it | 682 | // Bounded by the paint's own cap as well as the rect: a label past it |
| @@ -689,185 +692,8 @@ fn tileBanner(t: *Tile, text: []const u8) void { | |||
| 689 | paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); | 692 | paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); |
| 690 | } | 693 | } |
| 691 | 694 | ||
| 692 | /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the | ||
| 693 | /// paint after it. | ||
| 694 | fn copySelection( | ||
| 695 | t: *Tile, | ||
| 696 | alloc: std.mem.Allocator, | ||
| 697 | core: *interact.Core, | ||
| 698 | payload: []const u8, | ||
| 699 | ) void { | ||
| 700 | var answer: interact.Copy = .none; | ||
| 701 | { | ||
| 702 | t.shared.paint_mu.lock(); | ||
| 703 | defer t.shared.paint_mu.unlock(); | ||
| 704 | // The drag is this tile's own Core's now: a drag is per tile, and | ||
| 705 | // the pump that owns the link is the one whose Core holds it. | ||
| 706 | const held = core.drag.range(); | ||
| 707 | answer = core.selectionCopy(payload, held); | ||
| 708 | switch (answer) { | ||
| 709 | // `is_tty` and not the tile's claim: a tile that has released | ||
| 710 | // the terminal can still be the one whose finished drag the | ||
| 711 | // answer reaches, and the copy is still the user's. A piped | ||
| 712 | // `mux` asks its terminal for no mouse modes, so it can have no | ||
| 713 | // drag to copy in the first place. | ||
| 714 | .text => |text| if (t.shared.is_tty) | ||
| 715 | interact.writeSelectionCopy(alloc, t.shared.out_fd, text) catch {}, | ||
| 716 | .none, .too_large => {}, | ||
| 717 | } | ||
| 718 | } | ||
| 719 | // Outside the hold: `tileBanner` takes the same lock, which is not | ||
| 720 | // reentrant. The banner lands in this tile's own rect. | ||
| 721 | if (answer == .too_large) tileBanner(t, "[selection too large to copy]"); | ||
| 722 | } | ||
| 723 | |||
| 724 | /// Every live tile paints its own rect, so the sink admits a paint whenever | ||
| 725 | /// this tile has not been forgotten. `paint_mu` is held for the whole paint. | ||
| 726 | fn tilePaintBegin(ctx: ?*anyopaque) bool { | ||
| 727 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | ||
| 728 | if (t.gone.load(.acquire)) return false; | ||
| 729 | t.shared.paint_mu.lock(); | ||
| 730 | // Read UNDER the lock, not before it: a pump that tested the flag and | ||
| 731 | // then lost the race for `paint_mu` would paint its rect on top of the | ||
| 732 | // box the keyboard had just drawn — and `picker_stamp` suppresses the | ||
| 733 | // identical repaint that would have repaired it, so the damage sticks | ||
| 734 | // until a key changes the frame. | ||
| 735 | if (t.shared.picker_open.load(.acquire)) { | ||
| 736 | t.shared.paint_mu.unlock(); | ||
| 737 | return false; | ||
| 738 | } | ||
| 739 | return true; | ||
| 740 | } | ||
| 741 | |||
| 742 | fn tilePaintEnd(ctx: ?*anyopaque) void { | ||
| 743 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | ||
| 744 | defer t.shared.paint_mu.unlock(); | ||
| 745 | // Before releasing the terminal: the cursor belongs to the FOCUSED tile. | ||
| 746 | // A focused paint records where its cursor landed; an unfocused paint's | ||
| 747 | // last act is to put the cursor back there, so a redraw in tile 2 cannot | ||
| 748 | // steal the eye while the keys go to tile 1. The cursor is hidden for | ||
| 749 | // the move so the show after it does not flash it at the unfocused | ||
| 750 | // tile's final position before the CUP lands — then re-shown so the | ||
| 751 | // focused tile's cursor rests visible until its next paint. | ||
| 752 | if (t.idx == t.shared.sel) { | ||
| 753 | if (t.core) |core| t.shared.cursor = core.screenCursor(); | ||
| 754 | } else { | ||
| 755 | var cbuf: [26]u8 = undefined; | ||
| 756 | const cup = std.fmt.bufPrint(&cbuf, "\x1b[?25l\x1b[{d};{d}H\x1b[?25h", .{ t.shared.cursor.y + 1, t.shared.cursor.x + 1 }) catch return; | ||
| 757 | proto.writeAllFd(t.shared.out_fd, cup) catch {}; | ||
| 758 | } | ||
| 759 | } | ||
| 760 | |||
| 761 | /// The one place a wall tile puts an attach on the wire. A tile the user | ||
| 762 | /// asked for — the entry tile, a chord-born tile, a local line off the | ||
| 763 | /// saved wall — claims its rect on attach, and that size is what lets the | ||
| 764 | /// daemon create the session. A view tile (wall argv, a remote saved | ||
| 765 | /// line) attaches at 0x0 so it can only JOIN, then takes its rect with the | ||
| 766 | /// resize doorbell one frame later — every tile still claims its | ||
| 767 | /// rectangle, and a redial comes back claiming what the tile claimed. | ||
| 768 | fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void { | ||
| 769 | // Snapshot under `paint_mu`: the keyboard may relayout (re-cut stripes, | ||
| 770 | // resize) concurrently with the pump's first attach. | ||
| 771 | const snap = blk: { | ||
| 772 | t.shared.paint_mu.lock(); | ||
| 773 | defer t.shared.paint_mu.unlock(); | ||
| 774 | break :blk .{ | ||
| 775 | .cols = t.viewCols(), | ||
| 776 | .view_rows = t.viewRows(), | ||
| 777 | }; | ||
| 778 | }; | ||
| 779 | const cols: u16 = if (t.creates) snap.cols else 0; | ||
| 780 | const rows: u16 = if (t.creates) snap.view_rows else 0; | ||
| 781 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 782 | try tr.writeFrame(.attach, proto.encodeAttachNamed( | ||
| 783 | &buf, | ||
| 784 | cols, | ||
| 785 | rows, | ||
| 786 | have_seq, | ||
| 787 | have_epoch, | ||
| 788 | proto.wireName(t.r.session), | ||
| 789 | )); | ||
| 790 | // A view tile made no size claim, so it owes its rect now: the same | ||
| 791 | // doorbell path a relayout takes (adoptSize + .resize), run on this | ||
| 792 | // pump thread which is the transport's only writer. | ||
| 793 | if (!t.creates) { | ||
| 794 | t.shared.paint_mu.lock(); | ||
| 795 | t.resize_pending = true; | ||
| 796 | t.shared.paint_mu.unlock(); | ||
| 797 | ring(t); | ||
| 798 | } | ||
| 799 | // Re-armed on EVERY attach, not once per process: a redial is a fresh | ||
| 800 | // attach onto a fresh daemon-side slot, which remembers no offer. Empty | ||
| 801 | // payload, and a daemon too old to know the frame skips it. | ||
| 802 | if (t.r.agent) try tr.writeFrame(.agent_offer, ""); | ||
| 803 | } | ||
| 804 | |||
| 805 | /// What a pump owes itself after asking for its focus claim. | ||
| 806 | const ClaimStep = enum { | ||
| 807 | /// The claim landed, or the Core already held it: finish the pass. | ||
| 808 | done, | ||
| 809 | /// The sink refused and this tile still has the focus: try next pass. | ||
| 810 | rearmed, | ||
| 811 | /// The focus moved on while the popup was up: this arm is stale. | ||
| 812 | dropped, | ||
| 813 | }; | ||
| 814 | |||
| 815 | /// May this tile take the terminal for the arm it is holding? | ||
| 816 | fn claimAllowed(t: *Tile) bool { | ||
| 817 | // BEFORE the claim, never after. The keyboard arms `claim_pending` and | ||
| 818 | // the pump reads it a pass later, and the focus can move in between — | ||
| 819 | // a poller's tile arriving, the birth an Enter makes, all of it under | ||
| 820 | // the host picker's popup. An arm that outlives its focus and then | ||
| 821 | // SUCCEEDS puts two tiles' modes on one terminal, rests the cursor on | ||
| 822 | // the loser, and takes the focus notice with it; the outgoing tile's | ||
| 823 | // release was consumed a pass earlier, so nothing undoes any of it. | ||
| 824 | // | ||
| 825 | // Judging the claim's REFUSAL cannot cover this: the arm that matters | ||
| 826 | // is the one retried after the popup closed, which the sink admits. | ||
| 827 | t.shared.paint_mu.lock(); | ||
| 828 | defer t.shared.paint_mu.unlock(); | ||
| 829 | return t.shared.sel == t.idx; | ||
| 830 | } | ||
| 831 | |||
| 832 | /// The wall's ONLY door to `Core.claimTerminal`: the focus test, the size | ||
| 833 | /// adopt, and the claim, in that order and never apart. A caller that could | ||
| 834 | /// reach the claim around this is a caller that can mint a stale one. | ||
| 835 | fn claimFocus(t: *Tile, core: *interact.Core, rect: proto.Size) ClaimStep { | ||
| 836 | if (!claimAllowed(t)) return .dropped; | ||
| 837 | // A tile born before somebody resized the terminal clips its paints to | ||
| 838 | // a screen that is gone; the tile's current RECT, never the whole | ||
| 839 | // terminal, which on a wall of two would let it paint over a neighbour. | ||
| 840 | if (core.size.cols != rect.cols or core.size.rows != rect.rows) | ||
| 841 | core.adoptSize(rect); | ||
| 842 | return afterClaim(t, core.claimTerminal(), core.claim != .none, core.is_tty); | ||
| 843 | } | ||
| 844 | |||
| 845 | /// A refused focus claim, judged. | ||
| 846 | fn afterClaim(t: *Tile, claimed: bool, held: bool, is_tty: bool) ClaimStep { | ||
| 847 | // `held` is `Core.claim != .none`. A claim answers false for three | ||
| 848 | // reasons and only ONE of them is worth another pass: not a tty (there | ||
| 849 | // is no terminal to hold), already held (re-arming would re-take the | ||
| 850 | // focus notice every pass, forever), and the SINK refused — which is | ||
| 851 | // the host picker, whose popup admits no paint and a claim writes the | ||
| 852 | // session's modes through one. Read off the Core, never off | ||
| 853 | // `picker_open` a second time: the keyboard can clear that flag between | ||
| 854 | // the two loads, and then the refusal is dropped exactly as before. | ||
| 855 | if (claimed or held or !is_tty) return .done; | ||
| 856 | t.shared.paint_mu.lock(); | ||
| 857 | defer t.shared.paint_mu.unlock(); | ||
| 858 | // ...and only while this tile is STILL the focus. `claimAllowed` asked | ||
| 859 | // the same question before the claim; the keyboard can answer it | ||
| 860 | // differently in between, and re-arming then would mint the very stale | ||
| 861 | // arm that check exists to stop. Left CLEAR rather than cleared — the | ||
| 862 | // caller's `swap` did that — because a focus that came back inside this | ||
| 863 | // window re-armed it legitimately. | ||
| 864 | if (t.shared.sel != t.idx) return .dropped; | ||
| 865 | t.claim_pending.store(true, .release); | ||
| 866 | return .rearmed; | ||
| 867 | } | ||
| 868 | |||
| 869 | /// Never blocks and never reports: a full pipe is a bell already ringing. | 695 | /// Never blocks and never reports: a full pipe is a bell already ringing. |
| 870 | fn ring(t: *const Tile) void { | 696 | pub fn ring(t: *const Tile) void { |
| 871 | _ = std.posix.write(t.wake_w, "\x00") catch {}; | 697 | _ = std.posix.write(t.wake_w, "\x00") catch {}; |
| 872 | } | 698 | } |
| 873 | 699 | ||
| @@ -896,21 +722,13 @@ pub fn ringKeyboard(shared: *const Shared) void { | |||
| 896 | 722 | ||
| 897 | /// Both ends of both bells are non-blocking, which is what makes this safe | 723 | /// Both ends of both bells are non-blocking, which is what makes this safe |
| 898 | /// on a pipe nobody has rung. | 724 | /// on a pipe nobody has rung. |
| 899 | fn drainBell(fd: std.posix.fd_t) void { | 725 | pub fn drainBell(fd: std.posix.fd_t) void { |
| 900 | var sink: [64]u8 = undefined; | 726 | var sink: [64]u8 = undefined; |
| 901 | while (std.posix.read(fd, &sink)) |n| { | 727 | while (std.posix.read(fd, &sink)) |n| { |
| 902 | if (n < sink.len) break; | 728 | if (n < sink.len) break; |
| 903 | } else |_| {} | 729 | } else |_| {} |
| 904 | } | 730 | } |
| 905 | 731 | ||
| 906 | /// FOCUSED pumps only: a tile that never held the terminal has zero | ||
| 907 | /// counters, and publishing them would clobber the tile the user typed at. | ||
| 908 | fn publishStats(shared: *Shared, c: interact.PredictCounters) void { | ||
| 909 | shared.paint_mu.lock(); | ||
| 910 | defer shared.paint_mu.unlock(); | ||
| 911 | shared.stats = c; | ||
| 912 | } | ||
| 913 | |||
| 914 | /// Leave a sentence for whichever tile owns the terminal next. Keyboard | 732 | /// Leave a sentence for whichever tile owns the terminal next. Keyboard |
| 915 | /// thread only, and only for a focus it is about to move — the pump that | 733 | /// thread only, and only for a focus it is about to move — the pump that |
| 916 | /// lands there paints it as a banner on its claim. | 734 | /// lands there paints it as a banner on its claim. |
| @@ -934,7 +752,7 @@ pub fn setNoticeIdle(shared: *Shared, text: []const u8) void { | |||
| 934 | } | 752 | } |
| 935 | 753 | ||
| 936 | /// Take it, once. | 754 | /// Take it, once. |
| 937 | fn takeNotice(shared: *Shared, out: []u8) []const u8 { | 755 | pub fn takeNotice(shared: *Shared, out: []u8) []const u8 { |
| 938 | // Copied out because the caller paints it after releasing the lock: | 756 | // Copied out because the caller paints it after releasing the lock: |
| 939 | // `Core.banner` takes `paint_mu` itself, through the sink, and the | 757 | // `Core.banner` takes `paint_mu` itself, through the sink, and the |
| 940 | // sink is not reentrant. | 758 | // sink is not reentrant. |
| @@ -943,27 +761,6 @@ fn takeNotice(shared: *Shared, out: []u8) []const u8 { | |||
| 943 | return takeNoticeLocked(shared, out); | 761 | return takeNoticeLocked(shared, out); |
| 944 | } | 762 | } |
| 945 | 763 | ||
| 946 | /// Validated first: these bytes came out of a peer's `sessions_reply` and | ||
| 947 | /// `SessionName.of` memcpys with no bound of its own (`client.validPick`). | ||
| 948 | /// A name it refuses is a name nobody is moved to. | ||
| 949 | fn postAnswer(t: *Tile, pick: []const u8) void { | ||
| 950 | const name = client.validPick(pick) orelse return; | ||
| 951 | { | ||
| 952 | t.ans_mu.lock(); | ||
| 953 | defer t.ans_mu.unlock(); | ||
| 954 | t.ans = name; | ||
| 955 | } | ||
| 956 | t.ans_ready.store(true, .release); | ||
| 957 | ringKeyboard(t.shared); | ||
| 958 | } | ||
| 959 | |||
| 960 | /// Written before `alive` clears, so no dead tile is ever seen without a | ||
| 961 | /// reason. Rings NOTHING — the keyboard's test is `!alive`. | ||
| 962 | fn endWith(t: *Tile, reason: EndReason, code: u8) void { | ||
| 963 | t.code.store(code, .release); | ||
| 964 | t.end.store(@intFromEnum(reason), .release); | ||
| 965 | } | ||
| 966 | |||
| 967 | /// Called ONLY from the keyboard loop and ONLY while `t` is the focused tile: | 764 | /// Called ONLY from the keyboard loop and ONLY while `t` is the focused tile: |
| 968 | /// that restriction IS the enforcement of "an unfocused tile claims no terminal modes". | 765 | /// that restriction IS the enforcement of "an unfocused tile claims no terminal modes". |
| 969 | /// | 766 | /// |
| @@ -990,848 +787,6 @@ fn sendKeys(t: *Tile, keys: []const u8) void { | |||
| 990 | ring(t); | 787 | ring(t); |
| 991 | } | 788 | } |
| 992 | 789 | ||
| 993 | /// Whole-mailbox chunking, so `offerKeystroke` (one-byte chunks only) counts | ||
| 994 | /// keystrokes that arrive between polls as suppressed: a wall predicts a | ||
| 995 | /// little less. Splitting would speculate against a stale replica. | ||
| 996 | fn takeKeys(t: *Tile, out: []u8) []u8 { | ||
| 997 | t.in_mu.lock(); | ||
| 998 | defer t.in_mu.unlock(); | ||
| 999 | const n = @min(out.len, t.in_len); | ||
| 1000 | @memcpy(out[0..n], t.in[0..n]); | ||
| 1001 | std.mem.copyForwards(u8, t.in[0 .. t.in_len - n], t.in[n..t.in_len]); | ||
| 1002 | t.in_len -= n; | ||
| 1003 | return out[0..n]; | ||
| 1004 | } | ||
| 1005 | |||
| 1006 | fn drainWake(t: *const Tile) void { | ||
| 1007 | drainBell(t.wake_r); | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { | ||
| 1011 | var target = target_in; | ||
| 1012 | var backoff_ms: u64 = 0; | ||
| 1013 | // `gone` as well as `running`: a tile forgotten while it is retrying a | ||
| 1014 | // dead host must stop retrying, not keep a thread and a backoff alive | ||
| 1015 | // for a tile that is no longer on the wall. | ||
| 1016 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | ||
| 1017 | if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {} | ||
| 1018 | // An ask buys ONE attempt. Every retry below is the wall's own | ||
| 1019 | // idea: a `muxd start` per backoff would restart a daemon for as | ||
| 1020 | // long as the tile lives, and a fallback line per backoff would | ||
| 1021 | // scroll the alternate screen the tiles are painted on. | ||
| 1022 | if (target == .hand) target.hand.asked = false; | ||
| 1023 | backoff_ms = client.nextBackoffMs(backoff_ms); | ||
| 1024 | // Sliced sleep so quit is never behind a full backoff. | ||
| 1025 | var slept: u64 = 0; | ||
| 1026 | while (slept < backoff_ms and t.shared.running.load(.acquire) and | ||
| 1027 | !t.gone.load(.acquire)) : (slept += 50) | ||
| 1028 | { | ||
| 1029 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 1030 | } | ||
| 1031 | } | ||
| 1032 | return null; | ||
| 1033 | } | ||
| 1034 | |||
| 1035 | /// One forwarded ssh-agent channel, this end of it: the id the daemon | ||
| 1036 | /// allocated, and an fd to THIS machine's agent. | ||
| 1037 | /// | ||
| 1038 | /// Thread-local by construction — the table lives in `pumpTile`'s frame and | ||
| 1039 | /// no other thread can see it, which is why nothing here takes a lock and | ||
| 1040 | /// why these helpers take the table as a slice rather than reaching for one. | ||
| 1041 | const AgentLocal = struct { id: u32, fd: std.posix.fd_t }; | ||
| 1042 | |||
| 1043 | /// Fixed at `proto.agent_chans_max`, which is what the daemon opens anyway: | ||
| 1044 | /// a full table costs one failed lookup, not the pump's hot path an alloc. | ||
| 1045 | fn storeLocal(locals: []?AgentLocal, id: u32, fd: std.posix.fd_t) ?usize { | ||
| 1046 | for (locals, 0..) |c, s| { | ||
| 1047 | if (c != null) continue; | ||
| 1048 | locals[s] = .{ .id = id, .fd = fd }; | ||
| 1049 | return s; | ||
| 1050 | } | ||
| 1051 | return null; | ||
| 1052 | } | ||
| 1053 | |||
| 1054 | fn findLocal(locals: []?AgentLocal, id: u32) ?usize { | ||
| 1055 | for (locals, 0..) |c, s| if (c) |ch| { | ||
| 1056 | if (ch.id == id) return s; | ||
| 1057 | }; | ||
| 1058 | return null; | ||
| 1059 | } | ||
| 1060 | |||
| 1061 | /// Hang one channel up from this end and say so, because the daemon is | ||
| 1062 | /// holding the far socket open waiting for bytes that are not coming. A | ||
| 1063 | /// failed write is the transport itself being gone, which the caller's next | ||
| 1064 | /// pass turns into a redial. | ||
| 1065 | fn closeLocal(locals: []?AgentLocal, slot: usize, transport: *client.Transport) void { | ||
| 1066 | const ch = locals[slot] orelse return; | ||
| 1067 | locals[slot] = null; | ||
| 1068 | std.posix.close(ch.fd); | ||
| 1069 | transport.writeFrame(.agent_close, &proto.encodeAgentId(ch.id)) catch {}; | ||
| 1070 | } | ||
| 1071 | |||
| 1072 | /// Lifted out of the pump so the `offered` gate has a seam a test can watch. | ||
| 1073 | fn openAgentChan( | ||
| 1074 | locals: []?AgentLocal, | ||
| 1075 | id: u32, | ||
| 1076 | offered: bool, | ||
| 1077 | sock: []const u8, | ||
| 1078 | ) bool { | ||
| 1079 | // The OFFER is the consent, and it is per TILE: a wall where one tile | ||
| 1080 | // was typed with `-A` must not hand another tile's host the keys, | ||
| 1081 | // whoever asks. | ||
| 1082 | if (!offered) return false; | ||
| 1083 | // A live id reused. Refusing keeps the channel already on that id | ||
| 1084 | // intact, which is the half of the collision that has real bytes moving | ||
| 1085 | // through it. | ||
| 1086 | if (findLocal(locals, id) != null) return false; | ||
| 1087 | const fd = client.connectAgent(sock) orelse return false; | ||
| 1088 | if (storeLocal(locals, id, fd) == null) { | ||
| 1089 | std.posix.close(fd); | ||
| 1090 | return false; | ||
| 1091 | } | ||
| 1092 | return true; | ||
| 1093 | } | ||
| 1094 | |||
| 1095 | /// Lifted out of the pump so the length cap has a seam a test can reach. | ||
| 1096 | fn deliverAgentData( | ||
| 1097 | locals: []?AgentLocal, | ||
| 1098 | payload: []const u8, | ||
| 1099 | transport: *client.Transport, | ||
| 1100 | ) bool { | ||
| 1101 | const id = proto.decodeAgentId(payload) catch return false; | ||
| 1102 | const s = findLocal(locals, id) orelse return true; | ||
| 1103 | if (proto.agentDataOversize(payload)) { | ||
| 1104 | closeLocal(locals, s, transport); | ||
| 1105 | return true; | ||
| 1106 | } | ||
| 1107 | // Bytes onto the fd in order, never parsed and never reassembled: the | ||
| 1108 | // daemon reads the far end in `agent_data_max` bites, so one agent | ||
| 1109 | // message can arrive as several frames and several messages as one. The | ||
| 1110 | // agent protocol delimits itself over a stream, and this end is a pipe. | ||
| 1111 | proto.writeAllFd(locals[s].?.fd, payload[proto.agent_id_len..]) catch | ||
| 1112 | closeLocal(locals, s, transport); | ||
| 1113 | return true; | ||
| 1114 | } | ||
| 1115 | |||
| 1116 | /// Redial only: these belonged to the dead connection, whose `dropClient` | ||
| 1117 | /// already reaped the server side. | ||
| 1118 | fn dropLocals(locals: []?AgentLocal) void { | ||
| 1119 | for (locals, 0..) |c, s| if (c) |ch| { | ||
| 1120 | locals[s] = null; | ||
| 1121 | std.posix.close(ch.fd); | ||
| 1122 | }; | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | /// The transport died, or the dial has to be redone: rebuild it on the CLI's | ||
| 1126 | /// backoff and re-attach quoting what this tile holds. False means the pump | ||
| 1127 | /// is finished — the wall quit, or the tile was forgotten while retrying. | ||
| 1128 | /// | ||
| 1129 | /// One function for what were four copies of five steps, which had begun to | ||
| 1130 | /// differ: only some dropped a scroll view a resync was about to invalidate. | ||
| 1131 | fn redial( | ||
| 1132 | t: *Tile, | ||
| 1133 | alloc: std.mem.Allocator, | ||
| 1134 | core: *interact.Core, | ||
| 1135 | transport: *client.Transport, | ||
| 1136 | target: client.Target, | ||
| 1137 | state: *State, | ||
| 1138 | /// This tile's agent channels, which the dying connection owned. Dropped | ||
| 1139 | /// HERE, and here only, for the reason this function exists at all: | ||
| 1140 | /// every call site that had to remember would be one more chance to | ||
| 1141 | /// strand a channel on a connection that cannot close it. | ||
| 1142 | agents: []?AgentLocal, | ||
| 1143 | ) bool { | ||
| 1144 | // A close that follows our own detach is the daemon saying goodbye back, | ||
| 1145 | // not a tear to heal: the pump wrote the .detach frame and set | ||
| 1146 | // `detach_ack` before the save's file I/O window let readFrame see the | ||
| 1147 | // daemon's side. Redialing here would re-attach a slot the user just | ||
| 1148 | // released. | ||
| 1149 | if (t.detach_ack.load(.acquire)) return false; | ||
| 1150 | // Before the cold-dial refusal below, which returns without reconnecting | ||
| 1151 | // — a pump that ends still owes these fds. | ||
| 1152 | dropLocals(agents); | ||
| 1153 | // The plain client's rule, kept for the tile a `mux TARGET` is: a | ||
| 1154 | // transport that died before a single frame of state carried no | ||
| 1155 | // session, so there is nothing to resume and retrying a bad host or a | ||
| 1156 | // typo'd `--via` only makes an unkillable client. `session_epoch` is | ||
| 1157 | // the right signal because it is set from the first snapshot and never | ||
| 1158 | // reset. Wall tiles do the opposite deliberately — they retry forever, | ||
| 1159 | // because a wall is a thing you leave up while a box reboots. | ||
| 1160 | if (!t.retry_cold and core.rep.session_epoch == 0) { | ||
| 1161 | endWith(t, .lost, 1); | ||
| 1162 | return false; | ||
| 1163 | } | ||
| 1164 | transport.close(); | ||
| 1165 | state.* = .reconnecting; | ||
| 1166 | paintLabel(t, state.*); | ||
| 1167 | // ...and the same news for a one-tile wall, which has no label | ||
| 1168 | // bar on screen to read it off. The corner banner is the plain client's | ||
| 1169 | // own, said before the dial rather than inside it for its reason: it is | ||
| 1170 | // a PAINT on the session's screen, and the Core is what paints. `banner` | ||
| 1171 | // is gated on the sink, so a stripe's re-dial writes nothing here. | ||
| 1172 | core.banner("[reconnecting]"); | ||
| 1173 | // The resync's own paint is what will arrive, so a history page held | ||
| 1174 | // here would be silently replaced a moment later. | ||
| 1175 | core.dropScrollView(); | ||
| 1176 | transport.* = dial(alloc, t, target) orelse return false; | ||
| 1177 | // Clears `state_since_attach` (so the next exit_status is read as a | ||
| 1178 | // refusal again) and drops speculation made against a connection that | ||
| 1179 | // no longer exists — the Core's own highlight with it. | ||
| 1180 | core.reattached(); | ||
| 1181 | const have = core.rep.attachArgs(); | ||
| 1182 | sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; | ||
| 1183 | return true; | ||
| 1184 | } | ||
| 1185 | |||
| 1186 | /// One pump pass's geometry, plus the `.resize` that pass owes the daemon. | ||
| 1187 | const Pass = struct { | ||
| 1188 | top: u16, | ||
| 1189 | left: u16, | ||
| 1190 | rows: u16, | ||
| 1191 | cols: u16, | ||
| 1192 | label_rows: u16, | ||
| 1193 | term_cols: u16, | ||
| 1194 | term_rows: u16, | ||
| 1195 | // A relayout re-cut this tile: the pump owes the daemon THIS pass's | ||
| 1196 | // content size. | ||
| 1197 | resize: bool, | ||
| 1198 | }; | ||
| 1199 | |||
| 1200 | fn takePass(t: *Tile) Pass { | ||
| 1201 | t.shared.paint_mu.lock(); | ||
| 1202 | defer t.shared.paint_mu.unlock(); | ||
| 1203 | // The flag comes out of the SAME hold as the rect it describes. Read a | ||
| 1204 | // pass apart from its doorbell and a relayout landing between the two | ||
| 1205 | // is swallowed: the pump sends the rect it snapshotted first, clears | ||
| 1206 | // the flag, and the daemon keeps a grid the tile has already stopped | ||
| 1207 | // painting at — nothing re-sends, because the claim path does not. | ||
| 1208 | const owed = t.resize_pending; | ||
| 1209 | t.resize_pending = false; | ||
| 1210 | return .{ | ||
| 1211 | .top = t.rect.top, | ||
| 1212 | .left = t.rect.left, | ||
| 1213 | .rows = t.rect.rows, | ||
| 1214 | .cols = t.rect.cols, | ||
| 1215 | .label_rows = t.shared.label_rows, | ||
| 1216 | .term_cols = t.shared.size.cols, | ||
| 1217 | .term_rows = t.shared.size.rows, | ||
| 1218 | .resize = owed, | ||
| 1219 | }; | ||
| 1220 | } | ||
| 1221 | |||
| 1222 | /// Absolute rows count from the oldest row the daemon keeps, and a resync | ||
| 1223 | /// renames that space: a kept highlight inverts rows nobody selected. | ||
| 1224 | /// One tile's life: dial → attach → replay frames into its Core → repaint | ||
| 1225 | /// at its rect. Runs on its own thread (see module header). On transport | ||
| 1226 | /// death: reconnect on the CLI's backoff schedule, quoting | ||
| 1227 | /// have_seq/have_epoch, and the snapshot-vs-delta resolution does the rest. | ||
| 1228 | /// Ends when `running` clears, the session exits, or the attach is refused. | ||
| 1229 | /// | ||
| 1230 | /// This thread is also the tile's WRITER: every frame the keyboard doorbells | ||
| 1231 | /// for — a resize, a detach, a focus claim or release — goes out from here, | ||
| 1232 | /// because a Transport has exactly one owning thread (module header). | ||
| 1233 | fn pumpTile(t: *Tile) void { | ||
| 1234 | // FIRST defer, so it runs LAST: every `return` below — a refused | ||
| 1235 | // attach, an exited session, a dial the quit interrupted, a Core that | ||
| 1236 | // would not initialise — is this tile going quiet for good, and the | ||
| 1237 | // keyboard needs to know which tiles it has to paint for. Declared | ||
| 1238 | // before the allocator's own defer so nothing can end this thread | ||
| 1239 | // without it running. | ||
| 1240 | // The bell goes with the store and after it, `endWith`'s reason: the | ||
| 1241 | // keyboard's test is `!alive`, so a ring that precedes the store is a | ||
| 1242 | // wake-up that finds nothing. | ||
| 1243 | defer { | ||
| 1244 | t.alive.store(false, .release); | ||
| 1245 | ringKeyboard(t.shared); | ||
| 1246 | // LAST, after the bell above has finished reading `t.shared`: this | ||
| 1247 | // is what hands the slot to `birthTile`, and nothing may touch the | ||
| 1248 | // tile after it. | ||
| 1249 | t.pump_done.store(true, .release); | ||
| 1250 | } | ||
| 1251 | |||
| 1252 | // Per-thread allocator: nothing allocated here crosses threads except | ||
| 1253 | // painted bytes, which go out under the paint mutex. | ||
| 1254 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 1255 | defer _ = gpa.deinit(); | ||
| 1256 | const alloc = gpa.allocator(); | ||
| 1257 | |||
| 1258 | // Whether this tile may narrate and may start a daemon travels IN its | ||
| 1259 | // target, set once by whoever made the tile: a picker Enter is an ask, | ||
| 1260 | // a poll's list is not. This is the pump's own copy — `t.r.target` is | ||
| 1261 | // read by the KEYBOARD thread under `paint_mu` for chord births and is | ||
| 1262 | // never written from here — and it is spent below, once. | ||
| 1263 | var target = t.r.target; | ||
| 1264 | |||
| 1265 | // ONE Core per tile, from birth. It owns this tile's replica, its | ||
| 1266 | // prediction overlay and its drag for the tile's whole life: the tile | ||
| 1267 | // paints from that replica at its own offset, and what decides whether | ||
| 1268 | // a keystroke may be speculated at all is the pty's line discipline, | ||
| 1269 | // which arrives in `.pty_mode` frames long before the tile is focused. | ||
| 1270 | // | ||
| 1271 | // `in_fd` is the wall's stdin and this Core never reads it — the | ||
| 1272 | // keyboard thread does, on the far side of the mailbox. It is passed | ||
| 1273 | // because it is the truth about whether there is a terminal here at all | ||
| 1274 | // (`is_tty`), which the mouse split and the side channels are gated on. | ||
| 1275 | var core = interact.Core.initSized( | ||
| 1276 | alloc, | ||
| 1277 | std.posix.STDIN_FILENO, | ||
| 1278 | t.shared.out_fd, | ||
| 1279 | t.shared.size, | ||
| 1280 | ) catch return; | ||
| 1281 | defer core.deinit(); | ||
| 1282 | // Whether this tile has EVER held the terminal, which is the only | ||
| 1283 | // question the publish below is gated on: a tile that was never focused | ||
| 1284 | // has nothing to say about prediction and must not overwrite what the | ||
| 1285 | // tile that was does. | ||
| 1286 | var ever_focused = false; | ||
| 1287 | // Focus, as this pump knows it. Not `core.claim`: a claim needs a | ||
| 1288 | // terminal, and `mux` on a pipe has none, yet its one tile is focused | ||
| 1289 | // and its predictions still expire and still get counted. | ||
| 1290 | var focused = false; | ||
| 1291 | // The last word on this tile's prediction, whichever way the pump ends | ||
| 1292 | // — an exit_status arrives and RETURNS, so the per-pass publish inside | ||
| 1293 | // the loop is always one pass stale by then. | ||
| 1294 | defer if (ever_focused) publishStats(t.shared, core.overlay.counters); | ||
| 1295 | // Where this Core's paints land: this tile's rect. The sink admits a | ||
| 1296 | // paint whenever the tile has not been forgotten; see `tilePaintBegin`. | ||
| 1297 | core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd }; | ||
| 1298 | // The paint-end hook reads the core's screen cursor; only this pump | ||
| 1299 | // thread dereferences it, and the core outlives the pump. | ||
| 1300 | t.core = &core; | ||
| 1301 | // This thread does not own the exit and cannot print the stats line — | ||
| 1302 | // see `Shared.stats`. | ||
| 1303 | core.owns_stats = false; | ||
| 1304 | |||
| 1305 | paintLabel(t, .connecting); | ||
| 1306 | // The ENTRY tile arrives with its link already up — dialled on the main | ||
| 1307 | // thread, where the tty was, so ssh could prompt. `adopt` is what moves | ||
| 1308 | // its QUIC out-queue onto this thread's allocator; see there. | ||
| 1309 | var transport = if (t.pre) |pre| blk: { | ||
| 1310 | var tr = pre; | ||
| 1311 | t.pre = null; | ||
| 1312 | tr.adopt(alloc); | ||
| 1313 | break :blk tr; | ||
| 1314 | } else dial(alloc, t, target) orelse return; | ||
| 1315 | defer transport.close(); | ||
| 1316 | // The ask is SPENT, on whichever of the two branches above got the | ||
| 1317 | // link: the entry tile's dial happened on the main thread, a picker | ||
| 1318 | // birth's just happened here. Every `redial` below is handed this | ||
| 1319 | // copy, so a reconnect can neither start a daemon — `muxd stop` typed | ||
| 1320 | // on that box would otherwise be undone by the next backoff, the | ||
| 1321 | // poll's bug moved onto a tile — nor print the fallback line onto the | ||
| 1322 | // alternate screen the tiles are painted on. | ||
| 1323 | if (target == .hand) target.hand.asked = false; | ||
| 1324 | // The entry tile's attach carries its rect, so the session is sized to | ||
| 1325 | // the terminal the tile claims and no second resize follows — | ||
| 1326 | // re-asserting a size the daemon just heard costs one more snapshot on | ||
| 1327 | // every `mux`, which is exactly the round trip the convergence must not | ||
| 1328 | // add. A view tile attaches at 0x0 (join-only) and `sendAttach` doorbells | ||
| 1329 | // its rect behind the attach; that path is inside `sendAttach`. | ||
| 1330 | sendAttach(t, &transport, 0, 0) catch { | ||
| 1331 | endWith(t, .lost, 1); | ||
| 1332 | return; | ||
| 1333 | }; | ||
| 1334 | // One question at a time, with the deadline that makes a daemon too old | ||
| 1335 | // to have heard it (`sessions_req` is 0x0c) say so instead of swallowing | ||
| 1336 | // every chord for the rest of the session. `client.PendingSwitch` | ||
| 1337 | // verbatim — the client asked the same question and this is the same | ||
| 1338 | // answer, moved to the thread that owns the link. | ||
| 1339 | var pending: client.PendingSwitch = .{}; | ||
| 1340 | // The ssh-agent channels this tile is serving, one open fd each to this | ||
| 1341 | // machine's agent. Everything about them is thread-local: this table, | ||
| 1342 | // the fds in it, and the frames that move them all live on this pump. | ||
| 1343 | var agent_locals: [proto.agent_chans_max]?AgentLocal = @splat(null); | ||
| 1344 | // Every `return` below is this tile going quiet with channels possibly | ||
| 1345 | // still open, and the daemon's side of them dies with the transport the | ||
| 1346 | // defer above closes. | ||
| 1347 | defer dropLocals(&agent_locals); | ||
| 1348 | |||
| 1349 | var state: State = .connecting; | ||
| 1350 | // What this tile's paint is worth: while it matches the wall's | ||
| 1351 | // generation the terminal still holds what this thread drew. | ||
| 1352 | var painted_gen = t.shared.repaint_gen.load(.acquire); | ||
| 1353 | // `gone` ends this thread exactly as `running` does — the defers close | ||
| 1354 | // the transport, which frees the daemon slot and NOTHING else. The | ||
| 1355 | // session goes on running: "remove is detach". | ||
| 1356 | outer: while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | ||
| 1357 | // The link and the doorbell, then one fd per live agent channel — | ||
| 1358 | // joined into the pump's own poll rather than given a thread each, | ||
| 1359 | // because a Transport has exactly one owning thread and these bytes | ||
| 1360 | // leave through it. `at` remembers which table slot each of those | ||
| 1361 | // trailing fds came from, so a readable one can be traced back to | ||
| 1362 | // its channel without a second search. | ||
| 1363 | var fdbuf: [2 + agent_locals.len]std.posix.pollfd = undefined; | ||
| 1364 | fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 1365 | fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 1366 | var nfds: usize = 2; | ||
| 1367 | var at: [agent_locals.len]usize = undefined; | ||
| 1368 | for (agent_locals, 0..) |c, s| if (c) |ch| { | ||
| 1369 | at[nfds - 2] = s; | ||
| 1370 | fdbuf[nfds] = .{ .fd = ch.fd, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 1371 | nfds += 1; | ||
| 1372 | }; | ||
| 1373 | _ = std.posix.poll(fdbuf[0..nfds], transport.timeoutMs(100)) catch return; | ||
| 1374 | transport.service(); | ||
| 1375 | if (fdbuf[1].revents != 0) drainWake(t); | ||
| 1376 | |||
| 1377 | // The paint offset for this pass: a relayout may have re-cut this | ||
| 1378 | // tile's rect, and every paint the pass drives through the Core | ||
| 1379 | // has to land in the rect the tile currently owns. Snapshot under | ||
| 1380 | // `paint_mu` — the keyboard writes `rect` and `label_rows` under | ||
| 1381 | // it — and use the snapshot for the whole pass. | ||
| 1382 | const snap = takePass(t); | ||
| 1383 | core.row_off = snap.top + snap.label_rows; | ||
| 1384 | core.col_off = snap.left; | ||
| 1385 | core.owns_screen = snap.top == 0 and snap.left == 0 and | ||
| 1386 | snap.label_rows == 0 and snap.cols == snap.term_cols and | ||
| 1387 | snap.rows == snap.term_rows; | ||
| 1388 | const snap_view_rows: u16 = snap.rows -| snap.label_rows; | ||
| 1389 | const snap_view_cols: u16 = snap.cols; | ||
| 1390 | |||
| 1391 | // FOCUS CLAIM. The keyboard moved the focus onto this tile; the | ||
| 1392 | // session's mouse modes and side channels go on here, on the thread | ||
| 1393 | // that owns the transport and the Core. The resize the claim used | ||
| 1394 | // to send is gone — the attach already carried the rect, and a | ||
| 1395 | // relayout doorbells `resize_pending` for any later change. | ||
| 1396 | if (t.claim_pending.swap(false, .acq_rel)) { | ||
| 1397 | // A refused claim is re-armed rather than lost: the host | ||
| 1398 | // picker's popup admits no paint, and a claim writes the | ||
| 1399 | // session's modes through the paint sink, so a claim dropped | ||
| 1400 | // under it leaves this pump focused holding no terminal — no | ||
| 1401 | // mouse modes, no side channels, the notice below eaten — until | ||
| 1402 | // the user moves the focus away and back. The picker's close | ||
| 1403 | // rings this pump, so the retry is a keystroke away. | ||
| 1404 | // | ||
| 1405 | // The rest of the PASS still runs whatever comes back: | ||
| 1406 | // `takePass` has already cleared `resize_pending`, so skipping | ||
| 1407 | // out here would drop a relayout this tile owes the daemon. | ||
| 1408 | const step = claimFocus(t, &core, .{ .cols = snap_view_cols, .rows = snap_view_rows }); | ||
| 1409 | // A stale arm — the keyboard moved the focus between arming | ||
| 1410 | // this and this pass — takes nothing: no claim, no modes, no | ||
| 1411 | // notice, and these predictions are not the focus's to publish | ||
| 1412 | // either. The tile that DOES hold the focus was armed by the | ||
| 1413 | // same `setFocus` that took it from this one. | ||
| 1414 | focused = step != .dropped; | ||
| 1415 | ever_focused = ever_focused or focused; | ||
| 1416 | if (step == .done) { | ||
| 1417 | // A sentence the keyboard left for whoever owns the terminal | ||
| 1418 | // next — a refused `Ctrl-\ c`, so far. Painted here because | ||
| 1419 | // a banner belongs to a Core and this is the Core that has | ||
| 1420 | // just taken the screen; painted AFTER the repaint below | ||
| 1421 | // would be wrong, so it is taken now and shown once the grid | ||
| 1422 | // is up. | ||
| 1423 | var notice_buf: [96]u8 = undefined; | ||
| 1424 | const notice = takeNotice(t.shared, ¬ice_buf); | ||
| 1425 | // The replica has been hot the whole time, so a claim paints | ||
| 1426 | // from it NOW rather than waiting for the daemon's answering | ||
| 1427 | // snapshot. That is the headline: moving the focus costs a | ||
| 1428 | // local repaint, never a wire frame. | ||
| 1429 | // ...but only when there IS one. A tile focused before its | ||
| 1430 | // first snapshot — the entry tile, on every `mux` — would | ||
| 1431 | // otherwise paint a blank grid over the terminal before the | ||
| 1432 | // session has said anything, which is a screen the plain | ||
| 1433 | // client never drew and bytes a capture never held. | ||
| 1434 | if (core.rep.session_epoch != 0) core.repaint() catch {}; | ||
| 1435 | if (notice.len > 0) core.banner(notice); | ||
| 1436 | } | ||
| 1437 | } | ||
| 1438 | // FOCUS RELEASE. The keyboard moved the focus off this tile and | ||
| 1439 | // wrote the session's release itself, under `paint_mu`, before | ||
| 1440 | // doorbelling — so the handover is ordered and this pump owes only | ||
| 1441 | // its own state. `.already_written` is that discipline. | ||
| 1442 | if (t.release_pending.swap(false, .acq_rel)) { | ||
| 1443 | focused = false; | ||
| 1444 | core.releaseTerminal(.already_written); | ||
| 1445 | // The speculation described a screen this terminal no longer | ||
| 1446 | // shows. | ||
| 1447 | core.overlay.flush(); | ||
| 1448 | } | ||
| 1449 | |||
| 1450 | // RELAYOUT DOORBELL: this tile's rect changed. The pump is the | ||
| 1451 | // transport's only writer, so relayout sets the flag and the pump | ||
| 1452 | // sends the `.resize` from here. | ||
| 1453 | if (snap.resize) { | ||
| 1454 | core.overlay.setResizePending(true); | ||
| 1455 | // The Core clips every paint to its size; a resize the daemon | ||
| 1456 | // hears but the Core does not leaves the bottom of the new | ||
| 1457 | // grid cut off on screen forever. | ||
| 1458 | core.adoptSize(.{ .cols = snap_view_cols, .rows = snap_view_rows }); | ||
| 1459 | transport.writeFrame( | ||
| 1460 | .resize, | ||
| 1461 | &proto.encodeSize(snap_view_cols, snap_view_rows), | ||
| 1462 | ) catch { | ||
| 1463 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1464 | continue :outer; | ||
| 1465 | }; | ||
| 1466 | } | ||
| 1467 | |||
| 1468 | // `Ctrl-\ d`: hand the daemon its slot back before the process | ||
| 1469 | // dies, rather than leaving it for the socket's death to be | ||
| 1470 | // noticed. Only this thread may write the frame, so the keyboard | ||
| 1471 | // asked and is waiting for the ack. | ||
| 1472 | if (t.detach_req.swap(false, .acq_rel)) { | ||
| 1473 | transport.writeFrame(.detach, "") catch {}; | ||
| 1474 | t.detach_ack.store(true, .release); | ||
| 1475 | ringKeyboard(t.shared); | ||
| 1476 | } | ||
| 1477 | |||
| 1478 | // A focus chord's question, put on the wire from the thread that | ||
| 1479 | // owns the link. The ANSWER is read out of the `.sessions_reply` | ||
| 1480 | // arm below and handed to the keyboard, which is the only thread | ||
| 1481 | // that may move the focus or grow the wall. | ||
| 1482 | const asked: client.SwitchIntent = @enumFromInt(t.ask.swap(0, .acq_rel)); | ||
| 1483 | if (asked != .none) { | ||
| 1484 | // The deadline starts HERE, when the question goes on the wire, | ||
| 1485 | // not when the key was typed: it exists to catch a daemon too | ||
| 1486 | // old to have heard `sessions_req`, and the time a chord spent | ||
| 1487 | // in the mailbox is not that daemon's silence. | ||
| 1488 | pending.arm(asked, std.time.milliTimestamp()); | ||
| 1489 | var end_buf: [proto.end_req_max_len]u8 = undefined; | ||
| 1490 | const sent = switch (asked) { | ||
| 1491 | // The FORCE is the second press, not a second frame: the | ||
| 1492 | // daemon refused the first and this says the user meant it. | ||
| 1493 | .end, .end_force => transport.writeFrame(.end_req, proto.encodeEndReq( | ||
| 1494 | &end_buf, | ||
| 1495 | asked == .end_force, | ||
| 1496 | proto.wireName(t.r.session), | ||
| 1497 | )), | ||
| 1498 | else => transport.writeFrame(.sessions_req, ""), | ||
| 1499 | }; | ||
| 1500 | sent catch { | ||
| 1501 | pending.clear(); | ||
| 1502 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1503 | continue :outer; | ||
| 1504 | }; | ||
| 1505 | } | ||
| 1506 | |||
| 1507 | // A chord that was never answered. Said with the banner rather than | ||
| 1508 | // stderr for the client's reason: the terminal is in raw mode on | ||
| 1509 | // the alternate screen and a print there lands mid-grid. Reachable | ||
| 1510 | // at all because the poll below is capped at 100ms. | ||
| 1511 | // | ||
| 1512 | // Read before `expired` spends it: the sentence names the verb the | ||
| 1513 | // daemon did not know, and "no session list" over an `x` sends the | ||
| 1514 | // user looking at the wrong end of the wire. | ||
| 1515 | const waiting = pending.intent; | ||
| 1516 | if (pending.expired(std.time.milliTimestamp())) | ||
| 1517 | core.banner(switch (waiting) { | ||
| 1518 | .end, .end_force => "[daemon too old to end a session]", | ||
| 1519 | else => "[no session list: upgrade muxd]", | ||
| 1520 | }); | ||
| 1521 | |||
| 1522 | // FRAMES BEFORE KEYS, and that order is load-bearing rather than | ||
| 1523 | // arbitrary. What the session has already said must be known before | ||
| 1524 | // what the user is saying is interpreted, because the interpreting | ||
| 1525 | // depends on it: `Core.forward` splits a wheel notch by whether the | ||
| 1526 | // session's application asked for the mouse, and that fact arrives | ||
| 1527 | // in a `term_modes` frame. A pass that holds both a readable frame | ||
| 1528 | // and a mailbox of keystrokes and drains the mailbox first judges | ||
| 1529 | // the keystrokes against a session it has not finished listening | ||
| 1530 | // to — the plain client's loop read frames first, and an | ||
| 1531 | // application holding the mouse lost its first wheel notch to this | ||
| 1532 | // client's scrollback the moment the order was reversed. Found by | ||
| 1533 | // the suite, not by reading. | ||
| 1534 | // | ||
| 1535 | // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames | ||
| 1536 | // can arrive from the stream layer with the socket never going | ||
| 1537 | // readable. | ||
| 1538 | if (fdbuf[0].revents != 0 or transport.link == .quic) frames: { | ||
| 1539 | while (true) { | ||
| 1540 | const incoming = transport.readFrame(alloc) catch return; | ||
| 1541 | const frame = switch (incoming) { | ||
| 1542 | .frame => |f| f, | ||
| 1543 | .incomplete => break :frames, | ||
| 1544 | .closed => { | ||
| 1545 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1546 | continue :outer; | ||
| 1547 | }, | ||
| 1548 | }; | ||
| 1549 | defer frame.deinit(alloc); | ||
| 1550 | // Everything the frame means to the replica and the screen, | ||
| 1551 | // in the one place that switch lives. The Core paints at | ||
| 1552 | // this tile's offset through the sink, and writes side | ||
| 1553 | // channels only while it holds the claim — so a tile that | ||
| 1554 | // is not focused still keeps its replica hot and its grid | ||
| 1555 | // on its rect, but its title and mouse modes reach no | ||
| 1556 | // terminal until the focus comes to it. | ||
| 1557 | // | ||
| 1558 | // The one exception is deliberate and is `.pty_mode`: the | ||
| 1559 | // Core feeds the overlay's mode whatever the claim, because | ||
| 1560 | // the pty's line discipline is the entire gate on | ||
| 1561 | // speculation — a password prompt must never be predicted — | ||
| 1562 | // and a claim has to start from the truth rather than | ||
| 1563 | // from `.never` and a round trip. | ||
| 1564 | const routed = core.frame(frame.type, frame.payload) catch break :frames; | ||
| 1565 | switch (routed) { | ||
| 1566 | .skip, .handled => {}, | ||
| 1567 | .state => { | ||
| 1568 | if (state != .up) { | ||
| 1569 | state = .up; | ||
| 1570 | t.ever_up.store(true, .release); | ||
| 1571 | paintLabel(t, state); | ||
| 1572 | } | ||
| 1573 | }, | ||
| 1574 | // The replica is suspect, not the transport: re-attach | ||
| 1575 | // quoting (0,0) explicitly — a quoted seq would invite | ||
| 1576 | // the delta that cannot fix us. | ||
| 1577 | .resync => { | ||
| 1578 | core.rep.state_since_attach = false; | ||
| 1579 | // A resync renames the absolute row space, so a | ||
| 1580 | // held drag now names rows nobody selected — and | ||
| 1581 | // on an EPOCH change `sel_range` still matches it, | ||
| 1582 | // so an in-flight reply would copy the new session's | ||
| 1583 | // text. `redial` drops it for this reason; this path | ||
| 1584 | // re-attaches without going through it. | ||
| 1585 | core.drag.clear(); | ||
| 1586 | sendAttach(t, &transport, 0, 0) catch return; | ||
| 1587 | }, | ||
| 1588 | .not_mine => switch (frame.type) { | ||
| 1589 | .exit_status => { | ||
| 1590 | // Before any replay frame this is the refusal | ||
| 1591 | // path; after, the session really ended. | ||
| 1592 | const landed = core.rep.state_since_attach; | ||
| 1593 | state = if (landed) .exited else .refused; | ||
| 1594 | paintLabel(t, state); | ||
| 1595 | endWith( | ||
| 1596 | t, | ||
| 1597 | if (landed) .exited else .refused, | ||
| 1598 | if (landed and frame.payload.len >= 1) frame.payload[0] else 1, | ||
| 1599 | ); | ||
| 1600 | return; | ||
| 1601 | }, | ||
| 1602 | .taken_over => { | ||
| 1603 | // Unsent by this daemon (wire-compat only), but | ||
| 1604 | // the plain client had an answer for it and the | ||
| 1605 | // converged one keeps it: somebody else took the | ||
| 1606 | // session, so this tile is finished rather than | ||
| 1607 | // reconnecting into a fight over the grid. | ||
| 1608 | state = .exited; | ||
| 1609 | paintLabel(t, state); | ||
| 1610 | endWith(t, .taken, 0); | ||
| 1611 | return; | ||
| 1612 | }, | ||
| 1613 | .sessions_reply => { | ||
| 1614 | // Gated on the intent this tile's own chord | ||
| 1615 | // armed: only a question we asked may move the | ||
| 1616 | // focus, so an unasked-for reply is ignored. The | ||
| 1617 | // intent is SPENT here whichever name it picks | ||
| 1618 | // — one question, one answer. | ||
| 1619 | var name_buf: [proto.session_name_max]u8 = undefined; | ||
| 1620 | // A list is only ever asked for to NAME a new | ||
| 1621 | // session: `n`/`p` walk the wall's own tiles and | ||
| 1622 | // ask nothing, and the end verbs are answered by | ||
| 1623 | // `end_reply`. Anything else here is a reply to | ||
| 1624 | // a question this tile did not put. | ||
| 1625 | const pick: ?[]const u8 = switch (pending.take()) { | ||
| 1626 | .new => client.nextFreeName(&name_buf, frame.payload), | ||
| 1627 | else => null, | ||
| 1628 | }; | ||
| 1629 | if (pick) |p| postAnswer(t, p); | ||
| 1630 | }, | ||
| 1631 | .end_reply => { | ||
| 1632 | const r = proto.parseEndReply(frame.payload) orelse break :frames; | ||
| 1633 | _ = pending.take(); | ||
| 1634 | onEndReply(t, r, std.time.milliTimestamp()); | ||
| 1635 | // An ACCEPTED end says nothing: the hangup's | ||
| 1636 | // `exit_status` is on its way and the `.exited` | ||
| 1637 | // path narrates it, so a banner here would be | ||
| 1638 | // the client congratulating itself ahead of the | ||
| 1639 | // daemon. Not a `break` either — that exit | ||
| 1640 | // status is very likely the next frame in this | ||
| 1641 | // same pass, and leaving it for the next poll | ||
| 1642 | // is 100ms of a tile that is already gone. | ||
| 1643 | if (!r.accepted) { | ||
| 1644 | var b: [96]u8 = undefined; | ||
| 1645 | core.banner(if (r.others == 0) | ||
| 1646 | endRefusal(r.reason) | ||
| 1647 | else | ||
| 1648 | std.fmt.bufPrint(&b, "[{d} other{s} attached - x again to end]", .{ | ||
| 1649 | r.others, | ||
| 1650 | if (r.others == 1) "" else "s", | ||
| 1651 | }) catch "[others attached - x again to end]"); | ||
| 1652 | } | ||
| 1653 | }, | ||
| 1654 | .selection_reply => copySelection(t, alloc, &core, frame.payload), | ||
| 1655 | .agent_open => { | ||
| 1656 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | ||
| 1657 | // Every refusal is the same answer on the wire — | ||
| 1658 | // a channel that closes without a byte, which the | ||
| 1659 | // far side's ssh reads as "no agent" and gives up | ||
| 1660 | // on rather than hanging. Only one of the four | ||
| 1661 | // reasons is ordinary — no agent on this machine; | ||
| 1662 | // the rest are a daemon asking for something it | ||
| 1663 | // should not. | ||
| 1664 | const opened = openAgentChan( | ||
| 1665 | &agent_locals, | ||
| 1666 | id, | ||
| 1667 | t.r.agent, | ||
| 1668 | std.posix.getenv(proto.agent_sock_env) orelse "", | ||
| 1669 | ); | ||
| 1670 | if (!opened) | ||
| 1671 | transport.writeFrame( | ||
| 1672 | .agent_close, | ||
| 1673 | &proto.encodeAgentId(id), | ||
| 1674 | ) catch {}; | ||
| 1675 | }, | ||
| 1676 | .agent_data => if (!deliverAgentData( | ||
| 1677 | &agent_locals, | ||
| 1678 | frame.payload, | ||
| 1679 | &transport, | ||
| 1680 | )) break :frames, | ||
| 1681 | .agent_close => { | ||
| 1682 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | ||
| 1683 | // Silent, mirroring the daemon: it has already | ||
| 1684 | // retired this id, so an `agent_close` back would | ||
| 1685 | // be an echo it has to learn to ignore. | ||
| 1686 | if (findLocal(&agent_locals, id)) |s| { | ||
| 1687 | const ch = agent_locals[s].?; | ||
| 1688 | agent_locals[s] = null; | ||
| 1689 | std.posix.close(ch.fd); | ||
| 1690 | } | ||
| 1691 | }, | ||
| 1692 | // MsgType is an open enum, so the compiler still | ||
| 1693 | // wants an arm for everything `.not_mine` cannot be. | ||
| 1694 | else => {}, | ||
| 1695 | }, | ||
| 1696 | } | ||
| 1697 | // Only the socket link guarantees one readable event is | ||
| 1698 | // one frame; QUIC may have buffered more. | ||
| 1699 | // | ||
| 1700 | // But "one event, one frame" is not "one pass, one frame". | ||
| 1701 | // A resync is a BURST — snapshot, pty mode, title, terminal | ||
| 1702 | // modes — and stopping after the first leaves the rest to be | ||
| 1703 | // read a pass at a time, with the user's keystrokes | ||
| 1704 | // interleaved between them. `Core.forward` decides what a | ||
| 1705 | // wheel notch MEANS from the session's terminal modes, so a | ||
| 1706 | // notch judged before the `term_modes` at the END of that | ||
| 1707 | // burst is stolen for this client's scrollback instead of | ||
| 1708 | // reaching the application that asked for the mouse. A | ||
| 1709 | // single-threaded client hid the hazard by being busy | ||
| 1710 | // painting the snapshot when the notch arrived; a keyboard | ||
| 1711 | // on its own thread notices it immediately, and the suite | ||
| 1712 | // caught it two runs out of three. | ||
| 1713 | // | ||
| 1714 | // So: drain what has already ARRIVED. A zero timeout waits | ||
| 1715 | // for nothing, which is what keeps this a drain and not a | ||
| 1716 | // second blocking read. | ||
| 1717 | // | ||
| 1718 | // It cannot starve the rest of the pass, and two measured | ||
| 1719 | // facts are why. The daemon coalesces its deltas, so a | ||
| 1720 | // loud session hands over a bounded burst and `poll(0)` | ||
| 1721 | // finds the socket empty within it rather than a stream | ||
| 1722 | // that refills as fast as it is read. And the keyboard is | ||
| 1723 | // a different thread with its own 400ms bound on the one | ||
| 1724 | // thing it ever waits for a pump to do (`awaitDetach`), so | ||
| 1725 | // even a pump that did stall here could not hold the | ||
| 1726 | // terminal hostage — which is the failure this would | ||
| 1727 | // otherwise have to be argued safe against. | ||
| 1728 | if (transport.link != .quic) { | ||
| 1729 | var more = [_]std.posix.pollfd{ | ||
| 1730 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 1731 | }; | ||
| 1732 | const ready = std.posix.poll(&more, 0) catch 0; | ||
| 1733 | if (ready == 0 or more[0].revents == 0) break :frames; | ||
| 1734 | } | ||
| 1735 | } | ||
| 1736 | } | ||
| 1737 | |||
| 1738 | // The other direction: what this machine's agent answered, back out | ||
| 1739 | // as `agent_data`. AFTER the frame drain, deliberately — the table | ||
| 1740 | // this walks is then the one the daemon's latest word left behind, | ||
| 1741 | // so a channel it has just closed is already gone rather than read | ||
| 1742 | // once more on its way out. | ||
| 1743 | for (2..nfds) |i| { | ||
| 1744 | if (fdbuf[i].revents == 0) continue; | ||
| 1745 | const s = at[i - 2]; | ||
| 1746 | const ch = agent_locals[s] orelse continue; | ||
| 1747 | // The id is written into the head of the very buffer the read | ||
| 1748 | // fills, so a frame costs no second copy. The read is capped at | ||
| 1749 | // the wire's `agent_data_max` because that cap is what keeps one | ||
| 1750 | // busy channel from holding the link against the session's own | ||
| 1751 | // bytes — the same bound the daemon reads its end with. | ||
| 1752 | var buf: [proto.agent_id_len + proto.agent_data_max]u8 = undefined; | ||
| 1753 | buf[0..proto.agent_id_len].* = proto.encodeAgentId(ch.id); | ||
| 1754 | // DONTWAIT, and it is load-bearing rather than belt-and-braces: | ||
| 1755 | // the poll above happened before the frame drain, so the daemon | ||
| 1756 | // may since have closed this slot's channel and an `agent_open` | ||
| 1757 | // in the same burst may have refilled the slot with a fresh | ||
| 1758 | // connection that has said nothing yet. A blocking read there | ||
| 1759 | // would stop the whole tile — its keystrokes, its paints — on a | ||
| 1760 | // socket that is merely idle. `agent_locals[s]` is re-read above, | ||
| 1761 | // so whatever this does return is attributed to the id that | ||
| 1762 | // actually owns the fd. | ||
| 1763 | // | ||
| 1764 | // EOF and a broken connection are one case: the agent is done | ||
| 1765 | // with this channel either way, and the far side needs to hear so. | ||
| 1766 | const n = std.posix.recv( | ||
| 1767 | ch.fd, | ||
| 1768 | buf[proto.agent_id_len..], | ||
| 1769 | std.posix.MSG.DONTWAIT, | ||
| 1770 | ) catch |err| switch (err) { | ||
| 1771 | error.WouldBlock => continue, | ||
| 1772 | else => 0, | ||
| 1773 | }; | ||
| 1774 | if (n == 0) { | ||
| 1775 | closeLocal(&agent_locals, s, &transport); | ||
| 1776 | continue; | ||
| 1777 | } | ||
| 1778 | transport.writeFrame(.agent_data, buf[0 .. proto.agent_id_len + n]) catch { | ||
| 1779 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1780 | continue :outer; | ||
| 1781 | }; | ||
| 1782 | } | ||
| 1783 | |||
| 1784 | // Whatever the keyboard left, through everything a plain client's | ||
| 1785 | // keystrokes go through: the mouse split, the wheel, alternate | ||
| 1786 | // scroll, the scrollback view, the prediction and the input frame. | ||
| 1787 | // Only the focused tile's mailbox is ever written, but every pump | ||
| 1788 | // drains its own — an empty mailbox is the common, cheap answer. | ||
| 1789 | var keys_buf: [mailbox_max]u8 = undefined; | ||
| 1790 | const keys = takeKeys(t, &keys_buf); | ||
| 1791 | if (keys.len > 0) { | ||
| 1792 | // A paint that would not allocate is not a dead link: the | ||
| 1793 | // replica is untouched and the next frame redraws from it. | ||
| 1794 | const step = core.forward(&transport, keys) catch interact.Step.ok; | ||
| 1795 | if (step == .lost) { | ||
| 1796 | if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return; | ||
| 1797 | continue :outer; | ||
| 1798 | } | ||
| 1799 | // Input is moving again, so "input dropped" has stopped being | ||
| 1800 | // news. Repainted rather than merely cleared, because the bar | ||
| 1801 | // is still carrying the old sentence until something draws | ||
| 1802 | // over it — and only when there is a bar, which `paintLabel` | ||
| 1803 | // already decides. | ||
| 1804 | if (t.in_dropped.swap(false, .acq_rel)) paintLabel(t, state); | ||
| 1805 | } | ||
| 1806 | |||
| 1807 | // A prediction the daemon never answered must not sit on the | ||
| 1808 | // screen forever, and only the clock can say so — no frame will. | ||
| 1809 | // Only while focused: an unfocused tile's rect shows no prediction | ||
| 1810 | // to retire. | ||
| 1811 | if (focused) { | ||
| 1812 | core.idle() catch {}; | ||
| 1813 | publishStats(t.shared, core.overlay.counters); | ||
| 1814 | } | ||
| 1815 | |||
| 1816 | // A relayout re-cut the stripes (or cleared the screen for an empty | ||
| 1817 | // wall), and a quiet session sends nothing to trigger a repaint. | ||
| 1818 | // The replica is current — the SCREEN is not — so the generation is | ||
| 1819 | // what puts it back. Checked on the poll timeout, so a tile comes | ||
| 1820 | // back within ~100ms of a relayout whether or not its session ever | ||
| 1821 | // speaks again. The drag clears here too: a relayout moved the rect | ||
| 1822 | // a held drag's anchor was resolved against. | ||
| 1823 | const gen = t.shared.repaint_gen.load(.acquire); | ||
| 1824 | if (gen != painted_gen) { | ||
| 1825 | core.drag.clear(); | ||
| 1826 | paintLabel(t, state); | ||
| 1827 | // A 0-row rect (fullscreened out) paints nothing: the daemon | ||
| 1828 | // refused the 0×0 resize, so the session keeps its real grid. | ||
| 1829 | if (snap_view_rows > 0) core.repaint() catch {}; | ||
| 1830 | painted_gen = gen; | ||
| 1831 | } | ||
| 1832 | } | ||
| 1833 | } | ||
| 1834 | |||
| 1835 | /// Move the focus to tile `next`. Client-local: decides which pump owns the | 790 | /// Move the focus to tile `next`. Client-local: decides which pump owns the |
| 1836 | /// terminal's modes, not which session the daemon hears. The outgoing tile's | 791 | /// terminal's modes, not which session the daemon hears. The outgoing tile's |
| 1837 | /// `session_release` is written HERE, under `paint_mu`, so the handover is | 792 | /// `session_release` is written HERE, under `paint_mu`, so the handover is |
| @@ -2267,7 +1222,7 @@ fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize, | |||
| 2267 | } | 1222 | } |
| 2268 | 1223 | ||
| 2269 | pub fn spawnPump(t: *Tile) void { | 1224 | pub fn spawnPump(t: *Tile) void { |
| 2270 | const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch { | 1225 | const th = std.Thread.spawn(.{}, wall_pump.pumpTile, .{t}) catch { |
| 2271 | // A tile with no thread is a tile nothing will ever paint — the | 1226 | // A tile with no thread is a tile nothing will ever paint — the |
| 2272 | // same hole `pumpTile`'s exit closes, reached without the pump | 1227 | // same hole `pumpTile`'s exit closes, reached without the pump |
| 2273 | // having run at all. Marked here so the keyboard paints its bar and | 1228 | // having run at all. Marked here so the keyboard paints its bar and |
| @@ -2827,17 +1782,6 @@ pub fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[ | |||
| 2827 | return proto.resolveName(es); | 1782 | return proto.resolveName(es); |
| 2828 | } | 1783 | } |
| 2829 | 1784 | ||
| 2830 | /// A refusal in THIS client's words. `parseEndReply` hands back the frame's | ||
| 2831 | /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's | ||
| 2832 | /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants. | ||
| 2833 | fn endRefusal(reason: []const u8) []const u8 { | ||
| 2834 | if (std.mem.eql(u8, reason, proto.end_reason.no_session)) | ||
| 2835 | return "[no such session on that daemon]"; | ||
| 2836 | if (std.mem.eql(u8, reason, proto.end_reason.bad_frame)) | ||
| 2837 | return "[the daemon could not read the end request]"; | ||
| 2838 | return "[the daemon refused to end this session]"; | ||
| 2839 | } | ||
| 2840 | |||
| 2841 | /// For a caller already holding `paint_mu`. | 1785 | /// For a caller already holding `paint_mu`. |
| 2842 | fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 { | 1786 | fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 { |
| 2843 | const said = wall_picker.peekNoticeLocked(shared, out); | 1787 | const said = wall_picker.peekNoticeLocked(shared, out); |
| @@ -4370,7 +3314,7 @@ test "claimAllowed: a stale arm does not claim even when the sink would now admi | |||
| 4370 | // in this file that puts the two in that order. | 3314 | // in this file that puts the two in that order. |
| 4371 | const rect: proto.Size = .{ .cols = 80, .rows = 24 }; | 3315 | const rect: proto.Size = .{ .cols = 80, .rows = 24 }; |
| 4372 | _ = t.claim_pending.swap(false, .acq_rel); | 3316 | _ = t.claim_pending.swap(false, .acq_rel); |
| 4373 | try std.testing.expectEqual(ClaimStep.dropped, claimFocus(&t, &core, rect)); | 3317 | try std.testing.expectEqual(ClaimStep.dropped, wall_pump.claimFocus(&t, &core, rect)); |
| 4374 | 3318 | ||
| 4375 | // Nothing claimed, and nothing on the terminal: `session_claim` is the | 3319 | // Nothing claimed, and nothing on the terminal: `session_claim` is the |
| 4376 | // mouse modes, and a second tile setting them is what leaves one | 3320 | // mouse modes, and a second tile setting them is what leaves one |
| @@ -4382,7 +3326,7 @@ test "claimAllowed: a stale arm does not claim even when the sink would now admi | |||
| 4382 | // The control: the same call on the tile that DOES hold the focus takes | 3326 | // The control: the same call on the tile that DOES hold the focus takes |
| 4383 | // it, so the guard is a focus test and not a blanket refusal. | 3327 | // it, so the guard is a focus test and not a blanket refusal. |
| 4384 | shared.sel = 2; | 3328 | shared.sel = 2; |
| 4385 | try std.testing.expectEqual(ClaimStep.done, claimFocus(&t, &core, rect)); | 3329 | try std.testing.expectEqual(ClaimStep.done, wall_pump.claimFocus(&t, &core, rect)); |
| 4386 | try std.testing.expectEqual(interact.Claim.session, core.claim); | 3330 | try std.testing.expectEqual(interact.Claim.session, core.claim); |
| 4387 | try std.testing.expect((std.posix.read(pipe[0], &buf) catch 0) > 0); | 3331 | try std.testing.expect((std.posix.read(pipe[0], &buf) catch 0) > 0); |
| 4388 | } | 3332 | } |
| @@ -4393,7 +3337,7 @@ test "afterClaim: a claim the picker refused is armed again for this tile" { | |||
| 4393 | // against a hard-coded 0 would pass here and nowhere else. | 3337 | // against a hard-coded 0 would pass here and nowhere else. |
| 4394 | shared.sel = 2; | 3338 | shared.sel = 2; |
| 4395 | var t = claimBench(&shared, 2); | 3339 | var t = claimBench(&shared, 2); |
| 4396 | try std.testing.expectEqual(ClaimStep.rearmed, afterClaim(&t, false, false, true)); | 3340 | try std.testing.expectEqual(ClaimStep.rearmed, wall_pump.afterClaim(&t, false, false, true)); |
| 4397 | try std.testing.expect(t.claim_pending.load(.acquire)); | 3341 | try std.testing.expect(t.claim_pending.load(.acquire)); |
| 4398 | } | 3342 | } |
| 4399 | 3343 | ||
| @@ -4406,7 +3350,7 @@ test "afterClaim: an arm does not outlive the focus that earned it" { | |||
| 4406 | // resting on the loser, and the release already consumed so nothing | 3350 | // resting on the loser, and the release already consumed so nothing |
| 4407 | // takes either off. | 3351 | // takes either off. |
| 4408 | shared.sel = 1; | 3352 | shared.sel = 1; |
| 4409 | try std.testing.expectEqual(ClaimStep.dropped, afterClaim(&t, false, false, true)); | 3353 | try std.testing.expectEqual(ClaimStep.dropped, wall_pump.afterClaim(&t, false, false, true)); |
| 4410 | try std.testing.expect(!t.claim_pending.load(.acquire)); | 3354 | try std.testing.expect(!t.claim_pending.load(.acquire)); |
| 4411 | } | 3355 | } |
| 4412 | 3356 | ||
| @@ -4415,12 +3359,12 @@ test "afterClaim: only the SINK's refusal is worth another pass" { | |||
| 4415 | shared.sel = 2; | 3359 | shared.sel = 2; |
| 4416 | var t = claimBench(&shared, 2); | 3360 | var t = claimBench(&shared, 2); |
| 4417 | // A claim that landed owes nothing. | 3361 | // A claim that landed owes nothing. |
| 4418 | try std.testing.expectEqual(ClaimStep.done, afterClaim(&t, true, true, true)); | 3362 | try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, true, true, true)); |
| 4419 | // A Core that ALREADY holds it answers false too, and re-arming that | 3363 | // A Core that ALREADY holds it answers false too, and re-arming that |
| 4420 | // would re-take the focus notice on every pass, forever. | 3364 | // would re-take the focus notice on every pass, forever. |
| 4421 | try std.testing.expectEqual(ClaimStep.done, afterClaim(&t, false, true, true)); | 3365 | try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, true, true)); |
| 4422 | // No terminal, nothing to hold: a piped `mux` is one tile and no claim. | 3366 | // No terminal, nothing to hold: a piped `mux` is one tile and no claim. |
| 4423 | try std.testing.expectEqual(ClaimStep.done, afterClaim(&t, false, false, false)); | 3367 | try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, false, false)); |
| 4424 | // None of the three armed anything. | 3368 | // None of the three armed anything. |
| 4425 | try std.testing.expect(!t.claim_pending.load(.acquire)); | 3369 | try std.testing.expect(!t.claim_pending.load(.acquire)); |
| 4426 | } | 3370 | } |
| @@ -4439,10 +3383,10 @@ test "tilePaintBegin: no tile paints while the picker owns the screen" { | |||
| 4439 | // CLAIM fail under it (`Core.claimTerminal` writes through the same | 3383 | // CLAIM fail under it (`Core.claimTerminal` writes through the same |
| 4440 | // sink) — which is why the pump re-arms `claim_pending` instead of | 3384 | // sink) — which is why the pump re-arms `claim_pending` instead of |
| 4441 | // treating a false return as "already claimed". | 3385 | // treating a false return as "already claimed". |
| 4442 | try std.testing.expect(tilePaintBegin(&t)); | 3386 | try std.testing.expect(wall_pump.tilePaintBegin(&t)); |
| 4443 | tilePaintEnd(&t); | 3387 | wall_pump.tilePaintEnd(&t); |
| 4444 | shared.picker_open.store(true, .release); | 3388 | shared.picker_open.store(true, .release); |
| 4445 | try std.testing.expect(!tilePaintBegin(&t)); | 3389 | try std.testing.expect(!wall_pump.tilePaintBegin(&t)); |
| 4446 | // Refused WITHOUT holding the lock: a begin that returned false and | 3390 | // Refused WITHOUT holding the lock: a begin that returned false and |
| 4447 | // kept `paint_mu` would wedge the keyboard on its next paint. | 3391 | // kept `paint_mu` would wedge the keyboard on its next paint. |
| 4448 | try std.testing.expect(shared.paint_mu.tryLock()); | 3392 | try std.testing.expect(shared.paint_mu.tryLock()); |
| @@ -5018,23 +3962,23 @@ test "endKey: x on a tile that never came up closes the TILE — there is no ses | |||
| 5018 | test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" { | 3962 | test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" { |
| 5019 | try std.testing.expectEqualStrings( | 3963 | try std.testing.expectEqualStrings( |
| 5020 | "[no such session on that daemon]", | 3964 | "[no such session on that daemon]", |
| 5021 | endRefusal(proto.end_reason.no_session), | 3965 | wall_pump.endRefusal(proto.end_reason.no_session), |
| 5022 | ); | 3966 | ); |
| 5023 | try std.testing.expectEqualStrings( | 3967 | try std.testing.expectEqualStrings( |
| 5024 | "[the daemon could not read the end request]", | 3968 | "[the daemon could not read the end request]", |
| 5025 | endRefusal(proto.end_reason.bad_frame), | 3969 | wall_pump.endRefusal(proto.end_reason.bad_frame), |
| 5026 | ); | 3970 | ); |
| 5027 | // Anything the daemon does not say: an OSC title set, a CSI, or 260 | 3971 | // Anything the daemon does not say: an OSC title set, a CSI, or 260 |
| 5028 | // bytes of nothing. All three used to reach the terminal verbatim. | 3972 | // bytes of nothing. All three used to reach the terminal verbatim. |
| 5029 | try std.testing.expectEqualStrings( | 3973 | try std.testing.expectEqualStrings( |
| 5030 | "[the daemon refused to end this session]", | 3974 | "[the daemon refused to end this session]", |
| 5031 | endRefusal("\x1b]0;pwned\x07"), | 3975 | wall_pump.endRefusal("\x1b]0;pwned\x07"), |
| 5032 | ); | 3976 | ); |
| 5033 | try std.testing.expectEqualStrings( | 3977 | try std.testing.expectEqualStrings( |
| 5034 | "[the daemon refused to end this session]", | 3978 | "[the daemon refused to end this session]", |
| 5035 | endRefusal("\x1b[2J"), | 3979 | wall_pump.endRefusal("\x1b[2J"), |
| 5036 | ); | 3980 | ); |
| 5037 | try std.testing.expectEqualStrings("[the daemon refused to end this session]", endRefusal("")); | 3981 | try std.testing.expectEqualStrings("[the daemon refused to end this session]", wall_pump.endRefusal("")); |
| 5038 | } | 3982 | } |
| 5039 | 3983 | ||
| 5040 | test "pickerRepaint: a screen cleared under the spelling editor still owes a paint, carrying the half-typed line" { | 3984 | test "pickerRepaint: a screen cleared under the spelling editor still owes a paint, carrying the half-typed line" { |
| @@ -5604,7 +4548,7 @@ test "sendKeys: a chunk that does not fit is dropped whole, and says so" { | |||
| 5604 | // the next chunk cannot splice onto a half-delivered one. | 4548 | // the next chunk cannot splice onto a half-delivered one. |
| 5605 | sendKeys(&t, "def"); | 4549 | sendKeys(&t, "def"); |
| 5606 | var out: [mailbox_max]u8 = undefined; | 4550 | var out: [mailbox_max]u8 = undefined; |
| 5607 | try std.testing.expectEqualStrings("abcdef", takeKeys(&t, &out)); | 4551 | try std.testing.expectEqualStrings("abcdef", wall_pump.takeKeys(&t, &out)); |
| 5608 | 4552 | ||
| 5609 | // Exactly filling it is not overflow — the bound is `>`, not `>=`. | 4553 | // Exactly filling it is not overflow — the bound is `>`, not `>=`. |
| 5610 | const exact = [_]u8{'q'} ** mailbox_max; | 4554 | const exact = [_]u8{'q'} ** mailbox_max; |
| @@ -6002,7 +4946,7 @@ const ResizeWitness = struct { | |||
| 6002 | 4946 | ||
| 6003 | fn run(w: *ResizeWitness) void { | 4947 | fn run(w: *ResizeWitness) void { |
| 6004 | while (!w.stop.load(.acquire)) { | 4948 | while (!w.stop.load(.acquire)) { |
| 6005 | const p = takePass(w.t); | 4949 | const p = wall_pump.takePass(w.t); |
| 6006 | if (p.resize) w.sent.store(pack(p.cols, p.rows -| p.label_rows), .release); | 4950 | if (p.resize) w.sent.store(pack(p.cols, p.rows -| p.label_rows), .release); |
| 6007 | _ = w.passes.fetchAdd(1, .release); | 4951 | _ = w.passes.fetchAdd(1, .release); |
| 6008 | } | 4952 | } |
| @@ -6631,7 +5575,7 @@ test "a copy too big for OSC 52 is said out loud rather than dropped" { | |||
| 6631 | defer alloc.free(rbuf); | 5575 | defer alloc.free(rbuf); |
| 6632 | const payload = replyBytes(rbuf, core.sel_id, .ok, 0, big); | 5576 | const payload = replyBytes(rbuf, core.sel_id, .ok, 0, big); |
| 6633 | 5577 | ||
| 6634 | copySelection(&t, alloc, &core, payload); | 5578 | wall_pump.copySelection(&t, alloc, &core, payload); |
| 6635 | var buf: [8192]u8 = undefined; | 5579 | var buf: [8192]u8 = undefined; |
| 6636 | const said = readAvail(p[0], &buf); | 5580 | const said = readAvail(p[0], &buf); |
| 6637 | try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null); | 5581 | try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null); |
| @@ -6973,15 +5917,15 @@ test "agent channels: slots fill in order, a full table refuses, and a close is | |||
| 6973 | 5917 | ||
| 6974 | for (0..3) |i| try std.testing.expectEqual( | 5918 | for (0..3) |i| try std.testing.expectEqual( |
| 6975 | @as(?usize, i), | 5919 | @as(?usize, i), |
| 6976 | storeLocal(&locals, @intCast(10 + i), chans[i]), | 5920 | wall_pump.storeLocal(&locals, @intCast(10 + i), chans[i]), |
| 6977 | ); | 5921 | ); |
| 6978 | // The full table the pump answers with `agent_close`. The fourth fd stays | 5922 | // The full table the pump answers with `agent_close`. The fourth fd stays |
| 6979 | // this test's to close: a refused store never took ownership of it. | 5923 | // this test's to close: a refused store never took ownership of it. |
| 6980 | try std.testing.expectEqual(@as(?usize, null), storeLocal(&locals, 99, chans[3])); | 5924 | try std.testing.expectEqual(@as(?usize, null), wall_pump.storeLocal(&locals, 99, chans[3])); |
| 6981 | std.posix.close(chans[3]); | 5925 | std.posix.close(chans[3]); |
| 6982 | 5926 | ||
| 6983 | try std.testing.expectEqual(@as(?usize, 1), findLocal(&locals, 11)); | 5927 | try std.testing.expectEqual(@as(?usize, 1), wall_pump.findLocal(&locals, 11)); |
| 6984 | try std.testing.expectEqual(@as(?usize, null), findLocal(&locals, 99)); | 5928 | try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 99)); |
| 6985 | 5929 | ||
| 6986 | // A pipe for the link, which is what a `.fd` transport writes to. The | 5930 | // A pipe for the link, which is what a `.fd` transport writes to. The |
| 6987 | // frame that comes back out of it is the daemon's only notice that this | 5931 | // frame that comes back out of it is the daemon's only notice that this |
| @@ -6991,7 +5935,7 @@ test "agent channels: slots fill in order, a full table refuses, and a close is | |||
| 6991 | defer std.posix.close(link[1]); | 5935 | defer std.posix.close(link[1]); |
| 6992 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; | 5936 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; |
| 6993 | 5937 | ||
| 6994 | closeLocal(&locals, 1, &transport); | 5938 | wall_pump.closeLocal(&locals, 1, &transport); |
| 6995 | try std.testing.expectEqual(@as(?AgentLocal, null), locals[1]); | 5939 | try std.testing.expectEqual(@as(?AgentLocal, null), locals[1]); |
| 6996 | try std.testing.expect(peerClosed(peers[1])); | 5940 | try std.testing.expect(peerClosed(peers[1])); |
| 6997 | 5941 | ||
| @@ -7003,7 +5947,7 @@ test "agent channels: slots fill in order, a full table refuses, and a close is | |||
| 7003 | 5947 | ||
| 7004 | // The redial's sweep: every channel still open goes, and nothing is said | 5948 | // The redial's sweep: every channel still open goes, and nothing is said |
| 7005 | // — the connection that owned them is the one that just died. | 5949 | // — the connection that owned them is the one that just died. |
| 7006 | dropLocals(&locals); | 5950 | wall_pump.dropLocals(&locals); |
| 7007 | for (locals) |c| try std.testing.expectEqual(@as(?AgentLocal, null), c); | 5951 | for (locals) |c| try std.testing.expectEqual(@as(?AgentLocal, null), c); |
| 7008 | try std.testing.expect(peerClosed(peers[0])); | 5952 | try std.testing.expect(peerClosed(peers[0])); |
| 7009 | try std.testing.expect(peerClosed(peers[2])); | 5953 | try std.testing.expect(peerClosed(peers[2])); |
| @@ -7033,7 +5977,7 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 7033 | var locals: [2]?AgentLocal = @splat(null); | 5977 | var locals: [2]?AgentLocal = @splat(null); |
| 7034 | const chan = try std.posix.pipe2(.{ .NONBLOCK = true }); | 5978 | const chan = try std.posix.pipe2(.{ .NONBLOCK = true }); |
| 7035 | defer std.posix.close(chan[0]); | 5979 | defer std.posix.close(chan[0]); |
| 7036 | _ = storeLocal(&locals, 5, chan[1]); | 5980 | _ = wall_pump.storeLocal(&locals, 5, chan[1]); |
| 7037 | 5981 | ||
| 7038 | const link = try std.posix.pipe2(.{ .NONBLOCK = true }); | 5982 | const link = try std.posix.pipe2(.{ .NONBLOCK = true }); |
| 7039 | defer std.posix.close(link[0]); | 5983 | defer std.posix.close(link[0]); |
| @@ -7045,7 +5989,7 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 7045 | var small: [proto.agent_id_len + 2]u8 = undefined; | 5989 | var small: [proto.agent_id_len + 2]u8 = undefined; |
| 7046 | small[0..proto.agent_id_len].* = proto.encodeAgentId(5); | 5990 | small[0..proto.agent_id_len].* = proto.encodeAgentId(5); |
| 7047 | @memcpy(small[proto.agent_id_len..], "hi"); | 5991 | @memcpy(small[proto.agent_id_len..], "hi"); |
| 7048 | try std.testing.expect(deliverAgentData(&locals, &small, &transport)); | 5992 | try std.testing.expect(wall_pump.deliverAgentData(&locals, &small, &transport)); |
| 7049 | var got: [8]u8 = undefined; | 5993 | var got: [8]u8 = undefined; |
| 7050 | try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got)); | 5994 | try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got)); |
| 7051 | try std.testing.expectEqualSlices(u8, "hi", got[0..2]); | 5995 | try std.testing.expectEqualSlices(u8, "hi", got[0..2]); |
| @@ -7056,7 +6000,7 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 7056 | defer std.testing.allocator.free(over); | 6000 | defer std.testing.allocator.free(over); |
| 7057 | @memset(over, 'x'); | 6001 | @memset(over, 'x'); |
| 7058 | over[0..proto.agent_id_len].* = proto.encodeAgentId(5); | 6002 | over[0..proto.agent_id_len].* = proto.encodeAgentId(5); |
| 7059 | try std.testing.expect(deliverAgentData(&locals, over, &transport)); | 6003 | try std.testing.expect(wall_pump.deliverAgentData(&locals, over, &transport)); |
| 7060 | try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]); | 6004 | try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]); |
| 7061 | try std.testing.expect(peerClosed(chan[0])); | 6005 | try std.testing.expect(peerClosed(chan[0])); |
| 7062 | 6006 | ||
| @@ -7065,7 +6009,7 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 7065 | // A payload too short to name a channel: false, and the pump abandons the | 6009 | // A payload too short to name a channel: false, and the pump abandons the |
| 7066 | // batch on it rather than skipping one frame, because a stream that has | 6010 | // batch on it rather than skipping one frame, because a stream that has |
| 7067 | // lost frame alignment is not one to keep reading. | 6011 | // lost frame alignment is not one to keep reading. |
| 7068 | try std.testing.expect(!deliverAgentData(&locals, "ab", &transport)); | 6012 | try std.testing.expect(!wall_pump.deliverAgentData(&locals, "ab", &transport)); |
| 7069 | 6013 | ||
| 7070 | const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 6014 | const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| 7071 | return error.NoAgentCloseOnOversize; | 6015 | return error.NoAgentCloseOnOversize; |
| @@ -7088,13 +6032,13 @@ test "agent forwarding is per tile: no -A offers nothing and opens nothing" { | |||
| 7088 | defer listener.deinit(); | 6032 | defer listener.deinit(); |
| 7089 | 6033 | ||
| 7090 | var locals: [2]?AgentLocal = @splat(null); | 6034 | var locals: [2]?AgentLocal = @splat(null); |
| 7091 | defer dropLocals(&locals); | 6035 | defer wall_pump.dropLocals(&locals); |
| 7092 | 6036 | ||
| 7093 | // The gate, both ways round, against the same reachable agent. | 6037 | // The gate, both ways round, against the same reachable agent. |
| 7094 | try std.testing.expect(!openAgentChan(&locals, 1, false, sock)); | 6038 | try std.testing.expect(!wall_pump.openAgentChan(&locals, 1, false, sock)); |
| 7095 | try std.testing.expectEqual(@as(?usize, null), findLocal(&locals, 1)); | 6039 | try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 1)); |
| 7096 | try std.testing.expect(openAgentChan(&locals, 1, true, sock)); | 6040 | try std.testing.expect(wall_pump.openAgentChan(&locals, 1, true, sock)); |
| 7097 | try std.testing.expect(findLocal(&locals, 1) != null); | 6041 | try std.testing.expect(wall_pump.findLocal(&locals, 1) != null); |
| 7098 | 6042 | ||
| 7099 | // And the daemon does not get told about an agent the tile never | 6043 | // And the daemon does not get told about an agent the tile never |
| 7100 | // offered — the offer is what the daemon routes on, so a stray one | 6044 | // offered — the offer is what the daemon routes on, so a stray one |
| @@ -7113,7 +6057,7 @@ test "agent forwarding is per tile: no -A offers nothing and opens nothing" { | |||
| 7113 | defer std.posix.close(link[1]); | 6057 | defer std.posix.close(link[1]); |
| 7114 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; | 6058 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; |
| 7115 | 6059 | ||
| 7116 | try sendAttach(&t, &transport, 0, 0); | 6060 | try wall_pump.sendAttach(&t, &transport, 0, 0); |
| 7117 | const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 6061 | const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| 7118 | return error.NoAttach; | 6062 | return error.NoAttach; |
| 7119 | defer first.deinit(std.testing.allocator); | 6063 | defer first.deinit(std.testing.allocator); |
| @@ -7127,7 +6071,7 @@ test "agent forwarding is per tile: no -A offers nothing and opens nothing" { | |||
| 7127 | // The positive control on the same pipe: with `-A` the offer follows the | 6071 | // The positive control on the same pipe: with `-A` the offer follows the |
| 7128 | // attach, so the silence above is the gate and not an unwritten frame. | 6072 | // attach, so the silence above is the gate and not an unwritten frame. |
| 7129 | t.r.agent = true; | 6073 | t.r.agent = true; |
| 7130 | try sendAttach(&t, &transport, 0, 0); | 6074 | try wall_pump.sendAttach(&t, &transport, 0, 0); |
| 7131 | const attach = (try proto.readFrame(std.testing.allocator, link[0])).?; | 6075 | const attach = (try proto.readFrame(std.testing.allocator, link[0])).?; |
| 7132 | defer attach.deinit(std.testing.allocator); | 6076 | defer attach.deinit(std.testing.allocator); |
| 7133 | const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 6077 | const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| @@ -7161,7 +6105,7 @@ test "a view tile's attach makes no size claim; a tile the user asked for does" | |||
| 7161 | // A view tile: 0x0 on the wire, and the rect owed to the doorbell. | 6105 | // A view tile: 0x0 on the wire, and the rect owed to the doorbell. |
| 7162 | t.creates = false; | 6106 | t.creates = false; |
| 7163 | t.resize_pending = false; | 6107 | t.resize_pending = false; |
| 7164 | try sendAttach(&t, &transport, 0, 0); | 6108 | try wall_pump.sendAttach(&t, &transport, 0, 0); |
| 7165 | const view = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 6109 | const view = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| 7166 | return error.NoAttach; | 6110 | return error.NoAttach; |
| 7167 | defer view.deinit(std.testing.allocator); | 6111 | defer view.deinit(std.testing.allocator); |
| @@ -7175,7 +6119,7 @@ test "a view tile's attach makes no size claim; a tile the user asked for does" | |||
| 7175 | // and no doorbell is owed. | 6119 | // and no doorbell is owed. |
| 7176 | t.creates = true; | 6120 | t.creates = true; |
| 7177 | t.resize_pending = false; | 6121 | t.resize_pending = false; |
| 7178 | try sendAttach(&t, &transport, 0, 0); | 6122 | try wall_pump.sendAttach(&t, &transport, 0, 0); |
| 7179 | const entry = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 6123 | const entry = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| 7180 | return error.NoAttach; | 6124 | return error.NoAttach; |
| 7181 | defer entry.deinit(std.testing.allocator); | 6125 | defer entry.deinit(std.testing.allocator); |
| @@ -7209,7 +6153,7 @@ test "a newborn tile's first claim is its real stripe, not the placeholder" { | |||
| 7209 | defer std.posix.close(link[0]); | 6153 | defer std.posix.close(link[0]); |
| 7210 | defer std.posix.close(link[1]); | 6154 | defer std.posix.close(link[1]); |
| 7211 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; | 6155 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; |
| 7212 | try sendAttach(&t, &transport, 0, 0); | 6156 | try wall_pump.sendAttach(&t, &transport, 0, 0); |
| 7213 | const fr = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 6157 | const fr = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| 7214 | return error.NoAttach; | 6158 | return error.NoAttach; |
| 7215 | defer fr.deinit(std.testing.allocator); | 6159 | defer fr.deinit(std.testing.allocator); |
| @@ -7257,15 +6201,15 @@ test "the cursor sleeps in the focused tile, whoever painted last" { | |||
| 7257 | // cursor: hide, CUP to the focused tile's stored position, then show | 6201 | // cursor: hide, CUP to the focused tile's stored position, then show |
| 7258 | // again so the cursor rests visible in the focused tile until its own | 6202 | // again so the cursor rests visible in the focused tile until its own |
| 7259 | // next paint — without the trailing show the cursor stays hidden. | 6203 | // next paint — without the trailing show the cursor stays hidden. |
| 7260 | _ = tilePaintBegin(&tiles[1]); | 6204 | _ = wall_pump.tilePaintBegin(&tiles[1]); |
| 7261 | tilePaintEnd(&tiles[1]); | 6205 | wall_pump.tilePaintEnd(&tiles[1]); |
| 7262 | try std.testing.expectEqualStrings("\x1b[?25l\x1b[6;4H\x1b[?25h", readAvail(p[0], &buf)); | 6206 | try std.testing.expectEqualStrings("\x1b[?25l\x1b[6;4H\x1b[?25h", readAvail(p[0], &buf)); |
| 7263 | 6207 | ||
| 7264 | // A FOCUSED paint (tile 0, core null) records nothing and restores | 6208 | // A FOCUSED paint (tile 0, core null) records nothing and restores |
| 7265 | // nothing: no core means the shared cursor is left untouched, and the | 6209 | // nothing: no core means the shared cursor is left untouched, and the |
| 7266 | // pipe receives no new bytes. | 6210 | // pipe receives no new bytes. |
| 7267 | _ = tilePaintBegin(&tiles[0]); | 6211 | _ = wall_pump.tilePaintBegin(&tiles[0]); |
| 7268 | tilePaintEnd(&tiles[0]); | 6212 | wall_pump.tilePaintEnd(&tiles[0]); |
| 7269 | try std.testing.expectEqual(@as(usize, 0), readAvail(p[0], &buf).len); | 6213 | try std.testing.expectEqual(@as(usize, 0), readAvail(p[0], &buf).len); |
| 7270 | } | 6214 | } |
| 7271 | 6215 | ||