524037c1
refactor: painter, askpass, session table, relay, delta and layout
a73x 2026-08-30 20:06
Commit message
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. |