f9f8a3f2
refactor: DeltaTracker and socket-path identity extract with their tests
a73x 2026-08-12 19:06
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -173,6 +173,27 @@ pub fn build(b: *std.Build) void { | |||
| 173 | // file shares with the key file. xdg is a leaf, so no cycle. | 173 | // file shares with the key file. xdg is a leaf, so no cycle. |
| 174 | handoff_mod.addImport("xdg", xdg_mod); | 174 | handoff_mod.addImport("xdg", xdg_mod); |
| 175 | 175 | ||
| 176 | // Row-level change tracking behind the delta stream. Engine plus | ||
| 177 | // protocol and nothing else — no daemon, no clients — so the tracker's | ||
| 178 | // own tests drive it with an engine and no socket in sight. | ||
| 179 | const delta_mod = b.createModule(.{ | ||
| 180 | .root_source_file = b.path("src/delta.zig"), | ||
| 181 | .target = target, | ||
| 182 | .optimize = optimize, | ||
| 183 | }); | ||
| 184 | delta_mod.addImport("engine", engine_mod); | ||
| 185 | delta_mod.addImport("protocol", protocol_mod); | ||
| 186 | |||
| 187 | // The socket path's identity and the right to bind it: the stale-socket | ||
| 188 | // claim and the dev+ino record teardown compares against. A leaf — it | ||
| 189 | // takes a path and nothing else, and knows no Server exists. | ||
| 190 | const sockpath_mod = b.createModule(.{ | ||
| 191 | .root_source_file = b.path("src/sockpath.zig"), | ||
| 192 | .target = target, | ||
| 193 | .optimize = optimize, | ||
| 194 | .link_libc = true, | ||
| 195 | }); | ||
| 196 | |||
| 176 | const server_mod = b.createModule(.{ | 197 | const server_mod = b.createModule(.{ |
| 177 | .root_source_file = b.path("src/server.zig"), | 198 | .root_source_file = b.path("src/server.zig"), |
| 178 | .target = target, | 199 | .target = target, |
| @@ -182,6 +203,8 @@ pub fn build(b: *std.Build) void { | |||
| 182 | server_mod.addImport("engine", engine_mod); | 203 | server_mod.addImport("engine", engine_mod); |
| 183 | server_mod.addImport("pty", pty_mod); | 204 | server_mod.addImport("pty", pty_mod); |
| 184 | server_mod.addImport("protocol", protocol_mod); | 205 | server_mod.addImport("protocol", protocol_mod); |
| 206 | server_mod.addImport("delta", delta_mod); | ||
| 207 | server_mod.addImport("sockpath", sockpath_mod); | ||
| 185 | // Both: the listener it owns, and the vocabulary it names directly | 208 | // Both: the listener it owns, and the vocabulary it names directly |
| 186 | // (the key it loads, the idle default it falls back to). | 209 | // (the key it loads, the idle default it falls back to). |
| 187 | server_mod.addImport("quic", quic_mod); | 210 | server_mod.addImport("quic", quic_mod); |
| @@ -356,12 +379,18 @@ pub fn build(b: *std.Build) void { | |||
| 356 | b.installArtifact(ptyclient_exe); | 379 | b.installArtifact(ptyclient_exe); |
| 357 | 380 | ||
| 358 | const test_step = b.step("test", "Run unit tests"); | 381 | const test_step = b.step("test", "Run unit tests"); |
| 382 | // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their | ||
| 383 | // tests are seconds-long and socket-free, while a regression in either | ||
| 384 | // can wedge a server test that waits on a client forever — and a wedged | ||
| 385 | // step prints nothing at all. Failing first is what makes the catch | ||
| 386 | // legible. | ||
| 387 | // | ||
| 359 | // mux_mod and exe_mod are executable roots, but they carry the argument | 388 | // mux_mod and exe_mod are executable roots, but they carry the argument |
| 360 | // parsers, and a test that is never built is not a test. exe_mod's | 389 | // parsers, and a test that is never built is not a test. exe_mod's |
| 361 | // absence here was a live hazard recorded in decisions.md — muxd's | 390 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 362 | // entrypoint could grow tests that silently never ran, exactly as | 391 | // entrypoint could grow tests that silently never ran, exactly as |
| 363 | // mux_main.zig's five did before it was added. | 392 | // mux_main.zig's five did before it was added. |
| 364 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { | 393 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { |
| 365 | const t = b.addTest(.{ .root_module = mod }); | 394 | const t = b.addTest(.{ .root_module = mod }); |
| 366 | t.use_llvm = true; | 395 | t.use_llvm = true; |
| 367 | t.use_lld = true; | 396 | t.use_lld = true; |
src/delta.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,231 @@ | |||
| 1 | //! Row-level change tracking behind the delta stream: one content hash per | ||
| 2 | //! viewport row, so an engine update can be sent as just the rows that | ||
| 3 | //! actually changed, and a reattach can be answered with a delta instead of | ||
| 4 | //! a full snapshot. | ||
| 5 | //! | ||
| 6 | //! Engine and protocol are the whole of its world — no daemon, no clients, | ||
| 7 | //! no sockets — which is what lets the tracker be driven directly by a test | ||
| 8 | //! holding nothing but an engine. | ||
| 9 | const std = @import("std"); | ||
| 10 | const Engine = @import("engine").Engine; | ||
| 11 | const proto = @import("protocol"); | ||
| 12 | |||
| 13 | const Wyhash = std.hash.Wyhash; | ||
| 14 | |||
| 15 | /// Tracks a content hash per viewport row so an engine update can be sent | ||
| 16 | /// as just the rows that actually changed. Advances whether or not a | ||
| 17 | /// client is attached, so a reattach can be answered with a delta. | ||
| 18 | pub const DeltaTracker = struct { | ||
| 19 | seq: u64 = 0, | ||
| 20 | /// Seq at the last discontinuity (init/resize/screen switch). Clients | ||
| 21 | /// with have_seq older than this cannot be served a delta. | ||
| 22 | reset_seq: u64 = 0, | ||
| 23 | cols: u16 = 0, | ||
| 24 | rows: u16 = 0, | ||
| 25 | on_alt: bool = false, | ||
| 26 | cursor: Engine.CursorPos = .{ .x = 0, .y = 0 }, | ||
| 27 | history_rows: u32 = 0, | ||
| 28 | row_hashes: []u64 = &.{}, | ||
| 29 | row_seqs: []u64 = &.{}, | ||
| 30 | /// Where update() writes the hashes it is computing, so a diff pass | ||
| 31 | /// allocates nothing. Swapped with row_hashes once per advance. | ||
| 32 | scratch_hashes: []u64 = &.{}, | ||
| 33 | |||
| 34 | pub fn deinit(self: *DeltaTracker, alloc: std.mem.Allocator) void { | ||
| 35 | alloc.free(self.row_hashes); | ||
| 36 | alloc.free(self.row_seqs); | ||
| 37 | alloc.free(self.scratch_hashes); | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Re-hash every row and mark a discontinuity: what follows can only be | ||
| 41 | /// carried by a full snapshot. | ||
| 42 | pub fn rebuild(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, rows: u16, cols: u16) !void { | ||
| 43 | if (self.row_hashes.len != rows) { | ||
| 44 | // Every allocation lands before any old array is released, so a | ||
| 45 | // failure here leaves the tracker exactly as it was rather than | ||
| 46 | // half-freed. update()'s geometry check turns the resulting | ||
| 47 | // stale-but-consistent state into a snapshot on the next pump. | ||
| 48 | const hashes = try alloc.alloc(u64, rows); | ||
| 49 | errdefer alloc.free(hashes); | ||
| 50 | const seqs = try alloc.alloc(u64, rows); | ||
| 51 | errdefer alloc.free(seqs); | ||
| 52 | const scratch = try alloc.alloc(u64, rows); | ||
| 53 | alloc.free(self.row_hashes); | ||
| 54 | alloc.free(self.row_seqs); | ||
| 55 | alloc.free(self.scratch_hashes); | ||
| 56 | self.row_hashes = hashes; | ||
| 57 | self.row_seqs = seqs; | ||
| 58 | self.scratch_hashes = scratch; | ||
| 59 | } | ||
| 60 | self.cols = cols; | ||
| 61 | self.on_alt = eng.onAltScreen(); | ||
| 62 | self.seq += 1; | ||
| 63 | self.reset_seq = self.seq; | ||
| 64 | self.cursor = eng.cursorPos(); | ||
| 65 | self.history_rows = eng.historyRows(); | ||
| 66 | // Claim no rows until every row is stamped. A dump that fails | ||
| 67 | // partway would otherwise leave stale seqs in the tail of | ||
| 68 | // row_seqs, and any of them above a client's have_seq would put | ||
| 69 | // that row in every delta from here on. rows == 0 sends update() | ||
| 70 | // down its first-run branch and sendResync to a snapshot instead. | ||
| 71 | self.rows = 0; | ||
| 72 | for (0..rows) |y| { | ||
| 73 | const bytes = try eng.dumpVtRow(alloc, @intCast(y)); | ||
| 74 | defer alloc.free(bytes); | ||
| 75 | self.row_hashes[y] = Wyhash.hash(0, bytes); | ||
| 76 | self.row_seqs[y] = self.seq; | ||
| 77 | } | ||
| 78 | self.rows = rows; | ||
| 79 | } | ||
| 80 | |||
| 81 | pub const Update = union(enum) { | ||
| 82 | none, | ||
| 83 | discontinuity, | ||
| 84 | /// Something changed and the tracker now describes it; the payload | ||
| 85 | /// is built separately, and only if a client is there to read it. | ||
| 86 | advanced, | ||
| 87 | }; | ||
| 88 | |||
| 89 | /// Diff current engine state against the tracked state. Advances seq | ||
| 90 | /// and tracked rows when anything changed. Allocates only the per-row | ||
| 91 | /// dumps it hashes. | ||
| 92 | pub fn update(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine) !Update { | ||
| 93 | if (self.rows == 0) return .discontinuity; // never built | ||
| 94 | // Geometry is load-bearing, not decorative: row_hashes is indexed | ||
| 95 | // by the tracker's own row count, and dumpVtRow asserts against the | ||
| 96 | // engine's. A tracker left stale by a failed rebuild resyncs here | ||
| 97 | // instead of running off the end of the grid. | ||
| 98 | if (self.rows != eng.term.rows or self.cols != eng.term.cols) return .discontinuity; | ||
| 99 | if (eng.onAltScreen() != self.on_alt) return .discontinuity; | ||
| 100 | |||
| 101 | // Safe to stamp row_seqs with the seq we may not end up taking: it | ||
| 102 | // is only written for rows whose hash changed, and any changed row | ||
| 103 | // forces the advance below. | ||
| 104 | const next_seq = self.seq + 1; | ||
| 105 | var changed: usize = 0; | ||
| 106 | for (0..self.rows) |y| { | ||
| 107 | const bytes = try eng.dumpVtRow(alloc, @intCast(y)); | ||
| 108 | defer alloc.free(bytes); | ||
| 109 | const hash = Wyhash.hash(0, bytes); | ||
| 110 | self.scratch_hashes[y] = hash; | ||
| 111 | if (hash != self.row_hashes[y]) { | ||
| 112 | self.row_seqs[y] = next_seq; | ||
| 113 | changed += 1; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | const cur = eng.cursorPos(); | ||
| 118 | const hist = eng.historyRows(); | ||
| 119 | const cursor_moved = cur.x != self.cursor.x or cur.y != self.cursor.y; | ||
| 120 | if (changed == 0 and !cursor_moved and hist == self.history_rows) | ||
| 121 | return .none; | ||
| 122 | |||
| 123 | self.seq = next_seq; | ||
| 124 | self.cursor = cur; | ||
| 125 | self.history_rows = hist; | ||
| 126 | std.mem.swap([]u64, &self.row_hashes, &self.scratch_hashes); | ||
| 127 | return .advanced; | ||
| 128 | } | ||
| 129 | |||
| 130 | /// Can a client holding `have_seq` be sent a delta rather than a full | ||
| 131 | /// snapshot? Only the tracker's half of that question: whether the seq | ||
| 132 | /// falls inside the span this tracker can still describe. Whether the | ||
| 133 | /// seq is even ours to interpret — the epoch — is the caller's, since | ||
| 134 | /// the tracker does not know which daemon instance it belongs to. | ||
| 135 | /// | ||
| 136 | /// 0 is never serviceable: it is what a client says when it holds | ||
| 137 | /// nothing. | ||
| 138 | pub fn canServe(self: *const DeltaTracker, have_seq: u64) bool { | ||
| 139 | return have_seq != 0 and | ||
| 140 | have_seq >= self.reset_seq and have_seq <= self.seq and | ||
| 141 | self.rows != 0; | ||
| 142 | } | ||
| 143 | |||
| 144 | /// Build a delta payload of all rows changed after `since`. The header | ||
| 145 | /// row_count and the appended rows MUST agree (composeDelta validates), | ||
| 146 | /// so both come from the same `row_seq > since` predicate over row_seqs | ||
| 147 | /// with nothing mutating in between. | ||
| 148 | /// | ||
| 149 | /// Changed rows get dumped twice per update — once to hash, once here | ||
| 150 | /// to serialize. That is the price of one row-selection routine serving | ||
| 151 | /// both the live stream and the attach path, and it is 1-3 rows in | ||
| 152 | /// steady state. | ||
| 153 | pub fn buildDeltaSince(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, since: u64) ![]u8 { | ||
| 154 | var rows_changed: u16 = 0; | ||
| 155 | for (self.row_seqs) |s| { | ||
| 156 | if (s > since) rows_changed += 1; | ||
| 157 | } | ||
| 158 | var payload: std.ArrayList(u8) = .empty; | ||
| 159 | errdefer payload.deinit(alloc); | ||
| 160 | try proto.appendDeltaHeader(&payload, alloc, .{ | ||
| 161 | .seq = self.seq, | ||
| 162 | .history_rows = self.history_rows, | ||
| 163 | .cursor_x = self.cursor.x, | ||
| 164 | .cursor_y = self.cursor.y, | ||
| 165 | .row_count = rows_changed, | ||
| 166 | }); | ||
| 167 | for (self.row_seqs, 0..) |s, y| { | ||
| 168 | if (s <= since) continue; | ||
| 169 | const bytes = try eng.dumpVtRow(alloc, @intCast(y)); | ||
| 170 | defer alloc.free(bytes); | ||
| 171 | try proto.appendDeltaRow(&payload, alloc, @intCast(y), bytes); | ||
| 172 | } | ||
| 173 | return payload.toOwnedSlice(alloc); | ||
| 174 | } | ||
| 175 | }; | ||
| 176 | |||
| 177 | test "DeltaTracker: alt-screen flip is a discontinuity and rows follow the active screen" { | ||
| 178 | const alloc = std.testing.allocator; | ||
| 179 | |||
| 180 | const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 181 | defer eng.deinit(); | ||
| 182 | |||
| 183 | var tracker: DeltaTracker = .{}; | ||
| 184 | defer tracker.deinit(alloc); | ||
| 185 | try tracker.rebuild(alloc, eng, 24, 80); | ||
| 186 | |||
| 187 | eng.feed("primary text"); | ||
| 188 | switch (try tracker.update(alloc, eng)) { | ||
| 189 | .advanced => {}, | ||
| 190 | else => return error.ExpectedAdvance, | ||
| 191 | } | ||
| 192 | |||
| 193 | // Switching screens replaces every row at once: the tracked hashes | ||
| 194 | // describe the other screen, so a delta would be a lie. | ||
| 195 | eng.feed("\x1b[?1049h"); | ||
| 196 | switch (try tracker.update(alloc, eng)) { | ||
| 197 | .discontinuity => {}, | ||
| 198 | else => return error.ExpectedDiscontinuity, | ||
| 199 | } | ||
| 200 | |||
| 201 | try tracker.rebuild(alloc, eng, 24, 80); | ||
| 202 | eng.feed("alt content"); | ||
| 203 | switch (try tracker.update(alloc, eng)) { | ||
| 204 | .advanced => {}, | ||
| 205 | else => return error.ExpectedAdvance, | ||
| 206 | } | ||
| 207 | const payload = try tracker.buildDeltaSince(alloc, eng, tracker.seq - 1); | ||
| 208 | defer alloc.free(payload); | ||
| 209 | const composed = try proto.composeDelta(alloc, payload); | ||
| 210 | defer alloc.free(composed.bytes); | ||
| 211 | try std.testing.expect(std.mem.indexOf(u8, composed.bytes, "alt content") != null); | ||
| 212 | } | ||
| 213 | |||
| 214 | test "DeltaTracker: a resize behind the tracker's back resyncs instead of over-reading" { | ||
| 215 | const alloc = std.testing.allocator; | ||
| 216 | |||
| 217 | const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 218 | defer eng.deinit(); | ||
| 219 | |||
| 220 | var tracker: DeltaTracker = .{}; | ||
| 221 | defer tracker.deinit(alloc); | ||
| 222 | try tracker.rebuild(alloc, eng, 24, 80); | ||
| 223 | |||
| 224 | // Stands in for a rebuild that failed (OOM) after the engine resized: | ||
| 225 | // the tracker still describes 24 rows of an 80-column grid. | ||
| 226 | try eng.resize(80, 10); | ||
| 227 | switch (try tracker.update(alloc, eng)) { | ||
| 228 | .discontinuity => {}, | ||
| 229 | else => return error.ExpectedDiscontinuity, | ||
| 230 | } | ||
| 231 | } | ||
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,8 @@ const std = @import("std"); | |||
| 9 | const Engine = @import("engine").Engine; | 9 | const Engine = @import("engine").Engine; |
| 10 | const Pty = @import("pty").Pty; | 10 | const Pty = @import("pty").Pty; |
| 11 | const proto = @import("protocol"); | 11 | const proto = @import("protocol"); |
| 12 | const DeltaTracker = @import("delta").DeltaTracker; | ||
| 13 | const sockpath = @import("sockpath"); | ||
| 12 | const quic = @import("quic"); | 14 | const quic = @import("quic"); |
| 13 | const quic_server = @import("quic_server"); | 15 | const quic_server = @import("quic_server"); |
| 14 | const xdg = @import("xdg"); | 16 | const xdg = @import("xdg"); |
| @@ -75,8 +77,6 @@ fn boundUdpPort(l: *quic_server.Listener) u16 { | |||
| 75 | return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort(); | 77 | return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort(); |
| 76 | } | 78 | } |
| 77 | 79 | ||
| 78 | const Wyhash = std.hash.Wyhash; | ||
| 79 | |||
| 80 | const Stats = struct { | 80 | const Stats = struct { |
| 81 | snapshots: u64 = 0, | 81 | snapshots: u64 = 0, |
| 82 | snapshot_bytes: u64 = 0, | 82 | snapshot_bytes: u64 = 0, |
| @@ -87,154 +87,6 @@ const Stats = struct { | |||
| 87 | snapshot_equiv_bytes: u64 = 0, | 87 | snapshot_equiv_bytes: u64 = 0, |
| 88 | }; | 88 | }; |
| 89 | 89 | ||
| 90 | /// Tracks a content hash per viewport row so an engine update can be sent | ||
| 91 | /// as just the rows that actually changed. Advances whether or not a | ||
| 92 | /// client is attached, so a reattach can be answered with a delta. | ||
| 93 | const DeltaTracker = struct { | ||
| 94 | seq: u64 = 0, | ||
| 95 | /// Seq at the last discontinuity (init/resize/screen switch). Clients | ||
| 96 | /// with have_seq older than this cannot be served a delta. | ||
| 97 | reset_seq: u64 = 0, | ||
| 98 | cols: u16 = 0, | ||
| 99 | rows: u16 = 0, | ||
| 100 | on_alt: bool = false, | ||
| 101 | cursor: Engine.CursorPos = .{ .x = 0, .y = 0 }, | ||
| 102 | history_rows: u32 = 0, | ||
| 103 | row_hashes: []u64 = &.{}, | ||
| 104 | row_seqs: []u64 = &.{}, | ||
| 105 | /// Where update() writes the hashes it is computing, so a diff pass | ||
| 106 | /// allocates nothing. Swapped with row_hashes once per advance. | ||
| 107 | scratch_hashes: []u64 = &.{}, | ||
| 108 | |||
| 109 | fn deinit(self: *DeltaTracker, alloc: std.mem.Allocator) void { | ||
| 110 | alloc.free(self.row_hashes); | ||
| 111 | alloc.free(self.row_seqs); | ||
| 112 | alloc.free(self.scratch_hashes); | ||
| 113 | } | ||
| 114 | |||
| 115 | /// Re-hash every row and mark a discontinuity: what follows can only be | ||
| 116 | /// carried by a full snapshot. | ||
| 117 | fn rebuild(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, rows: u16, cols: u16) !void { | ||
| 118 | if (self.row_hashes.len != rows) { | ||
| 119 | // Every allocation lands before any old array is released, so a | ||
| 120 | // failure here leaves the tracker exactly as it was rather than | ||
| 121 | // half-freed. update()'s geometry check turns the resulting | ||
| 122 | // stale-but-consistent state into a snapshot on the next pump. | ||
| 123 | const hashes = try alloc.alloc(u64, rows); | ||
| 124 | errdefer alloc.free(hashes); | ||
| 125 | const seqs = try alloc.alloc(u64, rows); | ||
| 126 | errdefer alloc.free(seqs); | ||
| 127 | const scratch = try alloc.alloc(u64, rows); | ||
| 128 | alloc.free(self.row_hashes); | ||
| 129 | alloc.free(self.row_seqs); | ||
| 130 | alloc.free(self.scratch_hashes); | ||
| 131 | self.row_hashes = hashes; | ||
| 132 | self.row_seqs = seqs; | ||
| 133 | self.scratch_hashes = scratch; | ||
| 134 | } | ||
| 135 | self.cols = cols; | ||
| 136 | self.on_alt = eng.onAltScreen(); | ||
| 137 | self.seq += 1; | ||
| 138 | self.reset_seq = self.seq; | ||
| 139 | self.cursor = eng.cursorPos(); | ||
| 140 | self.history_rows = eng.historyRows(); | ||
| 141 | // Claim no rows until every row is stamped. A dump that fails | ||
| 142 | // partway would otherwise leave stale seqs in the tail of | ||
| 143 | // row_seqs, and any of them above a client's have_seq would put | ||
| 144 | // that row in every delta from here on. rows == 0 sends update() | ||
| 145 | // down its first-run branch and sendResync to a snapshot instead. | ||
| 146 | self.rows = 0; | ||
| 147 | for (0..rows) |y| { | ||
| 148 | const bytes = try eng.dumpVtRow(alloc, @intCast(y)); | ||
| 149 | defer alloc.free(bytes); | ||
| 150 | self.row_hashes[y] = Wyhash.hash(0, bytes); | ||
| 151 | self.row_seqs[y] = self.seq; | ||
| 152 | } | ||
| 153 | self.rows = rows; | ||
| 154 | } | ||
| 155 | |||
| 156 | const Update = union(enum) { | ||
| 157 | none, | ||
| 158 | discontinuity, | ||
| 159 | /// Something changed and the tracker now describes it; the payload | ||
| 160 | /// is built separately, and only if a client is there to read it. | ||
| 161 | advanced, | ||
| 162 | }; | ||
| 163 | |||
| 164 | /// Diff current engine state against the tracked state. Advances seq | ||
| 165 | /// and tracked rows when anything changed. Allocates only the per-row | ||
| 166 | /// dumps it hashes. | ||
| 167 | fn update(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine) !Update { | ||
| 168 | if (self.rows == 0) return .discontinuity; // never built | ||
| 169 | // Geometry is load-bearing, not decorative: row_hashes is indexed | ||
| 170 | // by the tracker's own row count, and dumpVtRow asserts against the | ||
| 171 | // engine's. A tracker left stale by a failed rebuild resyncs here | ||
| 172 | // instead of running off the end of the grid. | ||
| 173 | if (self.rows != eng.term.rows or self.cols != eng.term.cols) return .discontinuity; | ||
| 174 | if (eng.onAltScreen() != self.on_alt) return .discontinuity; | ||
| 175 | |||
| 176 | // Safe to stamp row_seqs with the seq we may not end up taking: it | ||
| 177 | // is only written for rows whose hash changed, and any changed row | ||
| 178 | // forces the advance below. | ||
| 179 | const next_seq = self.seq + 1; | ||
| 180 | var changed: usize = 0; | ||
| 181 | for (0..self.rows) |y| { | ||
| 182 | const bytes = try eng.dumpVtRow(alloc, @intCast(y)); | ||
| 183 | defer alloc.free(bytes); | ||
| 184 | const hash = Wyhash.hash(0, bytes); | ||
| 185 | self.scratch_hashes[y] = hash; | ||
| 186 | if (hash != self.row_hashes[y]) { | ||
| 187 | self.row_seqs[y] = next_seq; | ||
| 188 | changed += 1; | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | const cur = eng.cursorPos(); | ||
| 193 | const hist = eng.historyRows(); | ||
| 194 | const cursor_moved = cur.x != self.cursor.x or cur.y != self.cursor.y; | ||
| 195 | if (changed == 0 and !cursor_moved and hist == self.history_rows) | ||
| 196 | return .none; | ||
| 197 | |||
| 198 | self.seq = next_seq; | ||
| 199 | self.cursor = cur; | ||
| 200 | self.history_rows = hist; | ||
| 201 | std.mem.swap([]u64, &self.row_hashes, &self.scratch_hashes); | ||
| 202 | return .advanced; | ||
| 203 | } | ||
| 204 | |||
| 205 | /// Build a delta payload of all rows changed after `since`. The header | ||
| 206 | /// row_count and the appended rows MUST agree (composeDelta validates), | ||
| 207 | /// so both come from the same `row_seq > since` predicate over row_seqs | ||
| 208 | /// with nothing mutating in between. | ||
| 209 | /// | ||
| 210 | /// Changed rows get dumped twice per update — once to hash, once here | ||
| 211 | /// to serialize. That is the price of one row-selection routine serving | ||
| 212 | /// both the live stream and the attach path, and it is 1-3 rows in | ||
| 213 | /// steady state. | ||
| 214 | fn buildDeltaSince(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, since: u64) ![]u8 { | ||
| 215 | var rows_changed: u16 = 0; | ||
| 216 | for (self.row_seqs) |s| { | ||
| 217 | if (s > since) rows_changed += 1; | ||
| 218 | } | ||
| 219 | var payload: std.ArrayList(u8) = .empty; | ||
| 220 | errdefer payload.deinit(alloc); | ||
| 221 | try proto.appendDeltaHeader(&payload, alloc, .{ | ||
| 222 | .seq = self.seq, | ||
| 223 | .history_rows = self.history_rows, | ||
| 224 | .cursor_x = self.cursor.x, | ||
| 225 | .cursor_y = self.cursor.y, | ||
| 226 | .row_count = rows_changed, | ||
| 227 | }); | ||
| 228 | for (self.row_seqs, 0..) |s, y| { | ||
| 229 | if (s <= since) continue; | ||
| 230 | const bytes = try eng.dumpVtRow(alloc, @intCast(y)); | ||
| 231 | defer alloc.free(bytes); | ||
| 232 | try proto.appendDeltaRow(&payload, alloc, @intCast(y), bytes); | ||
| 233 | } | ||
| 234 | return payload.toOwnedSlice(alloc); | ||
| 235 | } | ||
| 236 | }; | ||
| 237 | |||
| 238 | var shutdown_flag = std.atomic.Value(bool).init(false); | 90 | var shutdown_flag = std.atomic.Value(bool).init(false); |
| 239 | 91 | ||
| 240 | fn onShutdownSignal(_: c_int) callconv(.c) void { | 92 | fn onShutdownSignal(_: c_int) callconv(.c) void { |
| @@ -365,18 +217,9 @@ pub const Server = struct { | |||
| 365 | pty: Pty, | 217 | pty: Pty, |
| 366 | listener: std.net.Server, | 218 | listener: std.net.Server, |
| 367 | sock_path: []const u8, | 219 | sock_path: []const u8, |
| 368 | /// The socket file's identity at the moment it was bound, so teardown | 220 | /// What the socket file was when we bound it, so teardown can tell our |
| 369 | /// can tell our socket from one that replaced it. | 221 | /// socket from one that replaced it. See sockpath.PathId. |
| 370 | /// | 222 | path_id: ?sockpath.PathId, |
| 371 | /// The PATH's dev+ino, deliberately, not the listening descriptor's: a | ||
| 372 | /// bound unix socket's descriptor lives in sockfs (dev 10 here) while | ||
| 373 | /// the path resolves to an ordinary filesystem inode (dev 38), so | ||
| 374 | /// comparing the two could never be equal. The guard read as careful | ||
| 375 | /// and was unconditionally false, which meant the daemon never unlinked | ||
| 376 | /// its socket on a clean exit at all — masked ever since by the stale | ||
| 377 | /// socket recovery in `claimSockPath` cleaning up on the next start. | ||
| 378 | sock_dev: u64, | ||
| 379 | sock_ino: u64, | ||
| 380 | /// The attached interactive clients. All of them see every update. | 223 | /// The attached interactive clients. All of them see every update. |
| 381 | clients: [max_clients]?ClientSlot = @splat(null), | 224 | clients: [max_clients]?ClientSlot = @splat(null), |
| 382 | /// How much unsent output one client may accumulate before the daemon | 225 | /// How much unsent output one client may accumulate before the daemon |
| @@ -430,7 +273,7 @@ pub const Server = struct { | |||
| 430 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | 273 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { |
| 431 | // Before the shell is spawned, so refusing costs nobody a fork and | 274 | // Before the shell is spawned, so refusing costs nobody a fork and |
| 432 | // leaves no process to reap. | 275 | // leaves no process to reap. |
| 433 | try claimSockPath(opts.sock_path); | 276 | try sockpath.claim(opts.sock_path); |
| 434 | 277 | ||
| 435 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); | 278 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); |
| 436 | errdefer eng.deinit(); | 279 | errdefer eng.deinit(); |
| @@ -446,67 +289,18 @@ pub const Server = struct { | |||
| 446 | 289 | ||
| 447 | const addr = try std.net.Address.initUnix(opts.sock_path); | 290 | const addr = try std.net.Address.initUnix(opts.sock_path); |
| 448 | const listener = try addr.listen(.{}); | 291 | const listener = try addr.listen(.{}); |
| 449 | // Stat the path we just created, not the descriptor: this is the | 292 | const path_id = try sockpath.PathId.of(opts.sock_path); |
| 450 | // record teardown compares against, and it has to be taken through | ||
| 451 | // the same lens it will be re-read through. | ||
| 452 | const st = try std.posix.fstatat(std.posix.AT.FDCWD, opts.sock_path, 0); | ||
| 453 | return .{ | 293 | return .{ |
| 454 | .alloc = alloc, | 294 | .alloc = alloc, |
| 455 | .eng = eng, | 295 | .eng = eng, |
| 456 | .pty = pty, | 296 | .pty = pty, |
| 457 | .listener = listener, | 297 | .listener = listener, |
| 458 | .sock_path = opts.sock_path, | 298 | .sock_path = opts.sock_path, |
| 459 | .sock_dev = @intCast(st.dev), | 299 | .path_id = path_id, |
| 460 | .sock_ino = @intCast(st.ino), | ||
| 461 | .epoch = epoch, | 300 | .epoch = epoch, |
| 462 | }; | 301 | }; |
| 463 | } | 302 | } |
| 464 | 303 | ||
| 465 | /// Make the socket path ours to bind, or refuse it. Field incident this | ||
| 466 | /// exists for: three daemons were started against one path, each | ||
| 467 | /// unlinking it and binding fresh. Every one of them kept running with | ||
| 468 | /// its sessions intact, but only the newest was reachable — the older | ||
| 469 | /// two were stranded, invisible, holding shells nobody could get back | ||
| 470 | /// to, and two terminals "in the same session" were really in two | ||
| 471 | /// different ones. | ||
| 472 | /// | ||
| 473 | /// So: unlink only what answers ECONNREFUSED *and* is a socket. | ||
| 474 | /// - something answers → a live daemon owns this path. Refuse. | ||
| 475 | /// - nothing there → bind, nothing to clean up. | ||
| 476 | /// - a dead socket file → a daemon that died without deinit's | ||
| 477 | /// unlink running. Ours to clear. | ||
| 478 | /// - anything else → propagate. A path we cannot positively | ||
| 479 | /// identify as a dead daemon's leftover is not | ||
| 480 | /// something we may delete. | ||
| 481 | fn claimSockPath(path: []const u8) !void { | ||
| 482 | if (std.net.connectUnixSocket(path)) |probe| { | ||
| 483 | probe.close(); | ||
| 484 | return error.DaemonAlreadyRunning; | ||
| 485 | } else |err| switch (err) { | ||
| 486 | error.FileNotFound => return, // free path; bind straight away | ||
| 487 | // Nobody is listening — but this is NOT yet proof of a stale | ||
| 488 | // socket: Linux answers ECONNREFUSED for a regular file at the | ||
| 489 | // path exactly as it does for a dead socket, so connect alone | ||
| 490 | // cannot tell a dead daemon from `muxd run --sock notes.txt`. | ||
| 491 | // The stat below is what separates them. | ||
| 492 | error.ConnectionRefused => {}, | ||
| 493 | else => |e| return e, | ||
| 494 | } | ||
| 495 | |||
| 496 | const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) { | ||
| 497 | error.FileNotFound => return, // vanished under us; path is free | ||
| 498 | else => |e| return e, | ||
| 499 | }; | ||
| 500 | if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket; | ||
| 501 | |||
| 502 | std.fs.cwd().deleteFile(path) catch |err| switch (err) { | ||
| 503 | // Someone else cleared it first. The path is free either way, | ||
| 504 | // which is the only thing this function was after. | ||
| 505 | error.FileNotFound => {}, | ||
| 506 | else => |e| return e, | ||
| 507 | }; | ||
| 508 | } | ||
| 509 | |||
| 510 | pub fn deinit(self: *Server) void { | 304 | pub fn deinit(self: *Server) void { |
| 511 | for (&self.clients) |*slot| { | 305 | for (&self.clients) |*slot| { |
| 512 | if (slot.*) |*c| { | 306 | if (slot.*) |*c| { |
| @@ -537,10 +331,7 @@ pub const Server = struct { | |||
| 537 | // newer daemon may have replaced the file since we bound it, and | 331 | // newer daemon may have replaced the file since we bound it, and |
| 538 | // deleting that one would hand its clients the same field incident | 332 | // deleting that one would hand its clients the same field incident |
| 539 | // the socket-steal fix exists to prevent. | 333 | // the socket-steal fix exists to prevent. |
| 540 | const ours: bool = ours: { | 334 | const ours: bool = if (self.path_id) |id| id.stillAt(self.sock_path) else false; |
| 541 | const pst = std.posix.fstatat(std.posix.AT.FDCWD, self.sock_path, 0) catch break :ours false; | ||
| 542 | break :ours pst.dev == self.sock_dev and pst.ino == self.sock_ino; | ||
| 543 | }; | ||
| 544 | self.listener.deinit(); | 335 | self.listener.deinit(); |
| 545 | if (ours) std.fs.cwd().deleteFile(self.sock_path) catch {}; | 336 | if (ours) std.fs.cwd().deleteFile(self.sock_path) catch {}; |
| 546 | self.tracker.deinit(self.alloc); | 337 | self.tracker.deinit(self.alloc); |
| @@ -1628,10 +1419,7 @@ pub const Server = struct { | |||
| 1628 | self.resyncSnapshot(); | 1419 | self.resyncSnapshot(); |
| 1629 | return; | 1420 | return; |
| 1630 | } | 1421 | } |
| 1631 | if (have_epoch == self.epoch and have_seq != 0 and | 1422 | if (have_epoch == self.epoch and self.tracker.canServe(have_seq)) { |
| 1632 | have_seq >= self.tracker.reset_seq and have_seq <= self.tracker.seq and | ||
| 1633 | self.tracker.rows != 0) | ||
| 1634 | { | ||
| 1635 | const payload = self.tracker.buildDeltaSince(self.alloc, self.eng, have_seq) catch { | 1423 | const payload = self.tracker.buildDeltaSince(self.alloc, self.eng, have_seq) catch { |
| 1636 | self.snapshotTo(i); | 1424 | self.snapshotTo(i); |
| 1637 | return; | 1425 | return; |
| @@ -3822,62 +3610,6 @@ fn firstStateFrame(alloc: std.mem.Allocator, fd: std.posix.fd_t, timeout_ms: u64 | |||
| 3822 | return null; | 3610 | return null; |
| 3823 | } | 3611 | } |
| 3824 | 3612 | ||
| 3825 | test "DeltaTracker: alt-screen flip is a discontinuity and rows follow the active screen" { | ||
| 3826 | const alloc = std.testing.allocator; | ||
| 3827 | |||
| 3828 | const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3829 | defer eng.deinit(); | ||
| 3830 | |||
| 3831 | var tracker: DeltaTracker = .{}; | ||
| 3832 | defer tracker.deinit(alloc); | ||
| 3833 | try tracker.rebuild(alloc, eng, 24, 80); | ||
| 3834 | |||
| 3835 | eng.feed("primary text"); | ||
| 3836 | switch (try tracker.update(alloc, eng)) { | ||
| 3837 | .advanced => {}, | ||
| 3838 | else => return error.ExpectedAdvance, | ||
| 3839 | } | ||
| 3840 | |||
| 3841 | // Switching screens replaces every row at once: the tracked hashes | ||
| 3842 | // describe the other screen, so a delta would be a lie. | ||
| 3843 | eng.feed("\x1b[?1049h"); | ||
| 3844 | switch (try tracker.update(alloc, eng)) { | ||
| 3845 | .discontinuity => {}, | ||
| 3846 | else => return error.ExpectedDiscontinuity, | ||
| 3847 | } | ||
| 3848 | |||
| 3849 | try tracker.rebuild(alloc, eng, 24, 80); | ||
| 3850 | eng.feed("alt content"); | ||
| 3851 | switch (try tracker.update(alloc, eng)) { | ||
| 3852 | .advanced => {}, | ||
| 3853 | else => return error.ExpectedAdvance, | ||
| 3854 | } | ||
| 3855 | const payload = try tracker.buildDeltaSince(alloc, eng, tracker.seq - 1); | ||
| 3856 | defer alloc.free(payload); | ||
| 3857 | const composed = try proto.composeDelta(alloc, payload); | ||
| 3858 | defer alloc.free(composed.bytes); | ||
| 3859 | try std.testing.expect(std.mem.indexOf(u8, composed.bytes, "alt content") != null); | ||
| 3860 | } | ||
| 3861 | |||
| 3862 | test "DeltaTracker: a resize behind the tracker's back resyncs instead of over-reading" { | ||
| 3863 | const alloc = std.testing.allocator; | ||
| 3864 | |||
| 3865 | const eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3866 | defer eng.deinit(); | ||
| 3867 | |||
| 3868 | var tracker: DeltaTracker = .{}; | ||
| 3869 | defer tracker.deinit(alloc); | ||
| 3870 | try tracker.rebuild(alloc, eng, 24, 80); | ||
| 3871 | |||
| 3872 | // Stands in for a rebuild that failed (OOM) after the engine resized: | ||
| 3873 | // the tracker still describes 24 rows of an 80-column grid. | ||
| 3874 | try eng.resize(80, 10); | ||
| 3875 | switch (try tracker.update(alloc, eng)) { | ||
| 3876 | .discontinuity => {}, | ||
| 3877 | else => return error.ExpectedDiscontinuity, | ||
| 3878 | } | ||
| 3879 | } | ||
| 3880 | |||
| 3881 | // Placed BEFORE the QUIC integration tests below, and that is not | 3613 | // Placed BEFORE the QUIC integration tests below, and that is not |
| 3882 | // cosmetic. Widening deinit's `.owned` arm to free a `.borrowed` listener | 3614 | // cosmetic. Widening deinit's `.owned` arm to free a `.borrowed` listener |
| 3883 | // too makes the daemon free one it does not own; every test down there hands | 3615 | // too makes the daemon free one it does not own; every test down there hands |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,86 @@ | |||
| 1 | //! The socket path's identity and the right to bind it: who owns a path | ||
| 2 | //! before a daemon starts, and whether the file sitting there at teardown | ||
| 3 | //! is still the one that daemon created. | ||
| 4 | //! | ||
| 5 | //! Both halves answer the same field incident from opposite ends — one | ||
| 6 | //! refuses to steal a live daemon's path, the other refuses to delete a | ||
| 7 | //! successor's socket — so they belong together and nowhere near the rest | ||
| 8 | //! of the daemon. Nothing here knows a Server exists; a path is all it | ||
| 9 | //! takes. | ||
| 10 | const std = @import("std"); | ||
| 11 | |||
| 12 | /// A socket file's identity at the moment it was bound, so teardown can | ||
| 13 | /// tell our socket from one that replaced it. | ||
| 14 | /// | ||
| 15 | /// The PATH's dev+ino, deliberately, not the listening descriptor's: a | ||
| 16 | /// bound unix socket's descriptor lives in sockfs (dev 10 here) while | ||
| 17 | /// the path resolves to an ordinary filesystem inode (dev 38), so | ||
| 18 | /// comparing the two could never be equal. The guard read as careful | ||
| 19 | /// and was unconditionally false, which meant the daemon never unlinked | ||
| 20 | /// its socket on a clean exit at all — masked ever since by the stale | ||
| 21 | /// socket recovery in `claim` cleaning up on the next start. | ||
| 22 | pub const PathId = struct { | ||
| 23 | dev: u64, | ||
| 24 | ino: u64, | ||
| 25 | |||
| 26 | /// Stat the path we just created, not the descriptor: this is the | ||
| 27 | /// record teardown compares against, and it has to be taken through | ||
| 28 | /// the same lens it will be re-read through. | ||
| 29 | pub fn of(path: []const u8) !PathId { | ||
| 30 | const st = try std.posix.fstatat(std.posix.AT.FDCWD, path, 0); | ||
| 31 | return .{ .dev = @intCast(st.dev), .ino = @intCast(st.ino) }; | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Does `path` still name this exact file? A path that cannot be | ||
| 35 | /// stat'd is not ours — it is gone, or it is something we may not | ||
| 36 | /// identify — and either way the answer callers want is "leave it". | ||
| 37 | pub fn stillAt(self: PathId, path: []const u8) bool { | ||
| 38 | const pst = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch return false; | ||
| 39 | return pst.dev == self.dev and pst.ino == self.ino; | ||
| 40 | } | ||
| 41 | }; | ||
| 42 | |||
| 43 | /// Make the socket path ours to bind, or refuse it. Field incident this | ||
| 44 | /// exists for: three daemons were started against one path, each | ||
| 45 | /// unlinking it and binding fresh. Every one of them kept running with | ||
| 46 | /// its sessions intact, but only the newest was reachable — the older | ||
| 47 | /// two were stranded, invisible, holding shells nobody could get back | ||
| 48 | /// to, and two terminals "in the same session" were really in two | ||
| 49 | /// different ones. | ||
| 50 | /// | ||
| 51 | /// So: unlink only what answers ECONNREFUSED *and* is a socket. | ||
| 52 | /// - something answers → a live daemon owns this path. Refuse. | ||
| 53 | /// - nothing there → bind, nothing to clean up. | ||
| 54 | /// - a dead socket file → a daemon that died without deinit's | ||
| 55 | /// unlink running. Ours to clear. | ||
| 56 | /// - anything else → propagate. A path we cannot positively | ||
| 57 | /// identify as a dead daemon's leftover is not | ||
| 58 | /// something we may delete. | ||
| 59 | pub fn claim(path: []const u8) !void { | ||
| 60 | if (std.net.connectUnixSocket(path)) |probe| { | ||
| 61 | probe.close(); | ||
| 62 | return error.DaemonAlreadyRunning; | ||
| 63 | } else |err| switch (err) { | ||
| 64 | error.FileNotFound => return, // free path; bind straight away | ||
| 65 | // Nobody is listening — but this is NOT yet proof of a stale | ||
| 66 | // socket: Linux answers ECONNREFUSED for a regular file at the | ||
| 67 | // path exactly as it does for a dead socket, so connect alone | ||
| 68 | // cannot tell a dead daemon from `muxd run --sock notes.txt`. | ||
| 69 | // The stat below is what separates them. | ||
| 70 | error.ConnectionRefused => {}, | ||
| 71 | else => |e| return e, | ||
| 72 | } | ||
| 73 | |||
| 74 | const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) { | ||
| 75 | error.FileNotFound => return, // vanished under us; path is free | ||
| 76 | else => |e| return e, | ||
| 77 | }; | ||
| 78 | if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket; | ||
| 79 | |||
| 80 | std.fs.cwd().deleteFile(path) catch |err| switch (err) { | ||
| 81 | // Someone else cleared it first. The path is free either way, | ||
| 82 | // which is the only thing this function was after. | ||
| 83 | error.FileNotFound => {}, | ||
| 84 | else => |e| return e, | ||
| 85 | }; | ||
| 86 | } | ||