4c363aa6
feat(cmd): pure command state machine over OSC 133 mark events
a73x 2026-08-13 15:54
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -184,6 +184,17 @@ 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 session's command state machine: MarkEvents in, transitions out. | ||
| 188 | // Engine plus protocol and nothing else, same shape as delta_mod — pure, | ||
| 189 | // socket-free, and its own tests drive it with no daemon in sight. | ||
| 190 | const cmd_mod = b.createModule(.{ | ||
| 191 | .root_source_file = b.path("src/cmd.zig"), | ||
| 192 | .target = target, | ||
| 193 | .optimize = optimize, | ||
| 194 | }); | ||
| 195 | cmd_mod.addImport("engine", engine_mod); | ||
| 196 | cmd_mod.addImport("protocol", protocol_mod); | ||
| 197 | |||
| 187 | // The replay core: snapshot/delta application and the resume | 198 | // The replay core: snapshot/delta application and the resume |
| 188 | // coordinates, shared by the CLI client, the wasm core, and the | 199 | // coordinates, shared by the CLI client, the wasm core, and the |
| 189 | // server's test fixtures. Engine plus protocol and nothing else, and | 200 | // server's test fixtures. Engine plus protocol and nothing else, and |
| @@ -235,6 +246,7 @@ pub fn build(b: *std.Build) void { | |||
| 235 | server_mod.addImport("pty", pty_mod); | 246 | server_mod.addImport("pty", pty_mod); |
| 236 | server_mod.addImport("protocol", protocol_mod); | 247 | server_mod.addImport("protocol", protocol_mod); |
| 237 | server_mod.addImport("delta", delta_mod); | 248 | server_mod.addImport("delta", delta_mod); |
| 249 | server_mod.addImport("cmd", cmd_mod); | ||
| 238 | server_mod.addImport("replica", replica_mod); | 250 | server_mod.addImport("replica", replica_mod); |
| 239 | server_mod.addImport("sockpath", sockpath_mod); | 251 | server_mod.addImport("sockpath", sockpath_mod); |
| 240 | // Both: the listener it owns, and the vocabulary it names directly | 252 | // Both: the listener it owns, and the vocabulary it names directly |
| @@ -366,6 +378,7 @@ pub fn build(b: *std.Build) void { | |||
| 366 | }); | 378 | }); |
| 367 | exe_mod.addImport("server", server_mod); | 379 | exe_mod.addImport("server", server_mod); |
| 368 | exe_mod.addImport("protocol", protocol_mod); | 380 | exe_mod.addImport("protocol", protocol_mod); |
| 381 | exe_mod.addImport("cmd", cmd_mod); | ||
| 369 | exe_mod.addImport("proxy", proxy_mod); | 382 | exe_mod.addImport("proxy", proxy_mod); |
| 370 | // The daemon entrypoint loads the key and constructs the listener, so it | 383 | // The daemon entrypoint loads the key and constructs the listener, so it |
| 371 | // needs the modules directly rather than through the server. | 384 | // needs the modules directly rather than through the server. |
| @@ -520,18 +533,18 @@ pub fn build(b: *std.Build) void { | |||
| 520 | b.installArtifact(webhub_exe); | 533 | b.installArtifact(webhub_exe); |
| 521 | 534 | ||
| 522 | const test_step = b.step("test", "Run unit tests"); | 535 | const test_step = b.step("test", "Run unit tests"); |
| 523 | // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their | 536 | // delta_mod, cmd_mod, and sockpath_mod sit BEFORE server_mod, |
| 524 | // tests are seconds-long and socket-free, while a regression in either | 537 | // deliberately: their tests are seconds-long and socket-free, while a |
| 525 | // can wedge a server test that waits on a client forever — and a wedged | 538 | // regression in any of them can wedge a server test that waits on a |
| 526 | // step prints nothing at all. Failing first is what makes the catch | 539 | // client forever — and a wedged step prints nothing at all. Failing |
| 527 | // legible. | 540 | // first is what makes the catch legible. |
| 528 | // | 541 | // |
| 529 | // mux_mod and exe_mod are executable roots, but they carry the argument | 542 | // mux_mod and exe_mod are executable roots, but they carry the argument |
| 530 | // parsers, and a test that is never built is not a test. exe_mod's | 543 | // parsers, and a test that is never built is not a test. exe_mod's |
| 531 | // absence here was a live hazard recorded in decisions.md — muxd's | 544 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 532 | // entrypoint could grow tests that silently never ran, exactly as | 545 | // entrypoint could grow tests that silently never ran, exactly as |
| 533 | // mux_main.zig's five did before it was added. | 546 | // mux_main.zig's five did before it was added. |
| 534 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_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, webhub_main_mod, wsclient_mod}) |mod| { | 547 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, replica_mod, keymap_mod, webhub_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, webhub_main_mod, wsclient_mod}) |mod| { |
| 535 | const t = b.addTest(.{ .root_module = mod }); | 548 | const t = b.addTest(.{ .root_module = mod }); |
| 536 | t.use_llvm = true; | 549 | t.use_llvm = true; |
| 537 | t.use_lld = true; | 550 | t.use_lld = true; |
src/cmd.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,113 @@ | |||
| 1 | //! The session's command state machine: MarkEvents in, transitions out. | ||
| 2 | //! Pure — no I/O, no clock, no seq. The server stamps seqs and decides who | ||
| 3 | //! hears about a transition; this module only decides what the marks mean. | ||
| 4 | //! Trust rule (spec): a D only counts if it closes a seen C; stray marks | ||
| 5 | //! reset to at_prompt rather than being believed. | ||
| 6 | const std = @import("std"); | ||
| 7 | const proto = @import("protocol"); | ||
| 8 | const Engine = @import("engine").Engine; | ||
| 9 | |||
| 10 | pub const Tracker = struct { | ||
| 11 | phase: proto.CmdPhase = .at_prompt, | ||
| 12 | /// Sticky: once any C has been seen, this session speaks marks and the | ||
| 13 | /// pgid fallback stops being consulted while a command is open. | ||
| 14 | marks_seen: bool = false, | ||
| 15 | start_row: u32 = 0, | ||
| 16 | end_row: u32 = 0, | ||
| 17 | exit_code: ?u8 = null, | ||
| 18 | |||
| 19 | pub const Transition = enum { running, returned, reset }; | ||
| 20 | |||
| 21 | pub fn apply(self: *Tracker, ev: Engine.MarkEvent) ?Transition { | ||
| 22 | switch (ev.kind) { | ||
| 23 | .command_start => { | ||
| 24 | self.marks_seen = true; | ||
| 25 | self.phase = .running; | ||
| 26 | self.start_row = ev.row; | ||
| 27 | self.exit_code = null; | ||
| 28 | return .running; | ||
| 29 | }, | ||
| 30 | .command_end => { | ||
| 31 | if (self.phase != .running) { | ||
| 32 | // A D with no open C: a nested program echoing marks it | ||
| 33 | // has no business emitting. Reset, believe nothing. | ||
| 34 | self.phase = .at_prompt; | ||
| 35 | return .reset; | ||
| 36 | } | ||
| 37 | self.phase = .returned; | ||
| 38 | self.end_row = ev.row; | ||
| 39 | self.exit_code = ev.exit_code; | ||
| 40 | return .returned; | ||
| 41 | }, | ||
| 42 | .prompt_start => { | ||
| 43 | // 'A' after a return is the prompt redrawing: back to rest. | ||
| 44 | // 'A' mid-run (Ctrl-C redraw) also lands here — the shell | ||
| 45 | // is telling us the command is over even without a D. | ||
| 46 | if (self.phase == .running) { | ||
| 47 | self.phase = .returned; | ||
| 48 | self.end_row = ev.row; | ||
| 49 | self.exit_code = null; // interrupted: no honest code | ||
| 50 | return .returned; | ||
| 51 | } | ||
| 52 | self.phase = .at_prompt; | ||
| 53 | return null; | ||
| 54 | }, | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | /// True while marks say a command is open — the window in which the | ||
| 59 | /// pgid fallback must NOT race the marks to a verdict. | ||
| 60 | pub fn marksOpen(self: *const Tracker) bool { | ||
| 61 | return self.marks_seen and self.phase == .running; | ||
| 62 | } | ||
| 63 | }; | ||
| 64 | |||
| 65 | test "C then D is running then returned, with rows and code" { | ||
| 66 | var t = Tracker{}; | ||
| 67 | try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null })); | ||
| 68 | try std.testing.expectEqual(proto.CmdPhase.running, t.phase); | ||
| 69 | try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 })); | ||
| 70 | try std.testing.expectEqual(proto.CmdPhase.returned, t.phase); | ||
| 71 | try std.testing.expectEqual(@as(u32, 10), t.start_row); | ||
| 72 | try std.testing.expectEqual(@as(u32, 14), t.end_row); | ||
| 73 | try std.testing.expectEqual(@as(?u8, 1), t.exit_code); | ||
| 74 | } | ||
| 75 | |||
| 76 | test "a stray D resets and is not believed" { | ||
| 77 | var t = Tracker{}; | ||
| 78 | try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 })); | ||
| 79 | try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase); | ||
| 80 | try std.testing.expectEqual(@as(?u8, null), t.exit_code); | ||
| 81 | } | ||
| 82 | |||
| 83 | test "A closes an open command without a code (Ctrl-C at a prompt redraw)" { | ||
| 84 | var t = Tracker{}; | ||
| 85 | _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null }); | ||
| 86 | try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null })); | ||
| 87 | try std.testing.expectEqual(@as(?u8, null), t.exit_code); | ||
| 88 | try std.testing.expectEqual(proto.CmdPhase.returned, t.phase); | ||
| 89 | // The next A settles back to rest with no transition. | ||
| 90 | try std.testing.expectEqual(@as(?Tracker.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null })); | ||
| 91 | try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase); | ||
| 92 | } | ||
| 93 | |||
| 94 | test "marksOpen guards the pgid race window" { | ||
| 95 | var t = Tracker{}; | ||
| 96 | try std.testing.expect(!t.marksOpen()); | ||
| 97 | _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null }); | ||
| 98 | try std.testing.expect(t.marksOpen()); | ||
| 99 | _ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 }); | ||
| 100 | try std.testing.expect(!t.marksOpen()); | ||
| 101 | // Sticky across the next prompt: the session still speaks marks. | ||
| 102 | _ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null }); | ||
| 103 | try std.testing.expect(t.marks_seen); | ||
| 104 | } | ||
| 105 | |||
| 106 | test "back-to-back commands: second C reopens cleanly" { | ||
| 107 | var t = Tracker{}; | ||
| 108 | _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null }); | ||
| 109 | _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 }); | ||
| 110 | try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 3, .exit_code = null })); | ||
| 111 | try std.testing.expectEqual(@as(u32, 3), t.start_row); | ||
| 112 | try std.testing.expectEqual(@as(?u8, null), t.exit_code); | ||
| 113 | } | ||