bdc6eeb5
refactor: replica.zig owns replay — extracted from session(), finally unit-tested
a73x 2026-08-13 10:13
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -184,6 +184,18 @@ pub fn build(b: *std.Build) void { | |||
| 184 | delta_mod.addImport("engine", engine_mod); | 184 | delta_mod.addImport("engine", engine_mod); |
| 185 | delta_mod.addImport("protocol", protocol_mod); | 185 | delta_mod.addImport("protocol", protocol_mod); |
| 186 | 186 | ||
| 187 | // The replay core: snapshot/delta application and the resume | ||
| 188 | // coordinates, shared by the CLI client, the wasm core, and the | ||
| 189 | // server's test fixtures. Engine plus protocol and nothing else, and | ||
| 190 | // deliberately platform-free — it must compile for wasm32. | ||
| 191 | const replica_mod = b.createModule(.{ | ||
| 192 | .root_source_file = b.path("src/replica.zig"), | ||
| 193 | .target = target, | ||
| 194 | .optimize = optimize, | ||
| 195 | }); | ||
| 196 | replica_mod.addImport("engine", engine_mod); | ||
| 197 | replica_mod.addImport("protocol", protocol_mod); | ||
| 198 | |||
| 187 | // The socket path's identity and the right to bind it: the stale-socket | 199 | // 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 | 200 | // claim and the dev+ino record teardown compares against. A leaf — it |
| 189 | // takes a path and nothing else, and knows no Server exists. | 201 | // takes a path and nothing else, and knows no Server exists. |
| @@ -204,6 +216,7 @@ pub fn build(b: *std.Build) void { | |||
| 204 | server_mod.addImport("pty", pty_mod); | 216 | server_mod.addImport("pty", pty_mod); |
| 205 | server_mod.addImport("protocol", protocol_mod); | 217 | server_mod.addImport("protocol", protocol_mod); |
| 206 | server_mod.addImport("delta", delta_mod); | 218 | server_mod.addImport("delta", delta_mod); |
| 219 | server_mod.addImport("replica", replica_mod); | ||
| 207 | server_mod.addImport("sockpath", sockpath_mod); | 220 | server_mod.addImport("sockpath", sockpath_mod); |
| 208 | // Both: the listener it owns, and the vocabulary it names directly | 221 | // Both: the listener it owns, and the vocabulary it names directly |
| 209 | // (the key it loads, the idle default it falls back to). | 222 | // (the key it loads, the idle default it falls back to). |
| @@ -258,6 +271,7 @@ pub fn build(b: *std.Build) void { | |||
| 258 | }); | 271 | }); |
| 259 | client_mod.addImport("engine", engine_mod); | 272 | client_mod.addImport("engine", engine_mod); |
| 260 | client_mod.addImport("protocol", protocol_mod); | 273 | client_mod.addImport("protocol", protocol_mod); |
| 274 | client_mod.addImport("replica", replica_mod); | ||
| 261 | client_mod.addImport("testtmp", testtmp_mod); | 275 | client_mod.addImport("testtmp", testtmp_mod); |
| 262 | client_mod.addImport("quic_client", quic_client_mod); | 276 | client_mod.addImport("quic_client", quic_client_mod); |
| 263 | // The client is the only thing that predicts: the overlay is a local | 277 | // The client is the only thing that predicts: the overlay is a local |
| @@ -390,7 +404,7 @@ pub fn build(b: *std.Build) void { | |||
| 390 | // absence here was a live hazard recorded in decisions.md — muxd's | 404 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 391 | // entrypoint could grow tests that silently never ran, exactly as | 405 | // entrypoint could grow tests that silently never ran, exactly as |
| 392 | // mux_main.zig's five did before it was added. | 406 | // mux_main.zig's five did before it was added. |
| 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| { | 407 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_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| { |
| 394 | const t = b.addTest(.{ .root_module = mod }); | 408 | const t = b.addTest(.{ .root_module = mod }); |
| 395 | t.use_llvm = true; | 409 | t.use_llvm = true; |
| 396 | t.use_lld = true; | 410 | t.use_lld = true; |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ | |||
| 7 | //! Detach chord: Ctrl-\ (0x1c). No keybinding layer in this prototype. | 7 | //! Detach chord: Ctrl-\ (0x1c). No keybinding layer in this prototype. |
| 8 | const std = @import("std"); | 8 | const std = @import("std"); |
| 9 | const Engine = @import("engine").Engine; | 9 | const Engine = @import("engine").Engine; |
| 10 | const Replica = @import("replica").Replica; | ||
| 10 | const proto = @import("protocol"); | 11 | const proto = @import("protocol"); |
| 11 | const TmpDir = @import("testtmp").TmpDir; | 12 | const TmpDir = @import("testtmp").TmpDir; |
| 12 | const quic_client = @import("quic_client"); | 13 | const quic_client = @import("quic_client"); |
| @@ -899,8 +900,11 @@ fn session( | |||
| 899 | 900 | ||
| 900 | var size = ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; | 901 | var size = ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; |
| 901 | 902 | ||
| 902 | var replica = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); | 903 | var eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); |
| 903 | defer replica.deinit(); | 904 | defer eng.deinit(); |
| 905 | // The replay core, extracted to replica.zig; the engine stays owned | ||
| 906 | // here (the Replica borrows it), so the deinit above is the one owner. | ||
| 907 | var rep = Replica.init(alloc, eng); | ||
| 904 | 908 | ||
| 905 | // Registered before the terminal-restore defer so it runs after it: | 909 | // Registered before the terminal-restore defer so it runs after it: |
| 906 | // messages land on the normal screen, not the wiped alternate one. | 910 | // messages land on the normal screen, not the wiped alternate one. |
| @@ -973,28 +977,9 @@ fn session( | |||
| 973 | 977 | ||
| 974 | var stdin_open = true; | 978 | var stdin_open = true; |
| 975 | // Scroll mode: 0 = live; N = viewing the page N screenfuls above live. | 979 | // Scroll mode: 0 = live; N = viewing the page N screenfuls above live. |
| 980 | // View state, not replay state, so it stays here rather than in the | ||
| 981 | // Replica (which holds grid/seq/epoch/history — see replica.zig). | ||
| 976 | var scroll_pages: u32 = 0; | 982 | var scroll_pages: u32 = 0; |
| 977 | var history_rows: u32 = 0; | ||
| 978 | // The authoritative grid size, learned from snapshot prefixes. Under | ||
| 979 | // latest-wins another client's attach or resize can make it differ | ||
| 980 | // from our tty (`size`); the replica follows the grid, the paint is | ||
| 981 | // clipped to the tty. | ||
| 982 | var grid = size; | ||
| 983 | // Whether state has arrived *on the current attach*. A refusal (session | ||
| 984 | // full) arrives as exit_status before anything else; without this we | ||
| 985 | // could not tell it apart from the shell exiting 1. Per attach rather | ||
| 986 | // than per session because a reconnect produces the identical shape: | ||
| 987 | // what we still hold from before the disconnect says nothing about | ||
| 988 | // whether this attach was let in. | ||
| 989 | var state_since_attach = false; | ||
| 990 | // The daemon instance we are talking to, learned from its snapshots, | ||
| 991 | // and quoted back on reconnect so the daemon can tell whether the seq | ||
| 992 | // we hold is one of its own (a restarted daemon counts from zero over | ||
| 993 | // different content). | ||
| 994 | var session_epoch: u64 = 0; | ||
| 995 | // The newest seq we hold. Sent with a reconnect attach; the daemon | ||
| 996 | // answers with a delta when it can still reach us from there. | ||
| 997 | var last_seq: u64 = 0; | ||
| 998 | // Set by any site that finds the transport dead; serviced at the top of | 983 | // Set by any site that finds the transport dead; serviced at the top of |
| 999 | // the loop so the bookkeeping around a reconnect lives in one place. | 984 | // the loop so the bookkeeping around a reconnect lives in one place. |
| 1000 | var needs_reconnect = false; | 985 | var needs_reconnect = false; |
| @@ -1020,8 +1005,8 @@ fn session( | |||
| 1020 | // `state_since_attach` cannot serve here: it is cleared on | 1005 | // `state_since_attach` cannot serve here: it is cleared on |
| 1021 | // every re-attach, so mid-session it would send us down this | 1006 | // every re-attach, so mid-session it would send us down this |
| 1022 | // exit path exactly when resuming is what we want. | 1007 | // exit path exactly when resuming is what we want. |
| 1023 | if (session_epoch == 0) { | 1008 | if (rep.session_epoch == 0) { |
| 1024 | exit_msg = lostMsg(target, session_epoch); | 1009 | exit_msg = lostMsg(target, rep.session_epoch); |
| 1025 | return 1; | 1010 | return 1; |
| 1026 | } | 1011 | } |
| 1027 | // A resync repaints live state, so a history page would be | 1012 | // A resync repaints live state, so a history page would be |
| @@ -1046,8 +1031,8 @@ fn session( | |||
| 1046 | transport, | 1031 | transport, |
| 1047 | target, | 1032 | target, |
| 1048 | size, | 1033 | size, |
| 1049 | last_seq, | 1034 | rep.last_seq, |
| 1050 | session_epoch, | 1035 | rep.session_epoch, |
| 1051 | stdin_fd, | 1036 | stdin_fd, |
| 1052 | stdout_fd, | 1037 | stdout_fd, |
| 1053 | is_tty, | 1038 | is_tty, |
| @@ -1057,7 +1042,9 @@ fn session( | |||
| 1057 | exit_msg = "mux: detached while reconnecting (session still running; run mux to reattach)"; | 1042 | exit_msg = "mux: detached while reconnecting (session still running; run mux to reattach)"; |
| 1058 | return 0; | 1043 | return 0; |
| 1059 | } | 1044 | } |
| 1060 | state_since_attach = false; | 1045 | // Only the caller knows a re-attach happened; the Replica's |
| 1046 | // contract says this clear is ours to do. | ||
| 1047 | rep.state_since_attach = false; | ||
| 1061 | if (reconnect_grace_until == null) { | 1048 | if (reconnect_grace_until == null) { |
| 1062 | reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; | 1049 | reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; |
| 1063 | } | 1050 | } |
| @@ -1103,7 +1090,7 @@ fn session( | |||
| 1103 | // reconcile will never run again and the glyph would otherwise stay | 1090 | // reconcile will never run again and the glyph would otherwise stay |
| 1104 | // on screen for the rest of the session. | 1091 | // on screen for the rest of the session. |
| 1105 | if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_pages == 0) { | 1092 | if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_pages == 0) { |
| 1106 | try paint_mod.renderClipped(alloc, replica, size, stdout_fd); | 1093 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); |
| 1107 | } | 1094 | } |
| 1108 | 1095 | ||
| 1109 | // Labelled, because "no whole frame yet" must leave the REST of this | 1096 | // Labelled, because "no whole frame yet" must leave the REST of this |
| @@ -1151,41 +1138,34 @@ fn session( | |||
| 1151 | } | 1138 | } |
| 1152 | switch (frame.type) { | 1139 | switch (frame.type) { |
| 1153 | .snapshot => { | 1140 | .snapshot => { |
| 1154 | const prefix = proto.readSnapshotPrefix(frame.payload) catch continue; | 1141 | // The Replica adopts seq/epoch/history, resizes to the |
| 1155 | // Set after the prefix parse here but before the compose | 1142 | // prefix's grid, and rebuilds — even while scrolled, so |
| 1156 | // catch in the delta arm: a short snapshot proves nothing, | 1143 | // leaving scroll mode paints current state. A short |
| 1157 | // while a delta's arrival alone proves we were admitted. | 1144 | // snapshot proves nothing (BadPayload leaves everything |
| 1158 | state_since_attach = true; | 1145 | // untouched, state_since_attach included); anything |
| 1146 | // else out of the resize stays loud. | ||
| 1147 | _ = rep.apply(.snapshot, frame.payload) catch |err| switch (err) { | ||
| 1148 | error.BadPayload => continue, | ||
| 1149 | else => |e| return e, | ||
| 1150 | }; | ||
| 1159 | reconnect_grace_until = null; | 1151 | reconnect_grace_until = null; |
| 1160 | session_epoch = prefix.epoch; | ||
| 1161 | last_seq = prefix.seq; | ||
| 1162 | history_rows = prefix.history_rows; | ||
| 1163 | if (prefix.cols != grid.cols or prefix.rows != grid.rows) { | ||
| 1164 | try replica.resize(prefix.cols, prefix.rows); | ||
| 1165 | grid = .{ .cols = prefix.cols, .rows = prefix.rows }; | ||
| 1166 | } | ||
| 1167 | // Authoritative resync: rebuild the replica even while | ||
| 1168 | // scrolled, so leaving scroll mode paints current state. | ||
| 1169 | replica.reset(); | ||
| 1170 | replica.feed(frame.payload[proto.snapshot_prefix_len..]); | ||
| 1171 | // A snapshot answers a resize, ends a reconnect, and | 1152 | // A snapshot answers a resize, ends a reconnect, and |
| 1172 | // rebuilds the screen under anything outstanding. None | 1153 | // rebuilds the screen under anything outstanding. None |
| 1173 | // of that says a prediction was wrong — it says we can | 1154 | // of that says a prediction was wrong — it says we can |
| 1174 | // no longer find out, so the queue goes and the counters | 1155 | // no longer find out, so the queue goes and the counters |
| 1175 | // do not move. | 1156 | // do not move. |
| 1176 | overlay.setGrid(grid.cols, grid.rows); | 1157 | overlay.setGrid(rep.grid.cols, rep.grid.rows); |
| 1177 | overlay.setResizePending(false); | 1158 | overlay.setResizePending(false); |
| 1178 | overlay.flush(); | 1159 | overlay.flush(); |
| 1179 | overlay.noteSeq(prefix.seq); | 1160 | overlay.noteSeq(rep.last_seq); |
| 1180 | if (scroll_pages == 0) { | 1161 | if (scroll_pages == 0) { |
| 1181 | try paint_mod.renderClipped(alloc, replica, size, stdout_fd); | 1162 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); |
| 1182 | repaint_after_resync = false; // banner painted over | 1163 | repaint_after_resync = false; // banner painted over |
| 1183 | } | 1164 | } |
| 1184 | }, | 1165 | }, |
| 1185 | .delta => { | 1166 | .delta => { |
| 1186 | state_since_attach = true; | ||
| 1187 | reconnect_grace_until = null; | 1167 | reconnect_grace_until = null; |
| 1188 | const composed = proto.composeDelta(alloc, frame.payload) catch { | 1168 | if (try rep.apply(.delta, frame.payload) == .resync) { |
| 1189 | // A rejected delta means the replica can no longer be | 1169 | // A rejected delta means the replica can no longer be |
| 1190 | // trusted; ask for a fresh snapshot rather than | 1170 | // trusted; ask for a fresh snapshot rather than |
| 1191 | // silently skipping it and desyncing for good. By | 1171 | // silently skipping it and desyncing for good. By |
| @@ -1203,18 +1183,14 @@ fn session( | |||
| 1203 | &proto.encodeAttach(size.cols, size.rows, 0, 0), | 1183 | &proto.encodeAttach(size.cols, size.rows, 0, 0), |
| 1204 | ) catch {}; | 1184 | ) catch {}; |
| 1205 | continue; | 1185 | continue; |
| 1206 | }; | 1186 | } |
| 1207 | defer alloc.free(composed.bytes); | ||
| 1208 | history_rows = composed.header.history_rows; | ||
| 1209 | last_seq = composed.header.seq; | ||
| 1210 | replica.feed(composed.bytes); | ||
| 1211 | // Judged against the replica the frame has just been fed | 1187 | // Judged against the replica the frame has just been fed |
| 1212 | // into, which is the only authority there is. | 1188 | // into, which is the only authority there is. |
| 1213 | const verdict = reconcileOverlay( | 1189 | const verdict = reconcileOverlay( |
| 1214 | alloc, | 1190 | alloc, |
| 1215 | &overlay, | 1191 | &overlay, |
| 1216 | replica, | 1192 | rep.eng, |
| 1217 | last_seq, | 1193 | rep.last_seq, |
| 1218 | std.time.milliTimestamp(), | 1194 | std.time.milliTimestamp(), |
| 1219 | ); | 1195 | ); |
| 1220 | // While scrolled the replica still tracks live output; the | 1196 | // While scrolled the replica still tracks live output; the |
| @@ -1233,7 +1209,7 @@ fn session( | |||
| 1233 | // It is affordable precisely because reconcile v2 | 1209 | // It is affordable precisely because reconcile v2 |
| 1234 | // made contradictions rare — a burst outrunning | 1210 | // made contradictions rare — a burst outrunning |
| 1235 | // the round trip is no longer one. | 1211 | // the round trip is no longer one. |
| 1236 | try paint_mod.renderClipped(alloc, replica, size, stdout_fd); | 1212 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); |
| 1237 | repaint_after_resync = false; | 1213 | repaint_after_resync = false; |
| 1238 | } else { | 1214 | } else { |
| 1239 | try paint_mod.paintDeltaClipped(alloc, frame.payload, size, stdout_fd); | 1215 | try paint_mod.paintDeltaClipped(alloc, frame.payload, size, stdout_fd); |
| @@ -1241,7 +1217,7 @@ fn session( | |||
| 1241 | // Last, and after either paint: the rows the daemon | 1217 | // Last, and after either paint: the rows the daemon |
| 1242 | // just sent have overwritten anything drawn on them, | 1218 | // just sent have overwritten anything drawn on them, |
| 1243 | // including predictions that are still outstanding. | 1219 | // including predictions that are still outstanding. |
| 1244 | paintOverlay(alloc, &overlay, replica.cursorPos(), size, stdout_fd); | 1220 | paintOverlay(alloc, &overlay, rep.eng.cursorPos(), size, stdout_fd); |
| 1245 | } | 1221 | } |
| 1246 | }, | 1222 | }, |
| 1247 | .pty_mode => { | 1223 | .pty_mode => { |
| @@ -1253,7 +1229,7 @@ fn session( | |||
| 1253 | const had_pending = overlay.pendingCount() > 0; | 1229 | const had_pending = overlay.pendingCount() > 0; |
| 1254 | overlay.setMode(flags); | 1230 | overlay.setMode(flags); |
| 1255 | if (had_pending and overlay.pendingCount() == 0 and scroll_pages == 0) { | 1231 | if (had_pending and overlay.pendingCount() == 0 and scroll_pages == 0) { |
| 1256 | try paint_mod.renderClipped(alloc, replica, size, stdout_fd); | 1232 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); |
| 1257 | } | 1233 | } |
| 1258 | }, | 1234 | }, |
| 1259 | .scrollback_chunk => { | 1235 | .scrollback_chunk => { |
| @@ -1264,7 +1240,7 @@ fn session( | |||
| 1264 | // Before any session state, exit_status is almost always | 1240 | // Before any session state, exit_status is almost always |
| 1265 | // the daemon refusing the attach — say so, or it looks | 1241 | // the daemon refusing the attach — say so, or it looks |
| 1266 | // exactly like the shell itself exiting non-zero. | 1242 | // exactly like the shell itself exiting non-zero. |
| 1267 | if (!state_since_attach) { | 1243 | if (!rep.state_since_attach) { |
| 1268 | // Right after a reconnect the usual cause is the | 1244 | // Right after a reconnect the usual cause is the |
| 1269 | // daemon not having reaped our dead predecessor's | 1245 | // daemon not having reaped our dead predecessor's |
| 1270 | // slot yet, so the session is full of *us*. Worth a | 1246 | // slot yet, so the session is full of *us*. Worth a |
| @@ -1305,14 +1281,14 @@ fn session( | |||
| 1305 | const scroll_up = "\x1b[5;2~"; // Shift+PageUp | 1281 | const scroll_up = "\x1b[5;2~"; // Shift+PageUp |
| 1306 | const scroll_dn = "\x1b[6;2~"; // Shift+PageDown | 1282 | const scroll_dn = "\x1b[6;2~"; // Shift+PageDown |
| 1307 | if (std.mem.eql(u8, buf[0..n], scroll_up)) { | 1283 | if (std.mem.eql(u8, buf[0..n], scroll_up)) { |
| 1308 | if (history_rows > 0) { | 1284 | if (rep.history_rows > 0) { |
| 1309 | const max_pages: u32 = (history_rows + size.rows - 1) / size.rows; | 1285 | const max_pages: u32 = (rep.history_rows + size.rows - 1) / size.rows; |
| 1310 | if (scroll_pages < max_pages) scroll_pages += 1; | 1286 | if (scroll_pages < max_pages) scroll_pages += 1; |
| 1311 | // The cursor is no longer where the user is looking, | 1287 | // The cursor is no longer where the user is looking, |
| 1312 | // so a prediction painted at it would land in the | 1288 | // so a prediction painted at it would land in the |
| 1313 | // middle of history. | 1289 | // middle of history. |
| 1314 | overlay.setScrollMode(true); | 1290 | overlay.setScrollMode(true); |
| 1315 | requestScrollPage(transport, scroll_pages, history_rows, size) catch { | 1291 | requestScrollPage(transport, &rep, scroll_pages, size) catch { |
| 1316 | needs_reconnect = true; | 1292 | needs_reconnect = true; |
| 1317 | continue; | 1293 | continue; |
| 1318 | }; | 1294 | }; |
| @@ -1321,9 +1297,9 @@ fn session( | |||
| 1321 | if (scroll_pages > 0) scroll_pages -= 1; | 1297 | if (scroll_pages > 0) scroll_pages -= 1; |
| 1322 | if (scroll_pages == 0) { | 1298 | if (scroll_pages == 0) { |
| 1323 | overlay.setScrollMode(false); | 1299 | overlay.setScrollMode(false); |
| 1324 | try paint_mod.renderClipped(alloc, replica, size, stdout_fd); | 1300 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); |
| 1325 | } else { | 1301 | } else { |
| 1326 | requestScrollPage(transport, scroll_pages, history_rows, size) catch { | 1302 | requestScrollPage(transport, &rep, scroll_pages, size) catch { |
| 1327 | needs_reconnect = true; | 1303 | needs_reconnect = true; |
| 1328 | continue; | 1304 | continue; |
| 1329 | }; | 1305 | }; |
| @@ -1332,12 +1308,12 @@ fn session( | |||
| 1332 | // Any other key exits scroll mode (swallowed, not forwarded). | 1308 | // Any other key exits scroll mode (swallowed, not forwarded). |
| 1333 | scroll_pages = 0; | 1309 | scroll_pages = 0; |
| 1334 | overlay.setScrollMode(false); | 1310 | overlay.setScrollMode(false); |
| 1335 | try paint_mod.renderClipped(alloc, replica, size, stdout_fd); | 1311 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); |
| 1336 | } else { | 1312 | } else { |
| 1337 | // Speculate before sending, so the glyph is on screen | 1313 | // Speculate before sending, so the glyph is on screen |
| 1338 | // while the keystroke is still in flight. The bytes that | 1314 | // while the keystroke is still in flight. The bytes that |
| 1339 | // go out are unchanged either way. | 1315 | // go out are unchanged either way. |
| 1340 | offerKeystroke(alloc, &overlay, replica, buf[0..n], size, stdout_fd); | 1316 | offerKeystroke(alloc, &overlay, rep.eng, buf[0..n], size, stdout_fd); |
| 1341 | transport.writeFrame(.input, buf[0..n]) catch { | 1317 | transport.writeFrame(.input, buf[0..n]) catch { |
| 1342 | // These keystrokes are lost with the transport, by | 1318 | // These keystrokes are lost with the transport, by |
| 1343 | // the same policy that drops what is typed while | 1319 | // the same policy that drops what is typed while |
| @@ -1517,14 +1493,13 @@ fn dumpPredictStats(c: predict.Counters) void { | |||
| 1517 | 1493 | ||
| 1518 | fn requestScrollPage( | 1494 | fn requestScrollPage( |
| 1519 | transport: *Transport, | 1495 | transport: *Transport, |
| 1496 | rep: *const Replica, | ||
| 1520 | pages_up: u32, | 1497 | pages_up: u32, |
| 1521 | history_rows: u32, | ||
| 1522 | size: proto.Size, | 1498 | size: proto.Size, |
| 1523 | ) !void { | 1499 | ) !void { |
| 1524 | // Page N shows `size.rows` rows ending N*rows above the live viewport | 1500 | // Page N shows `size.rows` rows ending N*rows above the live viewport |
| 1525 | // top (screen-space row index history_rows). | 1501 | // top; the row math lives with the replica's history_rows (replica.zig). |
| 1526 | const rows: u32 = size.rows; | 1502 | const start = rep.scrollStart(pages_up, size.rows); |
| 1527 | const start = history_rows -| (pages_up * rows); | ||
| 1528 | try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); | 1503 | try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); |
| 1529 | } | 1504 | } |
| 1530 | 1505 | ||
src/replica.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,299 @@ | |||
| 1 | //! The replay core: what a mux client does to mirror a daemon's session. | ||
| 2 | //! Applies snapshot and delta frames to a local engine, tracks the resume | ||
| 3 | //! coordinates (`last_seq`/`session_epoch`), and follows the authoritative | ||
| 4 | //! grid. Extracted from client.zig's session() (M-web Task 1) so the CLI | ||
| 5 | //! client, the wasm core, and the server's test fixtures replay through one | ||
| 6 | //! implementation instead of three. | ||
| 7 | //! | ||
| 8 | //! Deliberately platform-free: no posix, no fds, no clocks — this module | ||
| 9 | //! must compile for wasm32-freestanding. Anything that needs a terminal, | ||
| 10 | //! a timestamp, or a transport belongs to the caller. | ||
| 11 | //! | ||
| 12 | //! The Replica BORROWS its engine. The caller owns the Engine's lifetime | ||
| 13 | //! (the CLI's session() and the server's tests already do); a Replica is | ||
| 14 | //! bookkeeping around it, not a second owner. | ||
| 15 | |||
| 16 | const std = @import("std"); | ||
| 17 | const Engine = @import("engine").Engine; | ||
| 18 | const proto = @import("protocol"); | ||
| 19 | |||
| 20 | pub const Replica = struct { | ||
| 21 | alloc: std.mem.Allocator, | ||
| 22 | /// Borrowed. The engine the frames are replayed into. | ||
| 23 | eng: *Engine, | ||
| 24 | /// The authoritative grid size, learned from snapshot prefixes. Under | ||
| 25 | /// latest-wins another client's attach or resize can make it differ | ||
| 26 | /// from any local tty; the replica follows the grid, not the tty. | ||
| 27 | grid: proto.Size, | ||
| 28 | /// The daemon instance we are talking to, learned from its snapshots, | ||
| 29 | /// and quoted back on reconnect so the daemon can tell whether the seq | ||
| 30 | /// we hold is one of its own (a restarted daemon counts from zero over | ||
| 31 | /// different content). 0 until the first snapshot: "I hold nothing". | ||
| 32 | session_epoch: u64 = 0, | ||
| 33 | /// The newest seq we hold. Quoted on reconnect; the daemon answers | ||
| 34 | /// with a delta when it can still reach us from there. | ||
| 35 | last_seq: u64 = 0, | ||
| 36 | history_rows: u32 = 0, | ||
| 37 | /// Whether state has arrived *on the current attach*. A refusal | ||
| 38 | /// (session full) arrives as exit_status before anything else; without | ||
| 39 | /// this the caller could not tell it apart from the shell exiting 1. | ||
| 40 | /// Set here by any replay frame's arrival — a delta's arrival alone | ||
| 41 | /// proves we were admitted, even one that fails to decode — and | ||
| 42 | /// cleared by the CALLER on every re-attach, because only the caller | ||
| 43 | /// knows when one happened. | ||
| 44 | state_since_attach: bool = false, | ||
| 45 | |||
| 46 | pub const Applied = enum { | ||
| 47 | /// The frame landed; the engine reflects it. | ||
| 48 | painted, | ||
| 49 | /// A delta that could not be trusted (rejected by composeDelta). | ||
| 50 | /// The engine was not touched. The caller must re-attach with | ||
| 51 | /// have_seq=0 — quoting a seq would invite the delta that cannot | ||
| 52 | /// fix us — and this is deliberately NOT the reconnect path: the | ||
| 53 | /// transport is alive, it is the replica that is suspect. | ||
| 54 | resync, | ||
| 55 | }; | ||
| 56 | |||
| 57 | pub fn init(alloc: std.mem.Allocator, eng: *Engine) Replica { | ||
| 58 | return .{ | ||
| 59 | .alloc = alloc, | ||
| 60 | .eng = eng, | ||
| 61 | .grid = .{ .cols = eng.term.cols, .rows = eng.term.rows }, | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Consume one replay frame. Only .snapshot and .delta are replay | ||
| 66 | /// frames; callers dispatch on type before calling. | ||
| 67 | /// | ||
| 68 | /// .snapshot: a short/garbled prefix is error.BadPayload with nothing | ||
| 69 | /// consumed and `state_since_attach` untouched — a short snapshot | ||
| 70 | /// proves nothing. A good prefix adopts seq/epoch/history, resizes the | ||
| 71 | /// engine when the grid moved, then reset+feed: an authoritative | ||
| 72 | /// rebuild. Resize failure (OOM) propagates. | ||
| 73 | /// | ||
| 74 | /// .delta: arrival alone sets `state_since_attach`. A payload | ||
| 75 | /// composeDelta rejects returns .resync (see Applied); a good one | ||
| 76 | /// advances history_rows/last_seq and feeds the composed paint. | ||
| 77 | pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied { | ||
| 78 | switch (t) { | ||
| 79 | .snapshot => { | ||
| 80 | const prefix = try proto.readSnapshotPrefix(payload); | ||
| 81 | self.state_since_attach = true; | ||
| 82 | self.session_epoch = prefix.epoch; | ||
| 83 | self.last_seq = prefix.seq; | ||
| 84 | self.history_rows = prefix.history_rows; | ||
| 85 | if (prefix.cols != self.grid.cols or prefix.rows != self.grid.rows) { | ||
| 86 | try self.eng.resize(prefix.cols, prefix.rows); | ||
| 87 | self.grid = .{ .cols = prefix.cols, .rows = prefix.rows }; | ||
| 88 | } | ||
| 89 | self.eng.reset(); | ||
| 90 | self.eng.feed(payload[proto.snapshot_prefix_len..]); | ||
| 91 | return .painted; | ||
| 92 | }, | ||
| 93 | .delta => { | ||
| 94 | self.state_since_attach = true; | ||
| 95 | const composed = proto.composeDelta(self.alloc, payload) catch | ||
| 96 | return .resync; | ||
| 97 | defer self.alloc.free(composed.bytes); | ||
| 98 | self.history_rows = composed.header.history_rows; | ||
| 99 | self.last_seq = composed.header.seq; | ||
| 100 | self.eng.feed(composed.bytes); | ||
| 101 | return .painted; | ||
| 102 | }, | ||
| 103 | else => unreachable, // not a replay frame; callers dispatch | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | pub const AttachArgs = struct { have_seq: u64, have_epoch: u64 }; | ||
| 108 | |||
| 109 | /// What an attach frame quotes: (0,0) until the first snapshot — a | ||
| 110 | /// fresh replica holds nothing — and (last_seq, session_epoch) after. | ||
| 111 | /// The delta-resync re-attach quotes (0,0) EXPLICITLY at its call site | ||
| 112 | /// instead of using this: there the point is that what we hold is | ||
| 113 | /// untrusted. | ||
| 114 | pub fn attachArgs(self: *const Replica) AttachArgs { | ||
| 115 | return .{ .have_seq = self.last_seq, .have_epoch = self.session_epoch }; | ||
| 116 | } | ||
| 117 | |||
| 118 | /// Screen-space start row for scroll page N: page N shows `view_rows` | ||
| 119 | /// rows ending N*view_rows above the live viewport top (row index | ||
| 120 | /// history_rows). Saturates at the oldest retained row. | ||
| 121 | pub fn scrollStart(self: *const Replica, pages_up: u32, view_rows: u16) u32 { | ||
| 122 | return self.history_rows -| (pages_up * @as(u32, view_rows)); | ||
| 123 | } | ||
| 124 | }; | ||
| 125 | |||
| 126 | // --------------------------------------------------------------------------- | ||
| 127 | // Tests. The wire layouts these build are golden-pinned in protocol.zig, so | ||
| 128 | // constructing real payloads here is mechanical, not speculative. | ||
| 129 | |||
| 130 | fn testSnapshot( | ||
| 131 | alloc: std.mem.Allocator, | ||
| 132 | p: proto.SnapshotPrefix, | ||
| 133 | state: []const u8, | ||
| 134 | ) ![]u8 { | ||
| 135 | var payload = try alloc.alloc(u8, proto.snapshot_prefix_len + state.len); | ||
| 136 | proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], p); | ||
| 137 | @memcpy(payload[proto.snapshot_prefix_len..], state); | ||
| 138 | return payload; | ||
| 139 | } | ||
| 140 | |||
| 141 | test "snapshot replay: prefix consumed, state fed, epoch and seq adopted" { | ||
| 142 | const alloc = std.testing.allocator; | ||
| 143 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 144 | defer eng.deinit(); | ||
| 145 | var r = Replica.init(alloc, eng); | ||
| 146 | |||
| 147 | const payload = try testSnapshot(alloc, .{ | ||
| 148 | .seq = 7, | ||
| 149 | .history_rows = 3, | ||
| 150 | .cols = 80, | ||
| 151 | .rows = 24, | ||
| 152 | .epoch = 0xABCD, | ||
| 153 | }, "hi"); | ||
| 154 | defer alloc.free(payload); | ||
| 155 | |||
| 156 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload)); | ||
| 157 | try std.testing.expectEqual(@as(u64, 7), r.last_seq); | ||
| 158 | try std.testing.expectEqual(@as(u64, 0xABCD), r.session_epoch); | ||
| 159 | try std.testing.expectEqual(@as(u32, 3), r.history_rows); | ||
| 160 | try std.testing.expect(r.state_since_attach); | ||
| 161 | |||
| 162 | const dump = try eng.dumpPlain(alloc); | ||
| 163 | defer alloc.free(dump); | ||
| 164 | try std.testing.expect(std.mem.startsWith(u8, dump, "hi")); | ||
| 165 | } | ||
| 166 | |||
| 167 | test "snapshot at a new grid size resizes the replica engine first" { | ||
| 168 | const alloc = std.testing.allocator; | ||
| 169 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 170 | defer eng.deinit(); | ||
| 171 | var r = Replica.init(alloc, eng); | ||
| 172 | |||
| 173 | const payload = try testSnapshot(alloc, .{ | ||
| 174 | .seq = 1, | ||
| 175 | .history_rows = 0, | ||
| 176 | .cols = 100, | ||
| 177 | .rows = 30, | ||
| 178 | .epoch = 1, | ||
| 179 | }, "wide"); | ||
| 180 | defer alloc.free(payload); | ||
| 181 | |||
| 182 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload)); | ||
| 183 | try std.testing.expectEqual(@as(u16, 100), r.grid.cols); | ||
| 184 | try std.testing.expectEqual(@as(u16, 30), r.grid.rows); | ||
| 185 | try std.testing.expectEqual(@as(u16, 100), eng.term.cols); | ||
| 186 | try std.testing.expectEqual(@as(u16, 30), eng.term.rows); | ||
| 187 | } | ||
| 188 | |||
| 189 | test "short snapshot proves nothing: BadPayload, state_since_attach untouched" { | ||
| 190 | const alloc = std.testing.allocator; | ||
| 191 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 192 | defer eng.deinit(); | ||
| 193 | var r = Replica.init(alloc, eng); | ||
| 194 | |||
| 195 | const short = [_]u8{0} ** (proto.snapshot_prefix_len - 1); | ||
| 196 | try std.testing.expectError(error.BadPayload, r.apply(.snapshot, &short)); | ||
| 197 | try std.testing.expect(!r.state_since_attach); | ||
| 198 | try std.testing.expectEqual(@as(u64, 0), r.last_seq); | ||
| 199 | } | ||
| 200 | |||
| 201 | test "delta replay: composed rows land, last_seq advances, history follows" { | ||
| 202 | const alloc = std.testing.allocator; | ||
| 203 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 204 | defer eng.deinit(); | ||
| 205 | var r = Replica.init(alloc, eng); | ||
| 206 | |||
| 207 | const snap = try testSnapshot(alloc, .{ | ||
| 208 | .seq = 7, | ||
| 209 | .history_rows = 0, | ||
| 210 | .cols = 80, | ||
| 211 | .rows = 24, | ||
| 212 | .epoch = 0xABCD, | ||
| 213 | }, "hi"); | ||
| 214 | defer alloc.free(snap); | ||
| 215 | _ = try r.apply(.snapshot, snap); | ||
| 216 | |||
| 217 | var delta: std.ArrayList(u8) = .empty; | ||
| 218 | defer delta.deinit(alloc); | ||
| 219 | try proto.appendDeltaHeader(&delta, alloc, .{ | ||
| 220 | .seq = 8, | ||
| 221 | .history_rows = 2, | ||
| 222 | .cursor_x = 2, | ||
| 223 | .cursor_y = 0, | ||
| 224 | .row_count = 1, | ||
| 225 | }); | ||
| 226 | try proto.appendDeltaRow(&delta, alloc, 0, "yo"); | ||
| 227 | |||
| 228 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.delta, delta.items)); | ||
| 229 | try std.testing.expectEqual(@as(u64, 8), r.last_seq); | ||
| 230 | try std.testing.expectEqual(@as(u32, 2), r.history_rows); | ||
| 231 | |||
| 232 | const dump = try eng.dumpPlain(alloc); | ||
| 233 | defer alloc.free(dump); | ||
| 234 | try std.testing.expect(std.mem.startsWith(u8, dump, "yo")); | ||
| 235 | } | ||
| 236 | |||
| 237 | test "delta decode failure reports .resync — and its arrival still proves admission" { | ||
| 238 | const alloc = std.testing.allocator; | ||
| 239 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 240 | defer eng.deinit(); | ||
| 241 | var r = Replica.init(alloc, eng); | ||
| 242 | |||
| 243 | // Header claims two rows, payload carries one: composeDelta rejects it | ||
| 244 | // (the row_count check protocol.zig pins). | ||
| 245 | var delta: std.ArrayList(u8) = .empty; | ||
| 246 | defer delta.deinit(alloc); | ||
| 247 | try proto.appendDeltaHeader(&delta, alloc, .{ | ||
| 248 | .seq = 9, | ||
| 249 | .history_rows = 0, | ||
| 250 | .cursor_x = 0, | ||
| 251 | .cursor_y = 0, | ||
| 252 | .row_count = 2, | ||
| 253 | }); | ||
| 254 | try proto.appendDeltaRow(&delta, alloc, 0, "x"); | ||
| 255 | |||
| 256 | try std.testing.expectEqual(Replica.Applied.resync, try r.apply(.delta, delta.items)); | ||
| 257 | // The subtlety the CLI relies on: a delta's ARRIVAL alone proves the | ||
| 258 | // attach was admitted, decodable or not... | ||
| 259 | try std.testing.expect(r.state_since_attach); | ||
| 260 | // ...while the resume coordinates stay exactly where they were. | ||
| 261 | try std.testing.expectEqual(@as(u64, 0), r.last_seq); | ||
| 262 | } | ||
| 263 | |||
| 264 | test "attach args: first attach quotes (0,0); after a snapshot, (last_seq, epoch)" { | ||
| 265 | const alloc = std.testing.allocator; | ||
| 266 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 267 | defer eng.deinit(); | ||
| 268 | var r = Replica.init(alloc, eng); | ||
| 269 | |||
| 270 | const fresh = r.attachArgs(); | ||
| 271 | try std.testing.expectEqual(@as(u64, 0), fresh.have_seq); | ||
| 272 | try std.testing.expectEqual(@as(u64, 0), fresh.have_epoch); | ||
| 273 | |||
| 274 | const snap = try testSnapshot(alloc, .{ | ||
| 275 | .seq = 42, | ||
| 276 | .history_rows = 0, | ||
| 277 | .cols = 80, | ||
| 278 | .rows = 24, | ||
| 279 | .epoch = 0xFEED, | ||
| 280 | }, ""); | ||
| 281 | defer alloc.free(snap); | ||
| 282 | _ = try r.apply(.snapshot, snap); | ||
| 283 | |||
| 284 | const held = r.attachArgs(); | ||
| 285 | try std.testing.expectEqual(@as(u64, 42), held.have_seq); | ||
| 286 | try std.testing.expectEqual(@as(u64, 0xFEED), held.have_epoch); | ||
| 287 | } | ||
| 288 | |||
| 289 | test "scrollStart: pages count up from the live viewport top, saturating at row 0" { | ||
| 290 | const alloc = std.testing.allocator; | ||
| 291 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 292 | defer eng.deinit(); | ||
| 293 | var r = Replica.init(alloc, eng); | ||
| 294 | r.history_rows = 100; | ||
| 295 | |||
| 296 | try std.testing.expectEqual(@as(u32, 76), r.scrollStart(1, 24)); | ||
| 297 | try std.testing.expectEqual(@as(u32, 52), r.scrollStart(2, 24)); | ||
| 298 | try std.testing.expectEqual(@as(u32, 0), r.scrollStart(5, 24)); // 100 -| 120 | ||
| 299 | } | ||
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,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; | 12 | const DeltaTracker = @import("delta").DeltaTracker; |
| 13 | // Test-only consumer (applyFrame): the tests replay daemon frames through | ||
| 14 | // the production client's replay core rather than a hand-rolled twin. | ||
| 15 | const replica_mod = @import("replica"); | ||
| 13 | const sockpath = @import("sockpath"); | 16 | const sockpath = @import("sockpath"); |
| 14 | const quic = @import("quic"); | 17 | const quic = @import("quic"); |
| 15 | const quic_server = @import("quic_server"); | 18 | const quic_server = @import("quic_server"); |
| @@ -1495,24 +1498,16 @@ fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void { | |||
| 1495 | } | 1498 | } |
| 1496 | 1499 | ||
| 1497 | /// Test helper: bring a replica engine up to date with one daemon frame. | 1500 | /// Test helper: bring a replica engine up to date with one daemon frame. |
| 1498 | /// Snapshots are a full reset + state feed (skipping the fixed prefix); | 1501 | /// The replay itself lives in replica.zig — the production client's — so |
| 1499 | /// deltas are composed into row paints. | 1502 | /// these tests replay through the same code the client ships, not a |
| 1503 | /// hand-rolled twin. The Replica here is per-call scaffolding around the | ||
| 1504 | /// borrowed engine; resume state is not what these tests assert. | ||
| 1500 | fn applyFrame(alloc: std.mem.Allocator, replica: *Engine, frame: proto.Frame) !void { | 1505 | fn applyFrame(alloc: std.mem.Allocator, replica: *Engine, frame: proto.Frame) !void { |
| 1501 | switch (frame.type) { | 1506 | if (frame.type != .snapshot and frame.type != .delta) return; |
| 1502 | .snapshot => { | 1507 | var r = replica_mod.Replica.init(alloc, replica); |
| 1503 | // Not a discarded read: this is the bounds check that makes the | 1508 | // The old helper propagated a garbled delta as BadPayload; .resync is |
| 1504 | // fixed-width slice below safe on a short/garbled payload. | 1509 | // that same event with the production name on it. |
| 1505 | _ = try proto.readSnapshotPrefix(frame.payload); | 1510 | if (try r.apply(frame.type, frame.payload) == .resync) return error.BadPayload; |
| 1506 | replica.reset(); | ||
| 1507 | replica.feed(frame.payload[proto.snapshot_prefix_len..]); | ||
| 1508 | }, | ||
| 1509 | .delta => { | ||
| 1510 | const composed = try proto.composeDelta(alloc, frame.payload); | ||
| 1511 | defer alloc.free(composed.bytes); | ||
| 1512 | replica.feed(composed.bytes); | ||
| 1513 | }, | ||
| 1514 | else => {}, | ||
| 1515 | } | ||
| 1516 | } | 1511 | } |
| 1517 | 1512 | ||
| 1518 | test "Server: survives a client that dies without detaching; next attach works" { | 1513 | test "Server: survives a client that dies without detaching; next attach works" { |