a73x

524037c1

refactor: painter, askpass, session table, relay, delta and layout

a73x   2026-08-30 20:06

Commit message
refactor: painter, askpass, session table, relay, delta and layout

60 essays to 16. The rules that still earn their lines: an unpaired sync
commit hides a torn paint from both e2e suites; a refused askpass reads
as an empty password to ssh; a session's env unset is what keeps the
daemon's own agent out of it; stale hashes go unsafe silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUFuYHqU9wr4J5NC8EkWV

docscheck.blocks
Old New
@@ -1,9 +1,9 @@
1 askpass.zig 10 1 askpass.zig 3
2 client_core_wasm_check.zig 0 2 client_core_wasm_check.zig 0
3 client_core.zig 1 3 client_core.zig 1
4 client.zig 15 4 client.zig 15
5 cmd.zig 2 5 cmd.zig 2
6 delta.zig 8 6 delta.zig 2
7 docscheck.zig 4 7 docscheck.zig 4
8 engine.zig 5 8 engine.zig 5
9 flags.zig 2 9 flags.zig 2
@@ -11,12 +11,12 @@ handoff.zig 6
11 hosts.zig 5 11 hosts.zig 5
12 interact.zig 24 12 interact.zig 24
13 keymap.zig 1 13 keymap.zig 1
14 layout.zig 8 14 layout.zig 1
15 main.zig 7 15 main.zig 7
16 muxa.zig 1 16 muxa.zig 1
17 mux_main.zig 1 17 mux_main.zig 1
18 mux.zig 2 18 mux.zig 2
19 paint.zig 11 19 paint.zig 1
20 predict.zig 5 20 predict.zig 5
21 protocol.zig 9 21 protocol.zig 9
22 proxy.zig 1 22 proxy.zig 1
@@ -25,8 +25,8 @@ quic_server.zig 3
25 quic.zig 6 25 quic.zig 6
26 replica.zig 3 26 replica.zig 3
27 select.zig 4 27 select.zig 4
28 server_agent.zig 8 28 server_agent.zig 3
29 server_sessions.zig 9 29 server_sessions.zig 2
30 server_test_agent.zig 6 30 server_test_agent.zig 6
31 server_test_attach.zig 5 31 server_test_attach.zig 5
32 server_test_await.zig 16 32 server_test_await.zig 16
docscheck.budget
Old New
@@ -10,7 +10,7 @@ handoff.zig 0
10 hosts.zig 0 10 hosts.zig 0
11 interact.zig 0 11 interact.zig 0
12 keymap.zig 0 12 keymap.zig 0
13 layout.zig 761 13 layout.zig 623
14 main.zig 0 14 main.zig 0
15 muxa.zig 0 15 muxa.zig 0
16 mux_main.zig 0 16 mux_main.zig 0
src/client/askpass.zig
Old New
@@ -1,17 +1,12 @@
1 //! ssh's prompts, and the one place mux can answer them. 1 //! ssh's prompts, and the one place mux can answer them. ssh reads passwords
2 //! and the host-key question from `/dev/tty` — a read no fd mux sets can reach,
3 //! and under a wall's alternate screen one nobody can see.
4 //! `SSH_ASKPASS_REQUIRE=force` turns each into an exec whose stdout is the
5 //! answer, so the prompt becomes BYTES.
2 //! 6 //!
3 //! ssh reads passwords, passphrases and the host-key question from 7 //! Both ends of that trip live here: `Listener` is the client's, one socket per
4 //! `/dev/tty` — a read no fd mux sets can reach, and under a wall's 8 //! process, and `helperMain` is what `mux askpass` runs. Nothing here paints or
5 //! alternate screen a read nobody can even see. `SSH_ASKPASS_REQUIRE=force` 9 //! spawns, so the whole carriage drives from a test with no ssh and no wall.
6 //! turns each of those reads into an exec of `$SSH_ASKPASS "<prompt>"`
7 //! whose stdout is the answer, so the prompt becomes BYTES, and bytes
8 //! travel. This module is both ends of that trip: `Listener` is the
9 //! client's, one socket per client process, and `helperMain` is what
10 //! `mux askpass` runs.
11 //!
12 //! Nothing here paints and nothing here spawns: the wall owns the popup and
13 //! ssh owns the exec, which is what lets the whole carriage be driven by a
14 //! test with no pty, no ssh and no wall in the picture.
15 const std = @import("std"); 10 const std = @import("std");
16 11
17 /// Env var naming the socket. The mode word for the helper, too: ssh execs 12 /// Env var naming the socket. The mode word for the helper, too: ssh execs
@@ -45,11 +40,10 @@ const backlog = 8;
45 const reply_answer = '+'; 40 const reply_answer = '+';
46 const reply_decline = '-'; 41 const reply_decline = '-';
47 42
48 /// What OpenSSH 8.4+ puts in the helper's environment to say what it is 43 /// What OpenSSH 8.4+ puts in the helper's environment to say what it is asking
49 /// asking for. An exact signal, which is why there is no guessing here: 44 /// for. An exact signal, so there is no guessing: matching `assword` against the
50 /// matching `assword` against the text would call a server-authored 45 /// text would paint a server-authored prompt's answer in the clear whenever the
51 /// keyboard-interactive prompt a secret only when the server happened to 46 /// server spelled it differently.
52 /// spell it that way, and paint the answer in the clear when it did not.
53 pub const prompt_env = "SSH_ASKPASS_PROMPT"; 47 pub const prompt_env = "SSH_ASKPASS_PROMPT";
54 48
55 /// The three things ssh can want, and the one byte the wire carries to say 49 /// The three things ssh can want, and the one byte the wire carries to say
@@ -111,11 +105,9 @@ pub const Hooks = struct {
111 }; 105 };
112 106
113 /// The wall's end: one socket, one accept thread, one prompt at a time. 107 /// The wall's end: one socket, one accept thread, one prompt at a time.
114 /// 108 /// Serialized BY CONSTRUCTION — the accept thread serves a connection to
115 /// Serialized BY CONSTRUCTION rather than by a rule — the accept thread 109 /// completion before accepting the next, so a second ssh waits in the backlog.
116 /// serves a connection to completion before it accepts the next, so a 110 /// That is also the whole fairness policy: accept order.
117 /// second ssh asking while a popup is up waits in the listen backlog. That
118 /// is also the whole of the fairness policy: accept order.
119 pub const Listener = struct { 111 pub const Listener = struct {
120 /// The state one prompt moves through. `shown` exists so a doorbell the 112 /// The state one prompt moves through. `shown` exists so a doorbell the
121 /// keyboard rings twice opens one popup: `take` is the transition, not 113 /// keyboard rings twice opens one popup: `take` is the transition, not
@@ -203,11 +195,9 @@ pub const Listener = struct {
203 /// The half of `stop` a process about to `exit` may run. 195 /// The half of `stop` a process about to `exit` may run.
204 pub fn retire(self: *Listener) void { 196 pub fn retire(self: *Listener) void {
205 // The name leaves the filesystem and a waiting helper is declined; 197 // The name leaves the filesystem and a waiting helper is declined;
206 // nothing is joined, closed or freed. Split because the wall ends 198 // nothing is joined, closed or freed. The wall ends in `std.posix.exit`
207 // in `std.posix.exit` with detached pump threads still live, one of 199 // with detached pumps live, one of which may be inside `declined` on
208 // which may be inside `declined` on this very object — a free in 200 // this object — a free there is a use-after-free.
209 // that window is a use-after-free, while a socket left on disk is a
210 // file the next client of this pid finds on its own name.
211 self.mu.lock(); 201 self.mu.lock();
212 self.running = false; 202 self.running = false;
213 self.cv.broadcast(); 203 self.cv.broadcast();
@@ -251,15 +241,11 @@ pub const Listener = struct {
251 if (declining and self.prompt.ssh_pid != 0) { 241 if (declining and self.prompt.ssh_pid != 0) {
252 self.ring[self.ring_at % decline_ring] = self.prompt.ssh_pid; 242 self.ring[self.ring_at % decline_ring] = self.prompt.ssh_pid;
253 self.ring_at += 1; 243 self.ring_at += 1;
254 // The dial ends HERE, before the helper is released, because a 244 // The dial ends HERE, before the helper is released: a refused
255 // refused askpass is not a refused login to OpenSSH: a helper 245 // askpass is not a refused login to OpenSSH. A helper that exits
256 // that exits non-zero on a password or passphrase prompt is 246 // non-zero is read as the EMPTY password, so ssh tries it and asks
257 // read as the EMPTY password (`read_passphrase`, flags 0), ssh 247 // again up to `NumberOfPasswordPrompts` — one Esc, three prompts.
258 // tries it, the server says no, and ssh asks again up to 248 // Killing our own child makes one Esc one refusal.
259 // `NumberOfPasswordPrompts`. Measured against this box's sshd:
260 // one Esc, three prompts. Killing the ssh this prompt belongs
261 // to — our own child, by `dialOwner` — makes one Esc one
262 // refusal, which is what the wall says it does.
263 std.posix.kill(self.prompt.ssh_pid, std.posix.SIG.TERM) catch {}; 249 std.posix.kill(self.prompt.ssh_pid, std.posix.SIG.TERM) catch {};
264 } 250 }
265 self.phase = .done; 251 self.phase = .done;
@@ -298,10 +284,8 @@ pub const Listener = struct {
298 fn serve(self: *Listener, c: std.posix.socket_t) void { 284 fn serve(self: *Listener, c: std.posix.socket_t) void {
299 const cred = peerCred(c) orelse return; 285 const cred = peerCred(c) orelse return;
300 // The 0700 runtime directory is the boundary, and mux takes 286 // The 0700 runtime directory is the boundary, and mux takes
301 // `$XDG_RUNTIME_DIR` as found rather than verifying it. On a box 287 // `$XDG_RUNTIME_DIR` as found. Where it is not private, THIS line stops
302 // where that directory is not private, this line is what stops 288 // another local user raising a prompt and reading the answer.
303 // another local user raising a prompt on this wall and reading the
304 // answer the user types into it.
305 if (cred.uid != std.os.linux.geteuid()) return; 289 if (cred.uid != std.os.linux.geteuid()) return;
306 var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, std.os.linux.getpid(), parentOf) }; 290 var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, std.os.linux.getpid(), parentOf) };
307 var raw: [prompt_max + 1]u8 = undefined; 291 var raw: [prompt_max + 1]u8 = undefined;
@@ -347,11 +331,9 @@ pub const Listener = struct {
347 @memset(&reply, 0); 331 @memset(&reply, 0);
348 } 332 }
349 333
350 /// Blocks until the keyboard answers, the wall stops, or the PEER goes. 334 /// Blocks until the keyboard answers, the wall stops, or the PEER goes. True
351 /// True when it was the peer: ssh SIGTERMs its notifier helper the 335 /// when it was the peer: ssh SIGTERMs its notifier helper when the touch
352 /// moment the touch lands, and a box left standing after ssh has moved 336 /// lands, and dismissing the leftover box would decline a live dial.
353 /// on is one the user has to dismiss for no reason — and dismissing it
354 /// would record a live dial's pid as declined.
355 fn awaitAnswer(self: *Listener, c: std.posix.socket_t) bool { 337 fn awaitAnswer(self: *Listener, c: std.posix.socket_t) bool {
356 while (true) { 338 while (true) {
357 self.mu.lock(); 339 self.mu.lock();
@@ -384,13 +366,10 @@ pub const Listener = struct {
384 } 366 }
385 }; 367 };
386 368
387 /// The helper's end: `mux askpass`. One line out, one line back, and the 369 /// The helper's end: `mux askpass`. One line out, one back, and the answer on
388 /// answer on `out` — which is ssh's own stdin-side pipe, so a byte written 370 /// `out` — ssh's own stdin-side pipe, so a byte here that is not the answer is a
389 /// here that is not the answer is a byte ssh tries to log in with. 371 /// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written,
390 /// 372 /// which ssh reads as a refused prompt.
391 /// Every failure is exit 1 with NOTHING written: ssh reads a non-zero exit
392 /// as a refused prompt, which is the only honest report of a wall that
393 /// never answered.
394 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 { 373 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 {
395 const stream = std.net.connectUnixSocket(sock) catch return 1; 374 const stream = std.net.connectUnixSocket(sock) catch return 1;
396 defer stream.close(); 375 defer stream.close();
@@ -418,17 +397,11 @@ pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.
418 /// One prompt's bytes, made safe to paint: every control byte becomes a 397 /// One prompt's bytes, made safe to paint: every control byte becomes a
419 /// space. Returns how many were written. 398 /// space. Returns how many were written.
420 pub fn foldControl(dst: []u8, src: []const u8) usize { 399 pub fn foldControl(dst: []u8, src: []const u8) usize {
421 // The rule `handoff.Reason` already states for ssh's stderr, applied to 400 // `handoff.Reason`'s rule on ssh's other channel: a prompt is painted INSIDE
422 // the other channel ssh has: a prompt is painted INSIDE a wall's 401 // a wall's alternate screen, so an escape in one moves a cursor or fakes a
423 // alternate screen with the box's own attribute, so an escape sequence 402 // row in somebody's tile. The text is not always ssh's — a
424 // in one moves a cursor, sets a mode or fakes a row in somebody's tile. 403 // keyboard-interactive prompt is the SERVER's wording, unsanitized. Run on
425 // And the text is not always ssh's: a keyboard-interactive prompt is 404 // BOTH ends, because the end that paints is the end that must not trust.
426 // the SERVER's wording handed to the helper as argv[1], which OpenSSH
427 // does not sanitize on this path.
428 //
429 // Run on BOTH ends — the helper before it sends, the listener on what
430 // it received — because the wire is a socket any same-uid peer can
431 // reach, and the end that paints is the end that must not trust.
432 const n = @min(src.len, dst.len); 405 const n = @min(src.len, dst.len);
433 for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch; 406 for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch;
434 return n; 407 return n;
@@ -504,15 +477,11 @@ fn dialOwner(
504 me: std.posix.pid_t, 477 me: std.posix.pid_t,
505 parent: *const fn (std.posix.pid_t) std.posix.pid_t, 478 parent: *const fn (std.posix.pid_t) std.posix.pid_t,
506 ) std.posix.pid_t { 479 ) std.posix.pid_t {
507 // Not simply the helper's parent, which is what ssh execing its helper 480 // Not simply the helper's parent: under ProxyJump the INNER ssh inherits
508 // directly makes it. More can sit in between: under ProxyJump (and its 481 // `SSH_ASKPASS` and prompts through it, so the chain is helper → inner ssh →
509 // `ProxyCommand ssh -W` spelling) the INNER ssh inherits `SSH_ASKPASS` 482 // outer ssh → us. An attribution that breaks on one extra fork returns 0
510 // and prompts for the jump host through it, so the chain is helper → 483 // exactly when a wall needs a name. The INVARIANT is the far end: the ssh a
511 // inner ssh → outer ssh → us; a test's stand-in adds a shell the same 484 // dial spawned is a child of this process, and nothing else is.
512 // way. An attribution that breaks on one extra fork returns 0 exactly
513 // when a wall needs a name. What is INVARIANT is the other end: the
514 // ssh a dial spawned is a child of this process, and nothing else on
515 // the path is.
516 var at = peer; 485 var at = peer;
517 var steps: usize = 0; 486 var steps: usize = 0;
518 while (at > 0 and steps < ancestor_max) : (steps += 1) { 487 while (at > 0 and steps < ancestor_max) : (steps += 1) {
@@ -731,12 +700,9 @@ const FakeTree = struct {
731 }; 700 };
732 701
733 test "askpass: a helper two shells below the ssh we spawned is still that ssh's" { 702 test "askpass: a helper two shells below the ssh we spawned is still that ssh's" {
734 // ssh execs its helper directly, so the plain case is one step. A 703 // The plain case is one step; a ProxyJump is two, and a test's stand-in adds
735 // ProxyJump is two — the inner ssh inherits `SSH_ASKPASS` and prompts 704 // a shell. An attribution that breaks on one extra fork answers 0 exactly
736 // for the jump host through it — and a test's stand-in adds a shell. 705 // where a wall needs a name — so the walk climbs to OUR child.
737 // An attribution that breaks on one extra fork answers 0 exactly where
738 // a wall needs a name. The invariant is the far end: the ssh a dial
739 // spawned is OUR child, and nothing else on the path is.
740 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent)); 706 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent));
741 // The direct case, unchanged. 707 // The direct case, unchanged.
742 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent)); 708 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent));
src/client/layout.zig
Old New
@@ -1,20 +1,15 @@
1 //! The wall's container tree: where pane rects come from. An i3-style 1 //! The wall's container tree: where pane rects come from. An i3-style nestable
2 //! nestable split tree — `.beside` children share columns left→right, 2 //! split tree — `.beside` children share columns left→right, `.stacked` share
3 //! `.stacked` children share rows top→bottom — that flattens to the 3 //! rows top→bottom — flattened to the `Rect` list every tile claims. Who claims
4 //! `Rect` list every tile claims. Who claims them is wallview's 4 //! them is wallview's business; the tree answers geometry.
5 //! business; the tree answers geometry and nothing else.
6 //! 5 //!
7 //! The remainder rule reproduces `layoutStripes`' "leftover rows to the 6 //! Leftover cells go to the EARLIEST children, so a single-container tree is
8 //! earliest children" exactly (wallview.zig's stripe era), so the 7 //! the old stripe cut exactly. Rails (one column per `.beside` gap) are
9 //! stripe cut survives as the degenerate single-container tree — the 8 //! reported alongside the rects for relayout to paint.
10 //! wallview swap moves no pixel. Rails (one column per `.beside` gap) are reported
11 //! alongside the rects so relayout can paint them; tiles never touch
12 //! rails because their clears are span-bounded (paint.zig).
13 //! 9 //!
14 //! Floors are a parameter, not a constant: wallview passes the protocol 10 //! Floors are a parameter, not a constant. A leaf whose rect would fall under
15 //! minimums plus its label-row arithmetic. A leaf whose rect would fall 11 //! one is `error.TooSmall`, and the caller refuses the operation rather than
16 //! under the floor is `error.TooSmall`, and the caller refuses the 12 //! shrinking a pane below the daemon's floor.
17 //! operation rather than shrinking a pane below the daemon's floor.
18 13
19 const std = @import("std"); 14 const std = @import("std");
20 15
@@ -57,12 +52,10 @@ const Container = struct {
57 weights: std.ArrayListUnmanaged(u32), 52 weights: std.ArrayListUnmanaged(u32),
58 }; 53 };
59 54
60 /// Geometric adjacency over a flat result, not the tree. From the midpoint 55 /// Geometric adjacency over a FLAT result, not the tree. From the midpoint of
61 /// of the focused rect's `dir` edge (midpoint = `top + (rows - 1) / 2` on a 56 /// the focused rect's `dir` edge, candidates are panes whose opposite edge abuts
62 /// vertical edge, `left + (cols - 1) / 2` on a horizontal one), candidates 57 /// it (gap <= 1, since a rail sits between beside panes) and whose perpendicular
63 /// are panes whose opposite edge abuts it (gap ≤ 1 — a rail sits between 58 /// span contains the midpoint; nearest edge wins ties. No focus history.
64 /// beside panes) and whose perpendicular span contains the midpoint; nearest
65 /// edge wins ties. Deterministic, no focus history.
66 pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 { 59 pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 {
67 const fr = flat.rectOf(focus) orelse return null; 60 const fr = flat.rectOf(focus) orelse return null;
68 61
@@ -253,11 +246,9 @@ pub const Tree = struct {
253 pub fn serialize(self: *const Tree, spellings: []const []const u8, focus: ?u8, writer: anytype) !void { 246 pub fn serialize(self: *const Tree, spellings: []const []const u8, focus: ?u8, writer: anytype) !void {
254 try writer.writeAll("mux-layout 1\n"); 247 try writer.writeAll("mux-layout 1\n");
255 if (self.root) |r| try serializeNode(r, 0, 0, spellings, writer); 248 if (self.root) |r| try serializeNode(r, 0, 0, spellings, writer);
256 // The focus record comes after every node line so the parse walk 249 // The focus record comes after every node line, so the parse walk has a
257 // has a complete tree to range-check K against. K is the focused 250 // complete tree to range-check K against. K is the focused leaf's
258 // leaf's encounter index — its position in the depth-first walk, 251 // position in the depth-first walk — `collectLeafIds`' order.
259 // which is the same order `collectLeafIds` yields and `parse`
260 // assigns leaf ids.
261 if (focus) |fid| { 252 if (focus) |fid| {
262 var ids: std.ArrayListUnmanaged(u8) = .{}; 253 var ids: std.ArrayListUnmanaged(u8) = .{};
263 defer ids.deinit(self.alloc); 254 defer ids.deinit(self.alloc);
@@ -289,11 +280,10 @@ pub const Tree = struct {
289 } 280 }
290 } 281 }
291 282
292 /// Rewrite leaf ids through `map`: a null entry removes the leaf (via 283 /// Rewrite leaf ids through `map`: a null entry removes the leaf and lets
293 /// `remove`, so containers collapse); a non-null entry sets the leaf id 284 /// containers collapse. Removals ALL happen before any rewrite, so old ids
294 /// to `map[i].?`. Removals all happen before any rewrite so old ids stay 285 /// stay addressable through the removal pass; the rewrite is one pass, so a
295 /// addressable through the removal pass; the rewrite is one pass so new 286 /// new id cannot collide with a not-yet-rewritten old one.
296 /// ids cannot collide with not-yet-rewritten old ones.
297 pub fn remapLeaves(self: *Tree, map: []const ?u8) void { 287 pub fn remapLeaves(self: *Tree, map: []const ?u8) void {
298 // Collect ids first because removal mutates the tree and may 288 // Collect ids first because removal mutates the tree and may
299 // collapse containers, invalidating node pointers. 289 // collapse containers, invalidating node pointers.
@@ -307,12 +297,9 @@ pub const Tree = struct {
307 if (id < map.len and map[id] == null) self.remove(id); 297 if (id < map.len and map[id] == null) self.remove(id);
308 } 298 }
309 299
310 // Pass 2: one tree walk rewriting every remaining leaf through the 300 // One walk rewriting every remaining leaf, so a new id cannot collide
311 // map. A single pass means a new id cannot collide with a 301 // with a not-yet-rewritten old one: in the swap {2, null, 0}, 0→2 and
312 // not-yet-rewritten old id — the swap {2, null, 0} is the proof: 302 // 2→0 happen in the same walk and neither sees the other's result.
313 // 0→2 and 2→0 happen in the same walk, neither sees the other's
314 // result. Re-searching by id between rewrites would find the wrong
315 // node after the first swap.
316 rewriteLeafIds(self.root, map); 303 rewriteLeafIds(self.root, map);
317 } 304 }
318 305
@@ -385,15 +372,12 @@ pub const Tree = struct {
385 }; 372 };
386 } 373 }
387 374
388 /// Move the focus pane's `dir` boundary by `delta_cells`. Walks up to 375 /// Move the focus pane's `dir` boundary by `delta_cells`. Walks up to the
389 /// the nearest container of the right axis (beside for left/right, 376 /// nearest container of the right axis; inside it, the child holding focus
390 /// stacked for up/down); inside it, the child holding focus and its 377 /// and its adjacent sibling trade cells, so weights become exact cell
391 /// adjacent sibling toward `dir` trade cells — weights become cell 378 /// counts. False leaves the tree untouched. The per-axis floor is a fast
392 /// counts, exact and stable. Returns false when refused (no such 379 /// pre-filter and the FLATTEN probe is the contract, because a nested
393 /// sibling, or the result would not flatten), leaving the tree 380 /// container slot needs more than one leaf's floor.
394 /// untouched. The per-axis floor is a fast pre-filter; the flatten
395 /// probe is the contract, because a nested container slot needs more
396 /// than one leaf's floor.
397 pub fn resize( 381 pub fn resize(
398 self: *Tree, 382 self: *Tree,
399 alloc: std.mem.Allocator, 383 alloc: std.mem.Allocator,
@@ -518,11 +502,9 @@ pub const ParsedLayout = struct {
518 } 502 }
519 }; 503 };
520 504
521 /// Null on any malformation: the sidecar is derived convenience, so a bad 505 /// Null on any malformation: the sidecar is derived convenience, so a bad one
522 /// one degrades to the default cut instead of refusing startup — the 506 /// degrades to the default cut rather than refusing startup — the deliberate
523 /// deliberate opposite of wall.load's strictness about lines the user 507 /// opposite of the hosts file's strictness about lines the user authored.
524 /// authored. Allocation failure also degrades to null; the caller never
525 /// distinguishes and the sidecar is never the sole source of truth.
526 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout { 508 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout {
527 var line_iter = std.mem.splitScalar(u8, bytes, '\n'); 509 var line_iter = std.mem.splitScalar(u8, bytes, '\n');
528 510
@@ -1046,11 +1028,9 @@ test "a beside cut underflowing its rail count is refused, not trapped" {
1046 } 1028 }
1047 1029
1048 test "resize reads a nested same-orient child's bounding box, not its first leaf" { 1030 test "resize reads a nested same-orient child's bounding box, not its first leaf" {
1049 // beside[0, beside[1,2]] at rows=24 cols=81: tiles flatten to 1031 // beside[0, beside[1,2]] at 24x81 flattens to cols 40/20/19.
1050 // cols 40/20/19 (rail takes 1, right column splits 40 into 20+19+rail). 1032 // `resize(0, .right, 3)` must read the neighbour child's span as 40 — the
1051 // resize(0, .right, 3) must read the neighbor child's span as 40 1033 // SLOT — and not 20; otherwise weights go {43,17} and tile 0 lands at 58.
1052 // (the slot), not 20 (leaf 1's cols) — otherwise weights go {43,17}
1053 // and re-flatten gives tile 0 cols=58 instead of 43.
1054 var t = Tree.init(std.testing.allocator); 1034 var t = Tree.init(std.testing.allocator);
1055 defer t.deinit(); 1035 defer t.deinit();
1056 try t.addFirst(0); 1036 try t.addFirst(0);
@@ -1071,12 +1051,9 @@ test "resize reads a nested same-orient child's bounding box, not its first leaf
1071 } 1051 }
1072 1052
1073 test "resize refuses before a nested container's slot goes sub-minimum" { 1053 test "resize refuses before a nested container's slot goes sub-minimum" {
1074 // beside[0, beside[1,2]] at rows=24 cols=81 floors{2,2}: the right 1054 // The right slot holds a nested beside needing 2*2+1 = 5 cols to flatten,
1075 // slot holds a nested beside that needs 2*2+1=5 cols to flatten. 1055 // while the per-axis floor check alone passes at 4. So the PROBE is the
1076 // resize(0, .right, 1) shrinks the right slot one cell at a time; 1056 // contract and the floor is only a pre-filter.
1077 // each call must keep the tree flattenable. The per-axis floor check
1078 // alone (floors.cols=2) passes at 4, but flatten needs 5 — so the
1079 // probe is the contract, not the floor.
1080 var t = Tree.init(std.testing.allocator); 1057 var t = Tree.init(std.testing.allocator);
1081 defer t.deinit(); 1058 defer t.deinit();
1082 try t.addFirst(0); 1059 try t.addFirst(0);
src/engine/delta.zig
Old New
@@ -1,24 +1,18 @@
1 //! The daemon's half of the delta stream. This side holds the authoritative 1 //! The daemon's half of the delta stream: this side holds the authoritative
2 //! grid and decides WHICH rows a client is missing; the far side is a 2 //! grid and decides WHICH rows a client is missing, while the far side applies
3 //! replica that applies whatever payload arrives without knowing how it was 3 //! whatever payload arrives. protocol.zig owns the bytes; this owns the rows.
4 //! chosen. protocol.zig owns the bytes those payloads are made of — this 4 //! Engine and protocol are its whole world, so a test drives the tracker with
5 //! owns which rows go into them. 5 //! nothing but an engine.
6 //!
7 //! Engine and protocol are the whole of its world — no daemon, no clients,
8 //! no sockets — which is what lets the tracker be driven directly by a test
9 //! holding nothing but an engine.
10 const std = @import("std"); 6 const std = @import("std");
11 const Engine = @import("engine.zig").Engine; 7 const Engine = @import("engine.zig").Engine;
12 const proto = @import("protocol.zig"); 8 const proto = @import("protocol.zig");
13 9
14 const Wyhash = std.hash.Wyhash; 10 const Wyhash = std.hash.Wyhash;
15 11
16 /// Tracks a content hash per viewport row so an engine update can be sent 12 /// A content hash per viewport row, so an update sends only the rows that
17 /// as just the rows that actually changed. Advances whether or not a 13 /// changed. It advances whether or not a client is attached, so a reattach can
18 /// client is attached, so a reattach can be answered with a delta — but 14 /// be answered by delta — but with nobody attached it advances through
19 /// when nobody is attached it advances through `noteBlind`, because 15 /// `noteBlind`, because hashing means RENDERING every row.
20 /// computing those hashes means RENDERING every row, and that is the
21 /// daemon's largest single cost on a session nobody is watching.
22 pub const DeltaTracker = struct { 16 pub const DeltaTracker = struct {
23 seq: u64 = 0, 17 seq: u64 = 0,
24 /// Seq at the last discontinuity (init/resize/screen switch). Clients 18 /// Seq at the last discontinuity (init/resize/screen switch). Clients
@@ -49,17 +43,11 @@ pub const DeltaTracker = struct {
49 /// Re-hash every row and mark a discontinuity: what follows can only be 43 /// Re-hash every row and mark a discontinuity: what follows can only be
50 /// carried by a full snapshot. 44 /// carried by a full snapshot.
51 /// 45 ///
52 /// The daemon calls this through `Server.rebuildTracker` and nowhere 46 /// The daemon calls this through `Server.rebuildTracker` and nowhere else,
53 /// else in the daemon, deliberately; one server test drives it directly 47 /// deliberately: moving `reset_seq` makes a recorded side-channel event
54 /// on a live session, which is the exact shape this note warns about and 48 /// undeliverable, and that wrapper is where the event is dropped. A new
55 /// is safe only because that test records no side-channel event. 49 /// caller here leaves the user's copied text resident in a daemon that can
56 /// 50 /// no longer give it to anyone.
57 /// Moving reset_seq is what makes a recorded side-channel event
58 /// undeliverable, and that wrapper is where the event is dropped — so a
59 /// new caller here would leave the user's copied text resident in a
60 /// daemon that can no longer give it to anyone. This module has no
61 /// business knowing what a side channel is, which is why the coupling is
62 /// a note rather than a check.
63 pub fn rebuild(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, rows: u16, cols: u16) !void { 51 pub fn rebuild(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, rows: u16, cols: u16) !void {
64 if (self.row_hashes.len != rows) { 52 if (self.row_hashes.len != rows) {
65 // Every allocation lands before any old array is released, so a 53 // Every allocation lands before any old array is released, so a
@@ -84,11 +72,9 @@ pub const DeltaTracker = struct {
84 self.reset_seq = self.seq; 72 self.reset_seq = self.seq;
85 self.cursor = eng.cursorPos(); 73 self.cursor = eng.cursorPos();
86 self.history_rows = eng.historyRows(); 74 self.history_rows = eng.historyRows();
87 // Claim no rows until every row is stamped. A dump that fails 75 // Claim no rows until every row is stamped: a dump that fails partway
88 // partway would otherwise leave stale seqs in the tail of 76 // leaves stale seqs in the tail of `row_seqs`, and any above a client's
89 // row_seqs, and any of them above a client's have_seq would put 77 // `have_seq` puts that row in every delta from here on.
90 // that row in every delta from here on. rows == 0 sends update()
91 // down its first-run branch and sendResync to a snapshot instead.
92 self.rows = 0; 78 self.rows = 0;
93 for (0..rows) |y| { 79 for (0..rows) |y| {
94 const bytes = try eng.dumpVtRow(alloc, @intCast(y)); 80 const bytes = try eng.dumpVtRow(alloc, @intCast(y));
@@ -153,14 +139,10 @@ pub const DeltaTracker = struct {
153 return .advanced; 139 return .advanced;
154 } 140 }
155 141
156 /// Records that the grid moved WITHOUT rendering a row: hashing by 142 /// Records that the grid moved WITHOUT rendering a row: hashing by rendering
157 /// rendering dominates the daemon on a full-width repaint, and with 143 /// dominates the daemon on a full-width repaint, and with nobody attached
158 /// nobody attached those bytes go nowhere. Takes no allocator — a 144 /// those bytes go nowhere. Takes no allocator — a signature that can
159 /// signature that can allocate is one that can render. 145 /// allocate is one that can render.
160 ///
161 /// Two commands returning in one blind stretch must not share a `seq`;
162 /// a reattach must get the rows that moved while nobody looked;
163 /// stale hashes can match a row the client never held.
164 pub fn noteBlind(self: *DeltaTracker, eng: *Engine) Update { 146 pub fn noteBlind(self: *DeltaTracker, eng: *Engine) Update {
165 if (self.rows == 0) return .discontinuity; 147 if (self.rows == 0) return .discontinuity;
166 if (self.rows != eng.term.rows or self.cols != eng.term.cols) return .discontinuity; 148 if (self.rows != eng.term.rows or self.cols != eng.term.cols) return .discontinuity;
@@ -183,15 +165,10 @@ pub const DeltaTracker = struct {
183 self.rows != 0; 165 self.rows != 0;
184 } 166 }
185 167
186 /// Build a delta payload of all rows changed after `since`. The header 168 /// Build a delta payload of all rows changed after `since`. The header's
187 /// row_count and the appended rows MUST agree (composeDelta validates), 169 /// `row_count` and the appended rows MUST agree, so both come from the same
188 /// so both come from the same `row_seq > since` predicate over row_seqs 170 /// predicate with nothing mutating in between. Changed rows are dumped twice
189 /// with nothing mutating in between. 171 /// — once to hash, once to serialize — which is 1-3 rows in steady state.
190 ///
191 /// Changed rows get dumped twice per update — once to hash, once here
192 /// to serialize. That is the price of one row-selection routine serving
193 /// both the live stream and the attach path, and it is 1-3 rows in
194 /// steady state.
195 pub fn buildDeltaSince(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, since: u64) ![]u8 { 172 pub fn buildDeltaSince(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, since: u64) ![]u8 {
196 var rows_changed: u16 = 0; 173 var rows_changed: u16 = 0;
197 for (self.row_seqs) |s| { 174 for (self.row_seqs) |s| {
@@ -301,14 +278,10 @@ test "DeltaTracker: blind output is answerable on reattach without rendering a r
301 std.mem.indexOf(u8, composed.bytes, "printed with nobody watching") != null, 278 std.mem.indexOf(u8, composed.bytes, "printed with nobody watching") != null,
302 ); 279 );
303 280
304 // The header, not only the rows. buildDeltaSince serialises the 281 // The HEADER, not only the rows: `buildDeltaSince` serialises the tracker's
305 // TRACKER's cursor and history_rows, so a blind path that stamped every 282 // cursor and `history_rows`, so a blind path that stamped every row and
306 // row but forgot to refresh those two would repaint the right text with 283 // forgot those two repaints the right text with the cursor parked where the
307 // the cursor parked where the gap began. Deleting either line in 284 // gap began. Any reattach quoting a held seq wears it.
308 // noteBlind left the entire unit suite green until this assertion
309 // existed, and any reattach quoting a held seq wears it — a browser
310 // reconnect, or the CLI's redial after transport death
311 // (wall_pump.sendAttach); only a fresh attach takes the snapshot arm.
312 const hdr = try proto.readDeltaHeader(payload); 285 const hdr = try proto.readDeltaHeader(payload);
313 const cur = eng.cursorPos(); 286 const cur = eng.cursorPos();
314 try std.testing.expectEqual(cur.x, hdr.cursor_x); 287 try std.testing.expectEqual(cur.x, hdr.cursor_x);
@@ -317,18 +290,11 @@ test "DeltaTracker: blind output is answerable on reattach without rendering a r
317 } 290 }
318 291
319 test "DeltaTracker: a blind chunk that changed nothing still advances seq" { 292 test "DeltaTracker: a blind chunk that changed nothing still advances seq" {
320 // Reads like a triviality; it is the premise two comments in server.zig 293 // The premise two comments in server.zig rest on. Side-channel events are
321 // now rest on. Side-channel events (OSC 52, a bell) are stamped at 294 // stamped at `tracker.seq` and replayed only strictly ABOVE a reattaching
322 // tracker.seq and replayed only when strictly ABOVE the reattaching 295 // client's watermark — and `update()` answers `.none` for a chunk that moved
323 // client's watermark. update() answers .none for a chunk that moved no 296 // no cell, so a bare BEL during a gap is stamped where the gap began and
324 // cell, which left a bare BEL during a gap stamped at the seq the gap 297 // never replayed. `noteBlind` has no `.none`, so those events survive.
325 // began on and therefore never replayed — replayPending calls that out
326 // as the price it pays. noteBlind has no .none case, so during a gap
327 // those events now land above the watermark and survive the reattach.
328 //
329 // Deleting the unconditional `self.seq += 1` is what silently reverts
330 // that, and no server test would notice: the gap fixture prints a
331 // visible marker first precisely so it never depends on this.
332 const alloc = std.testing.allocator; 298 const alloc = std.testing.allocator;
333 299
334 const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 300 const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
@@ -369,12 +335,10 @@ test "DeltaTracker: two blind stretches never share a seq" {
369 } 335 }
370 336
371 test "DeltaTracker: a row that reverts after a blind stretch is still sent" { 337 test "DeltaTracker: a row that reverts after a blind stretch is still sent" {
372 // The hazard that makes stale hashes unsafe, and it is silent. 338 // Why stale hashes are unsafe, silently. Blind output moves a row A -> B and
373 // Blind output moves a row A -> B. The reattach delta carries every 339 // the reattach delta carries it, so the client holds B — but the STORED hash
374 // row, so the client now holds B, but the STORED hash still describes 340 // still describes A. If the row later moves B -> A, the comparison says
375 // A because the blind path never rendered anything. If the row later 341 // "unchanged" and the client shows B for the rest of the session.
376 // moves B -> A, a hash comparison says "unchanged", nothing is sent,
377 // and the client shows B for the rest of the session.
378 const alloc = std.testing.allocator; 342 const alloc = std.testing.allocator;
379 343
380 const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 344 const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
src/server/server_agent.zig
Old New
@@ -1,20 +1,14 @@
1 //! The daemon's agent-forwarding relay: the channel table, the counters 1 //! The daemon's agent-forwarding relay: the channel table, the counters that
2 //! that explain a refusal, and the private directory the per-session 2 //! explain a refusal, and the private directory the per-session
3 //! `SSH_AUTH_SOCK`s are bound in. 3 //! `SSH_AUTH_SOCK`s are bound in.
4 //! 4 //!
5 //! Split out of server.zig because these seven fields are touched by 5 //! It needs exactly four things back from the daemon — `agentAnswerer`,
6 //! thirteen functions and almost nothing else — the tightest cluster in the 6 //! `queueFrame`, `revokeAgentOffer`, and a session's listening fd — handed a
7 //! file. What the relay needs back from the daemon is four things and they 7 //! `*Server` per call rather than held, since `Server` is returned by value
8 //! are all named: `agentAnswerer` (who answers for a session — the clients 8 //! from `init` and a back-pointer would name the copy left behind.
9 //! table's latest-wins rule, which stays with the clients table),
10 //! `queueFrame`, `revokeAgentOffer`, and a session's listening fd. It is
11 //! handed a `*Server` per call rather than holding one: `Server` is
12 //! returned by value from `init`, so a back-pointer taken there would name
13 //! the copy that was left behind.
14 //! 9 //!
15 //! The relay never reads `ClientSlot`. A client is a slot index to it, and 10 //! The relay never reads `ClientSlot`: a client is a slot index to it, and every
16 //! every rule about which index that is lives on the other side of those 11 //! rule about which index that is lives behind those four calls.
17 //! four calls.
18 12
19 const std = @import("std"); 13 const std = @import("std");
20 const proto = @import("term").protocol; 14 const proto = @import("term").protocol;
@@ -44,22 +38,18 @@ pub const AgentSock = struct {
44 /// from the same number. 38 /// from the same number.
45 pub const max_agent_chans = proto.agent_chans_max; 39 pub const max_agent_chans = proto.agent_chans_max;
46 40
47 /// `client` and `session` decide who may speak for a channel — the client 41 /// `client` and `session` decide who may speak for a channel: the client because
48 /// because ids are daemon-wide and a guessed one must not reach a stranger's 42 /// ids are daemon-wide and a guessed one must not reach a stranger's ssh-agent,
49 /// ssh-agent, the session because a channel dies with the shell that dialled 43 /// the session because a channel dies with the shell that dialled it. No buffer
50 /// it even when its client attaches elsewhere first. 44 /// here — the daemon never holds agent bytes.
51 ///
52 /// No buffer here: the daemon never holds agent bytes.
53 pub const AgentChan = struct { 45 pub const AgentChan = struct {
54 fd: std.posix.fd_t, 46 fd: std.posix.fd_t,
55 id: u32, 47 id: u32,
56 client: usize, 48 client: usize,
57 session: usize, 49 session: usize,
58 /// The answer clock: when the first request was handed to the client, 50 /// The answer clock: from the first request handed to the client until its
59 /// until the client's first reply lands. Started by the request and not 51 /// first reply. Started by the REQUEST, because until ssh asks the client
60 /// by the open, because until ssh asks the client owes nothing; stopped 52 /// owes nothing; stopped for good by one reply, which is the proof.
61 /// for good by one reply, because that reply is the proof the offer
62 /// claimed (`Server.agent_answer_ms`).
63 answer: union(enum) { unasked, asked: i64, proven } = .unasked, 53 answer: union(enum) { unasked, asked: i64, proven } = .unasked,
64 }; 54 };
65 55
@@ -94,17 +84,12 @@ pub const AgentRelay = struct {
94 /// confused with an absent one at a glance, and wraps — `nextId` 84 /// confused with an absent one at a glance, and wraps — `nextId`
95 /// is what keeps a wrap from colliding with a live channel. 85 /// is what keeps a wrap from colliding with a live channel.
96 next_id: u32 = 1, 86 next_id: u32 = 1,
97 /// How long a client may sit on a channel's FIRST forwarded request 87 /// How long a client may sit on a channel's FIRST forwarded request before
98 /// before the daemon hangs up for it and stops routing to it. An 88 /// the daemon hangs up for it. An `agent_offer` is a declaration, not a
99 /// `agent_offer` is a declaration, not a capability: a peer that 89 /// capability: a peer that cannot answer does not degrade forwarding, it
100 /// offered and cannot answer does not degrade forwarding, it wedges 90 /// WEDGES it — ssh blocks past 8s on a socket that accepts and never
101 /// it — ssh on a socket that accepts and never replies blocks past 8s 91 /// replies. Only the first request is clocked, because a later SIGN may
102 /// where one that closes falls through in 2ms (decisions.md). Only the 92 /// legitimately wait on a human's touch.
103 /// first request is clocked: one reply proves the peer speaks for an
104 /// agent, and a later SIGN may legitimately wait on a human's touch.
105 /// Ten times the preflight's 500ms round-trip bound (decisions.md), so
106 /// it never separates slow from refused. A field rather than a const so
107 /// a test does not wait it out.
108 answer_ms: i64 = 5000, 93 answer_ms: i64 = 5000,
109 94
110 /// What `statsText` prints and `writeManifestTo` carries. A struct so 95 /// What `statsText` prints and `writeManifestTo` carries. A struct so
@@ -115,15 +100,11 @@ pub const AgentRelay = struct {
115 return .{ .refused_no_offer = self.refused_no_offer, .refused_full = self.refused_full }; 100 return .{ .refused_no_offer = self.refused_no_offer, .refused_full = self.refused_full };
116 } 101 }
117 102
118 /// with a random half: that parent is a shared `/tmp` whenever there is no 103 /// with a random half: that parent is a shared `/tmp` without
119 /// `$XDG_RUNTIME_DIR`, a pid alone is guessable, and an entry pre-created 104 /// `$XDG_RUNTIME_DIR`, a pid alone is guessable, and a symlink pre-created
120 /// there by another user as a symlink would put this daemon's sockets 105 /// there would put this daemon's sockets somewhere it does not own.
121 /// somewhere it does not own. 106 /// Degrades to null rather than failing the daemon, and says so on stderr —
122 /// 107 /// from inside the shell an absent `SSH_AUTH_SOCK` looks like no `-A`.
123 /// Degrades to null rather than failing the daemon: a session with no
124 /// forwarded agent is a working session. Said on stderr, because from
125 /// inside the shell an absent `SSH_AUTH_SOCK` looks exactly like a client
126 /// that never asked to forward one.
127 pub fn makeDir(alloc: std.mem.Allocator, sock_path: []const u8) ?[]const u8 { 108 pub fn makeDir(alloc: std.mem.Allocator, sock_path: []const u8) ?[]const u8 {
128 const parent = std.fs.path.dirname(sock_path) orelse "."; 109 const parent = std.fs.path.dirname(sock_path) orelse ".";
129 const dir = std.fmt.allocPrint( 110 const dir = std.fmt.allocPrint(
@@ -146,15 +127,10 @@ pub const AgentRelay = struct {
146 return dir; 127 return dir;
147 } 128 }
148 129
149 /// Bind and listen on `agent-<name>.sock` in `dir`, or answer null — 130 /// Bind and listen on `agent-<name>.sock` in `dir`, or answer null — never
150 /// never an error. Every caller is creating a session, and a session 131 /// an error. Every caller is creating a session and a session outlives
151 /// outlives forwarding: there is nothing here worth refusing a shell 132 /// forwarding, so the two ways to have no socket are one answer. The 0700
152 /// over, so the two ways to have no socket (no directory, and a bind 133 /// DIRECTORY is the access boundary, not the socket's own mode.
153 /// that would not go) are one answer.
154 ///
155 /// The 0700 directory is the access boundary, not the socket's own
156 /// mode: a umask can only take bits off the socket, and a listener
157 /// nobody can reach through its parent is already unreachable.
158 pub fn bindSock(alloc: std.mem.Allocator, dir: ?[]const u8, name: []const u8) ?AgentSock { 134 pub fn bindSock(alloc: std.mem.Allocator, dir: ?[]const u8, name: []const u8) ?AgentSock {
159 const d = dir orelse return null; 135 const d = dir orelse return null;
160 const path = std.fmt.allocPrintSentinel( 136 const path = std.fmt.allocPrintSentinel(
@@ -223,15 +199,11 @@ pub const AgentRelay = struct {
223 return null; 199 return null;
224 } 200 }
225 201
226 /// One dial at a session's `SSH_AUTH_SOCK`, routed or refused. 202 /// One dial at a session's `SSH_AUTH_SOCK`, routed or refused. Refusing
227 /// 203 /// means closing AT ONCE: ssh reads that as "agent refused operation" and
228 /// Refusing means closing at once, and that IS the behaviour: ssh reads 204 /// falls through to its other methods, where a connection accepted and left
229 /// a closed agent socket as "agent refused operation" and falls straight 205 /// silent makes it wait out a timeout on every dial. The socket lives as
230 /// through to its other auth methods, where a connection accepted and 206 /// long as the session; only the answer comes and goes.
231 /// left silent would make it wait out a timeout on every dial. The
232 /// socket exists for as long as the session does; only the answer comes
233 /// and goes, which is what makes it safe to hand every shell the
234 /// variable whether or not anyone has offered a key.
235 pub fn accept(self: *AgentRelay, srv: *Server, si: usize) void { 207 pub fn accept(self: *AgentRelay, srv: *Server, si: usize) void {
236 // CLOEXEC for the same reason the listener has it: this daemon 208 // CLOEXEC for the same reason the listener has it: this daemon
237 // forks a shell per session, and a live agent connection leaked 209 // forks a shell per session, and a live agent connection leaked
@@ -271,12 +243,9 @@ pub const AgentRelay = struct {
271 _ = srv.queueFrame(target, .agent_open, &proto.encodeAgentId(self.chans[s].?.id)); 243 _ = srv.queueFrame(target, .agent_open, &proto.encodeAgentId(self.chans[s].?.id));
272 } 244 }
273 245
274 /// Bytes off one agent connection, handed to the client that owns it. 246 /// Bytes off one agent connection, handed to the client that owns it. BLIND:
275 /// 247 /// the id is prefixed and the rest copied through unread, because a daemon
276 /// Blind: the id is prefixed and the rest is copied through unread. The 248 /// that parsed the agent protocol would be a second implementation of it.
277 /// daemon has no stake in the agent protocol, and a daemon that parsed
278 /// it would be a second implementation of it — one more thing to be
279 /// wrong about a format neither end asked it to understand.
280 pub fn service(self: *AgentRelay, srv: *Server, s: usize) void { 249 pub fn service(self: *AgentRelay, srv: *Server, s: usize) void {
281 const ch = self.chans[s].?; 250 const ch = self.chans[s].?;
282 // The id written first and read into the space after it, so the 251 // The id written first and read into the space after it, so the
@@ -293,11 +262,9 @@ pub const AgentRelay = struct {
293 if (ch.answer == .unasked) { 262 if (ch.answer == .unasked) {
294 self.chans[s].?.answer = .{ .asked = std.time.milliTimestamp() }; 263 self.chans[s].?.answer = .{ .asked = std.time.milliTimestamp() };
295 } 264 }
296 // Drop-on-backpressure, per queueFrame's standing contract: an agent 265 // Drop-on-backpressure, per `queueFrame`'s contract: an agent exchange is
297 // exchange is one or two KB against an 8 MiB cap, so tripping it 266 // one or two KB against an 8 MiB cap, so tripping it means the peer
298 // means the peer stopped reading, not that the agent is chatty. A 267 // stopped reading. A false return is a client already dropped.
299 // false return is a client already dropped — and dropClient's sweep
300 // has already closed this channel, so there is nothing left to do.
301 _ = srv.queueFrame(ch.client, .agent_data, buf[0 .. proto.agent_id_len + n]); 268 _ = srv.queueFrame(ch.client, .agent_data, buf[0 .. proto.agent_id_len + n]);
302 } 269 }
303 270
src/server/server_sessions.zig
Old New
@@ -1,21 +1,13 @@
1 //! The daemon's session table: up to `max_sessions` slots, and everything 1 //! The daemon's session table: up to `max_sessions` slots, and everything that
2 //! that creates, finds, names, counts or reaps one. 2 //! creates, finds, names, counts or reaps one. These are the only functions
3 //! with a RULE about the table; every other reader just walks it, which is why
4 //! `table` is a pub field rather than an iterator.
3 //! 5 //!
4 //! Split out of server.zig because these eight functions are the only ones 6 //! What it needs back from the daemon arrives as a `*Server` per call, never a
5 //! with a rule about the table — every other reader just walks 7 //! back-pointer: `Server` is returned by value from `init`.
6 //! `sessions.table`. What the table needs back from the daemon is the spawn
7 //! plan and allocator (`resolve`), and the clients a dying session owes an
8 //! exit code to (`reap`); both arrive as a `*Server` per call, for
9 //! server_agent.zig's reason — `Server` is returned by value from `init`.
10 //! 8 //!
11 //! `table` is a pub field rather than an iterator: `pumpOnce`, `deinit` and 9 //! Names are stored INLINE, and that is load-bearing: iterating by value copies
12 //! `writeManifestTo` walk it whole, and an iterator would have bought them 10 //! a Session, and a name sliced out of that copy dies with the iteration.
13 //! nothing but a new spelling.
14 //!
15 //! Names are stored inline (`Session.name_buf`) and that is load-bearing
16 //! here: iterating this table BY VALUE copies a Session, and a name sliced
17 //! out of that copy dies with the iteration. It once renamed every session
18 //! after the first to the last one's bytes.
19 11
20 const std = @import("std"); 12 const std = @import("std");
21 const proto = @import("term").protocol; 13 const proto = @import("term").protocol;
@@ -42,13 +34,10 @@ pub const SessionTable = struct {
42 /// Null if it could not be made. Created exclusively at 0700 under a name 34 /// Null if it could not be made. Created exclusively at 0700 under a name
43 /// A session instance's identity in every snapshot it sends. 35 /// A session instance's identity in every snapshot it sends.
44 pub fn freshEpoch() u64 { 36 pub fn freshEpoch() u64 {
45 // Random rather than a counter or a timestamp: nothing on disk 37 // Random rather than a counter or timestamp: nothing on disk survives a
46 // survives a daemon, and two daemons started in the same 38 // daemon, and two started in the same millisecond must still differ.
47 // millisecond (tests do exactly this) must still differ. Never 0 — 39 // Never 0 — that is a client saying it holds nothing. An adopted session
48 // that value is a client saying it holds nothing. An adopted 40 // mints one too, so every returning client takes the snapshot path.
49 // session mints one too: the grid it replays is not the one any
50 // client was being deltaed against, so every returning client has
51 // to take the snapshot path.
52 var epoch: u64 = 0; 41 var epoch: u64 = 0;
53 while (epoch == 0) epoch = std.crypto.random.int(u64); 42 while (epoch == 0) epoch = std.crypto.random.int(u64);
54 return epoch; 43 return epoch;
@@ -76,11 +65,9 @@ pub const SessionTable = struct {
76 }); 65 });
77 errdefer eng.deinit(); 66 errdefer eng.deinit();
78 67
79 // The env pairs the shared plan cannot carry, because both are this 68 // The env pairs the shared plan cannot carry, because both are THIS
80 // session's and the plan is built once for every session this 69 // session's: its name, and the agent socket bound for it. Freed as soon
81 // daemon will ever spawn: its name, and the agent socket bound for 70 // as `spawnArgv` returns — the child read them before the exec.
82 // it. Freed as soon as spawnArgv returns — the child read them
83 // before the exec, in its own copy of this memory.
84 var name_z: [proto.session_name_max + 1]u8 = undefined; 71 var name_z: [proto.session_name_max + 1]u8 = undefined;
85 @memcpy(name_z[0..name.len], name); 72 @memcpy(name_z[0..name.len], name);
86 name_z[name.len] = 0; 73 name_z[name.len] = 0;
@@ -88,18 +75,10 @@ pub const SessionTable = struct {
88 defer alloc.free(env); 75 defer alloc.free(env);
89 @memcpy(env[0..plan.env.len], plan.env); 76 @memcpy(env[0..plan.env.len], plan.env);
90 env[plan.env.len] = .{ .key = proto.session_env, .value = name_z[0..name.len :0] }; 77 env[plan.env.len] = .{ .key = proto.session_env, .value = name_z[0..name.len :0] };
91 // Always written, either way, overwriting whatever the daemon 78 // Always written, overwriting whatever the daemon inherited: a session
92 // inherited: a session pointed at the DAEMON's ssh-agent would be 79 // pointed at the DAEMON's ssh-agent reaches past the client watching it,
93 // reaching past the client that is watching it, and every client 80 // and every client would share one identity. Null — an UNSET — on the
94 // would share one identity. The socket answers only for the client 81 // bind failure paths, which would otherwise fall through to exactly that.
95 // that offered a key, so a shell with nobody offering finds an
96 // agent holding nothing — which is what ssh already handles by
97 // falling through to its other methods.
98 //
99 // Null — an unset — for the failure paths of makeAgentDir and
100 // bindAgentSock, which are the arm that used to fall through to the
101 // inherited socket: the state this comment forbids, reached exactly
102 // where nobody is watching for it.
103 env[plan.env.len + 1] = .{ 82 env[plan.env.len + 1] = .{
104 .key = proto.agent_sock_env, 83 .key = proto.agent_sock_env,
105 .value = if (agent) |a| a.path else null, 84 .value = if (agent) |a| a.path else null,
@@ -143,19 +122,14 @@ pub const SessionTable = struct {
143 return null; 122 return null;
144 } 123 }
145 124
146 /// Attach-or-create. Creation demands a size the session can live at, the 125 /// Attach-or-create. Creation demands a size the session can live at — the
147 /// SAME threshold `applySize` enforces. A 0x0 attach makes no size claim 126 /// SAME threshold `applySize` enforces, since 1x1 is a size clients really
148 /// at all — `mux a`, the wall's view stripes, an unzoomed browser tile — and a 127 /// send and gating on merely nonzero creates a session no resize can move.
149 /// client with no size must never be the reason a shell spawns. 128 /// A 0x0 attach makes no claim at all, and a client with no size must never
150 /// 129 /// be the reason a shell spawns.
151 /// 1x1 is a size a client genuinely sends, so gating on merely nonzero
152 /// creates a session `applySize` then refuses to move, which nobody can
153 /// use or fix.
154 /// 130 ///
155 /// Null is a refusal: bad name, table full, too small, or a failed spawn. 131 /// Null is a refusal: bad name, table full, too small, or a failed spawn —
156 /// Only the failed spawn logs, being the one operational cause among four. 132 /// and only the spawn logs, being the one operational cause among four.
157 /// `create` gets the resolved name, so a live session never holds
158 /// an empty one.
159 pub fn resolve(self: *SessionTable, srv: *Server, wire_name: []const u8, cols: u16, rows: u16) ?usize { 133 pub fn resolve(self: *SessionTable, srv: *Server, wire_name: []const u8, cols: u16, rows: u16) ?usize {
160 const name = proto.resolveName(wire_name); 134 const name = proto.resolveName(wire_name);
161 if (!proto.validSessionName(name)) return null; 135 if (!proto.validSessionName(name)) return null;
@@ -184,10 +158,9 @@ pub const SessionTable = struct {
184 } 158 }
185 159
186 /// Tear down every session whose shell has exited: its clients told and 160 /// Tear down every session whose shell has exited: its clients told and
187 /// dropped, its slot nulled, its name freed. Answers nothing, because a 161 /// dropped, its slot nulled, its name freed. Answers nothing — a shell's
188 /// shell's exit code is a fact about that shell and never about the 162 /// exit code is a fact about that shell, and an emptied table is a daemon
189 /// daemon: an emptied table is a daemon with nothing on it, not a daemon 163 /// with nothing on it rather than one that is leaving.
190 /// that is leaving. `mux d stop` is the end (decisions.md).
191 pub fn reap(self: *SessionTable, srv: *Server) void { 164 pub fn reap(self: *SessionTable, srv: *Server) void {
192 for (&self.table, 0..) |*slot, si| { 165 for (&self.table, 0..) |*slot, si| {
193 const s = if (slot.*) |*sp| sp else continue; 166 const s = if (slot.*) |*sp| sp else continue;
@@ -206,33 +179,16 @@ pub const SessionTable = struct {
206 } 179 }
207 } 180 }
208 if (exited) |code| { 181 if (exited) |code| {
209 // This session's clients, only: exit_status is a fact about 182 // THIS session's clients only: `exit_status` is a fact about one
210 // ONE shell now. Queued then drained under the same 250ms 183 // shell. Queued then drained under a 250ms deadline, because a
211 // deadline as ever, for the same reason as ever — a client 184 // client that misses the frame reads EOF, reports a lost
212 // that misses this frame reads EOF instead, reports 185 // connection and exits 1 — losing the shell's real code rather
213 // "connection to the daemon lost" and exits 1, so the shell's real 186 // than delaying it. The budget can stack to
214 // exit code would be lost rather than merely delayed. The 187 // `max_sessions` x 250ms if the whole table dies at once.
215 // budget can stack — up to max_sessions × 250ms, if the
216 // whole table dies into stalled peers in one pass — and a
217 // stalled bystander client of a surviving session spends it
218 // too, since the drain waits on ALL owed bytes. Both are the
219 // price of draining per-death, bounded by a compile-time
220 // constant of 32 (`max_sessions`) — and only ever paid in
221 // full when the whole table dies into stalled peers in one
222 // pass. A dropped client's outstanding await needs
223 // nothing here: the connection dying IS `mux a`'s answer
224 // (decision 8), which is also why checkAwaits' session-less
225 // `continue` stays unreachable — no client survives its
226 // session.
227 // 188 //
228 // One client this does not reach: one that attaches to the 189 // One client this cannot reach: one that attaches DURING the
229 // dying session DURING the drain. The exit_status frames 190 // drain, since the frames were queued before it existed. That is
230 // were queued before it existed, so it is dropped by the 191 // why the drop loop re-reads the client table.
231 // loop below having been told nothing — it sees the socket
232 // close, not the code. Rare and benign (the session it
233 // asked for is already gone either way), but it is why the
234 // drop loop re-reads the client table instead of reusing
235 // the list the queue loop walked.
236 for (0..max_clients) |i| { 192 for (0..max_clients) |i| {
237 const c = srv.clients[i] orelse continue; 193 const c = srv.clients[i] orelse continue;
238 if (c.session != si) continue; 194 if (c.session != si) continue;
@@ -243,35 +199,27 @@ pub const SessionTable = struct {
243 const c = srv.clients[i] orelse continue; 199 const c = srv.clients[i] orelse continue;
244 if (c.session == si) srv.dropClient(i); 200 if (c.session == si) srv.dropClient(i);
245 } 201 }
246 // Teardown in deinit's order: tracker, title, pending, pty, 202 // Teardown in `deinit`'s order. Nulling the slot frees the name
247 // eng, agent socket. 203 // for re-creation under a NEW epoch, so a client quoting this
248 // Nulling the slot is what frees the name for re-creation — 204 // instance's seqs resyncs by snapshot.
249 // under a new epoch, so a client quoting this instance's
250 // seqs resyncs by snapshot rather than being delta-served
251 // history it never saw.
252 s.tracker.deinit(srv.alloc); 205 s.tracker.deinit(srv.alloc);
253 if (s.title_sent) |t| srv.alloc.free(t); 206 if (s.title_sent) |t| srv.alloc.free(t);
254 s.freePending(srv.alloc); 207 s.freePending(srv.alloc);
255 s.pty.deinit(); 208 s.pty.deinit();
256 s.eng.deinit(); 209 s.eng.deinit();
257 s.closeAgent(srv.alloc); 210 s.closeAgent(srv.alloc);
258 // After the listener, so nothing can be accepted into a 211 // After the listener, so nothing can be accepted into a session
259 // session being torn down. Normally a no-op — the clients 212 // being torn down. Normally a no-op, but a client that reattached
260 // that own these channels were dropped just above, and 213 // elsewhere keeps its channels and is still here to be told.
261 // dropClient swept them — but not always: a client that
262 // reattached elsewhere keeps its channels and is still here
263 // to be told.
264 srv.agents.closeOfSession(srv, si); 214 srv.agents.closeOfSession(srv, si);
265 slot.* = null; 215 slot.* = null;
266 } 216 }
267 } 217 }
268 } 218 }
269 219
270 /// The `sessions_reply` payload: live names, '\n'-separated, slot order. 220 /// The `sessions_reply` payload: live names, '\n'-separated, in SLOT order —
271 /// Slot order rather than creation order because slot order is the only 221 /// the only order the daemon has, and stable across replies, so a client
272 /// order the daemon actually has — and it is stable across a reply, so 222 /// cycling the list sees the same ring unless a session came or went.
273 /// a client cycling through the list (Ctrl-\ n) sees the same ring
274 /// twice running unless a session really came or went.
275 pub fn text(self: *const SessionTable, buf: []u8) []const u8 { 223 pub fn text(self: *const SessionTable, buf: []u8) []const u8 {
276 var w: std.Io.Writer = .fixed(buf); 224 var w: std.Io.Writer = .fixed(buf);
277 for (self.table) |slot| { 225 for (self.table) |slot| {
src/tui/paint.zig
Old New
@@ -1,20 +1,15 @@
1 //! painting the replica to a tty: clipped renders, delta rows, banner, 1 //! Painting the replica to a tty: clipped renders, delta rows, banner,
2 //! scrollback — pure fd-out, no transport knowledge. paintDeltaClipped is 2 //! scrollback — pure fd-out, no transport knowledge. `paintDeltaClipped` is
3 //! the exception worth naming: its `[]const u8` payload is raw wire, which 3 //! the exception: its payload is raw wire, decoded here, so this module knows
4 //! it decodes here rather than being handed rows, so this module knows the 4 //! the delta FORMAT without knowing what carried it. Also the single home of
5 //! delta format even though it knows nothing about what carried it. 5 //! the synchronized-update bracket, for every caller.
6 //!
7 //! Also the single home of the synchronized-update bracket, for every
8 //! caller — including paintOverlay, which lives in interact.zig.
9 const std = @import("std"); 6 const std = @import("std");
10 const Engine = @import("term").engine.Engine; 7 const Engine = @import("term").engine.Engine;
11 const proto = @import("term").protocol; 8 const proto = @import("term").protocol;
12 9
13 /// The synchronized-update bracket. Exists exactly once because a 10 /// The synchronized-update bracket. Exactly once, because a dropped half is
14 /// dropped half is invisible to both e2e suites (the bytes still 11 /// invisible to both e2e suites — the bytes still paint, just tearably — so
15 /// paint, just tearably) — only unit tests see it, and three now pin 12 /// only the three unit pins can see it.
16 /// the halves: renderClipped's, the wrapper below, and paintOverlay's
17 /// in interact.zig.
18 pub const sync_begin = "\x1b[?2026h\x1b[?25l"; 13 pub const sync_begin = "\x1b[?2026h\x1b[?25l";
19 pub const sync_end = "\x1b[?25h\x1b[?2026l"; 14 pub const sync_end = "\x1b[?25h\x1b[?2026l";
20 15
@@ -28,11 +23,10 @@ pub const Highlight = struct {
28 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, 23 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,
29 }; 24 };
30 25
31 /// One grid row, inverted where the highlight says so, bounded to the 26 /// One grid row, inverted where the highlight says so, bounded to the pane's
32 /// pane's own columns. The single place the two painters agree about what 27 /// own columns. The single place the two painters agree about what a selection
33 /// a selection does to a row — and about where a row stops: DECAWM off 28 /// does to a row, and about where a row STOPS: DECAWM off clips at the screen's
34 /// clips a row at the SCREEN's edge, which is the pane's only when the 29 /// edge, which is the pane's only when the pane owns the screen.
35 /// pane owns the screen.
36 fn dumpRow(alloc: std.mem.Allocator, replica: *Engine, y: u16, hl: Highlight, view: Engine.RowView) ![]u8 { 30 fn dumpRow(alloc: std.mem.Allocator, replica: *Engine, y: u16, hl: Highlight, view: Engine.RowView) ![]u8 {
37 const ask = hl.span orelse return replica.dumpVtRowClipped(alloc, y, view); 31 const ask = hl.span orelse return replica.dumpVtRowClipped(alloc, y, view);
38 const s = ask(hl.ctx, y, @intCast(replica.term.cols)) orelse 32 const s = ask(hl.ctx, y, @intCast(replica.term.cols)) orelse
@@ -153,17 +147,11 @@ pub fn paintDeltaClipped(
153 if (row.row >= vp.rows) continue; 147 if (row.row >= vp.rows) continue;
154 var cup: [24]u8 = undefined; 148 var cup: [24]u8 = undefined;
155 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, row.row) + vp.top + 1, vp.left + 1, ech })); 149 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, row.row) + vp.top + 1, vp.left + 1, ech }));
156 // A row under the selection is redrawn from the replica, which has 150 // A row under the selection is redrawn from the replica, which has been
157 // already been fed this very frame — same content, inversion on 151 // fed this very frame; every other row keeps the daemon's bytes verbatim,
158 // top. Every other row keeps the daemon's bytes verbatim, and that 152 // so a held selection costs one dumped row per covered row. Verbatim is
159 // is the whole point: a held selection costs one dumped row per 153 // only safe while the pane is as WIDE as the grid — the daemon's rows are
160 // covered row, not a repaint of the screen. 154 // grid-wide, and a narrower pane's surplus lands on the neighbour.
161 //
162 // Verbatim is only safe while the pane is as wide as the grid: the
163 // daemon's rows are grid-wide, and the surplus of a narrower pane
164 // lands on the rail and the neighbour across it. A row the replica
165 // has not got yet keeps the daemon's bytes regardless — there is
166 // nothing else to paint it from.
167 const overwide = grid_cols > vp.cols and row.row < grid_rows; 155 const overwide = grid_cols > vp.cols and row.row < grid_rows;
168 if (deltaRowSpan(hl, row.row, grid_rows, grid_cols)) |s| { 156 if (deltaRowSpan(hl, row.row, grid_rows, grid_cols)) |s| {
169 const inverted = try replica.dumpVtRowSpan(alloc, row.row, s.from, s.to, view); 157 const inverted = try replica.dumpVtRowSpan(alloc, row.row, s.from, s.to, view);
@@ -197,12 +185,9 @@ fn bannerText(buf: []u8, view_cols: u16, label: []const u8, row_off: u16, col_of
197 return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label }); 185 return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label });
198 } 186 }
199 187
200 // The longest label `paintBanner` will render whole. It holds the wall's 188 // The longest label `paintBanner` renders whole. It holds the wall's add-tile
201 // add-tile prompt line whole — `interact.PrefixFilter.prompt_max` plus its 189 // prompt line, which interact asserts at comptime. Above this, `bufPrint`
202 // `": "` and `"_"` — which interact asserts at comptime, that import being 190 // overflows and the caller's status marker never reaches the screen.
203 // the one that runs the right way.
204 // Below this the banner is best-effort; above it, `bufPrint` overflows and
205 // the caller's status marker silently never reaches the screen.
206 pub const banner_label_max: usize = 260; 191 pub const banner_label_max: usize = 260;
207 192
208 /// Drop a banner onto a screen that is otherwise staying put — the cursor is 193 /// Drop a banner onto a screen that is otherwise staying put — the cursor is
@@ -292,11 +277,9 @@ pub fn renderScrollback(
292 } 277 }
293 var mark_buf: [96]u8 = undefined; 278 var mark_buf: [96]u8 = undefined;
294 try paint.appendSlice(alloc, try bannerText(&mark_buf, vp.cols, "[scroll]", vp.top, vp.left)); 279 try paint.appendSlice(alloc, try bannerText(&mark_buf, vp.cols, "[scroll]", vp.top, vp.left));
295 // The open above is the shared half; only this commit is deliberately 280 // Deliberately unpaired, and not to be "simplified" into `sync_end`: it
296 // unpaired, and it must not be "simplified" into sync_end: it closes the 281 // closes the update WITHOUT the cursor-show, because a cursor parked in a
297 // update WITHOUT the cursor-show, because a cursor parked in a history 282 // history page means nothing. Every exit from scroll mode repaints.
298 // page means nothing. The renderClipped that ends scroll mode brings it
299 // back — every exit path runs one.
300 try paint.appendSlice(alloc, "\x1b[?2026l"); 283 try paint.appendSlice(alloc, "\x1b[?2026l");
301 try proto.writeAllFd(out_fd, paint.items); 284 try proto.writeAllFd(out_fd, paint.items);
302 } 285 }
@@ -499,14 +482,10 @@ test "renderScrollback at a row_off owns only its sub-rect" {
499 defer std.posix.close(pipe[0]); 482 defer std.posix.close(pipe[0]);
500 const row_off: u16 = 5; 483 const row_off: u16 = 5;
501 const size: proto.Size = .{ .cols = 80, .rows = 24 }; 484 const size: proto.Size = .{ .cols = 80, .rows = 24 };
502 // CRLF-separated rows, the shape `dumpScrollback` composes: the split 485 // CRLF-separated rows, the shape `dumpScrollback` composes. Three rows MORE
503 // this paint makes on the row breaks. The leading reset is the 486 // than the tile is tall: the ASK is not what bounds the paint, so a daemon
504 // formatter's own prefix on the first row. 487 // that disagrees about the height would land its surplus on the tile below,
505 // 488 // and a blob shorter than the band could never say so.
506 // Three rows MORE than the tile is tall. The client asks for `rows` of
507 // history, but the ask is not what bounds the paint — a daemon that
508 // disagrees about the tile's height would land its surplus on the tile
509 // below, and a blob shorter than the band can never say so.
510 const blob = comptime blk: { 489 const blob = comptime blk: {
511 var s: []const u8 = "\x1b[0m"; 490 var s: []const u8 = "\x1b[0m";
512 var r: u16 = 1; 491 var r: u16 = 1;
@@ -597,11 +576,9 @@ test "renderClipped paints only rows that fit and clamps the cursor" {
597 // Positional, and both bytes: the update must open before the first CUP, 576 // Positional, and both bytes: the update must open before the first CUP,
598 // and the cursor must go down with it or it visibly walks the rows. 577 // and the cursor must go down with it or it visibly walks the rows.
599 try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h\x1b[?25l")); 578 try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h\x1b[?25l"));
600 // And the bracket closes. Spelled out rather than compared against 579 // Spelled out rather than compared against `sync_end`, which would hold
601 // sync_end, which would hold whatever the constant said: a full repaint 580 // whatever the constant said. A full repaint runs on every attach, resize
602 // runs on every attach, resize and reconnect, so losing the cursor-show 581 // and reconnect, so losing the cursor-show strands a hidden cursor.
603 // or the commit here strands the terminal with a hidden cursor and an
604 // uncommitted frame — and neither e2e suite can see it.
605 try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[?25h\x1b[?2026l")); 582 try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[?25h\x1b[?2026l"));
606 } 583 }
607 584
@@ -740,11 +717,9 @@ test "paintDeltaClipped re-inverts a delta row the selection covers" {
740 const n = try std.posix.read(pipe[0], &out); 717 const n = try std.posix.read(pipe[0], &out);
741 const text = out[0..n]; 718 const text = out[0..n];
742 719
743 // A delta paints the daemon's bytes as the daemon sent them, so a row 720 // A delta paints the daemon's bytes as sent, so a row under the selection
744 // under the selection would come back un-inverted and the highlight 721 // would come back un-inverted and the highlight would develop holes wherever
745 // would develop holes wherever the session was still writing. The 722 // the session was writing. The covered row is redrawn from the replica.
746 // covered row is redrawn from the replica instead, with the inversion
747 // on it and the span opening at column 4 (0-based 3).
748 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\x1b[7m")); 723 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\x1b[7m"));
749 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null); 724 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null);
750 // The uncovered row is still the daemon's own bytes, verbatim: this 725 // The uncovered row is still the daemon's own bytes, verbatim: this
@@ -754,13 +729,10 @@ test "paintDeltaClipped re-inverts a delta row the selection covers" {
754 } 729 }
755 730
756 test "paintDeltaClipped brackets the whole paint in one synchronized update" { 731 test "paintDeltaClipped brackets the whole paint in one synchronized update" {
757 // The delta path's flicker defence, and the one layer that can hold it: 732 // The delta path's flicker defence, and the one layer that can hold it: a
758 // a synchronized update changes WHEN the terminal shows the paint, never 733 // synchronized update changes WHEN the terminal shows the paint, never WHAT,
759 // WHAT it shows, so no rendered grid can tell a torn paint from a whole 734 // so no rendered grid can tell a torn paint from a whole one. Asserted on
760 // one. The campaign proved that the hard way — mutation 5 dropped this 735 // the ENDS, since only position shows that every row lands inside.
761 // wrapper and both e2e suites passed, because there was nothing for them
762 // to see. Asserted on the ends rather than by substring search: the point
763 // is that every row lands INSIDE the brackets, which only position shows.
764 const alloc = std.testing.allocator; 736 const alloc = std.testing.allocator;
765 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 30 }); 737 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 30 });
766 defer replica.deinit(); 738 defer replica.deinit();
@@ -791,11 +763,9 @@ test "paintDeltaClipped brackets the whole paint in one synchronized update" {
791 } 763 }
792 764
793 test "paint: a delta lands in the tile's rect, every row of it" { 765 test "paint: a delta lands in the tile's rect, every row of it" {
794 // Every delta row's CUP takes both offsets, not just the cursor's: a 766 // Every delta row's CUP takes BOTH offsets, not just the cursor's: a frame
795 // frame that moved some rows and left others at the screen origin tears 767 // that left some rows at the screen origin tears a held selection across two
796 // a held selection across two tiles. Judged on the grid, because a row 768 // tiles. Judged on the grid — a misplaced row still emits the right address.
797 // that landed one tile up still puts the address the search wants in
798 // the stream.
799 const alloc = std.testing.allocator; 769 const alloc = std.testing.allocator;
800 const row_off: u16 = 2; 770 const row_off: u16 = 2;
801 var replica = try Engine.init(alloc, .{ .cols = beside_width, .rows = 4 }); 771 var replica = try Engine.init(alloc, .{ .cols = beside_width, .rows = 4 });
@@ -869,11 +839,9 @@ test "a pane off the left edge paints inside its own span" {
869 } 839 }
870 840
871 // ------------------------------------------------- the beside-pane oracle 841 // ------------------------------------------------- the beside-pane oracle
872 // 842 // A painter that emits TOO MUCH is invisible to a substring search: every byte
873 // A painter that emits TOO MUCH is invisible to a substring search: every 843 // the assertion wants is present, plus the ones that landed on the neighbour.
874 // byte the assertion looks for is present, plus the ones that landed on the 844 // So these replay onto a screen holding two panes and judge the GRID.
875 // neighbour. So these replay the paint onto a screen that already holds a
876 // left pane, a rail and a right pane, and judge the resulting GRID.
877 845
878 /// Screen columns of the fixture below: a 12-column left pane, a rail, then 846 /// Screen columns of the fixture below: a 12-column left pane, a rail, then
879 /// the pane under test from column 13 to the screen's edge. 847 /// the pane under test from column 13 to the screen's edge.