src/server/cmd.zig
Ref: Size: 8.1 KiB History
//! `LiveCommand` follows one session's command through the OSC 133 marks its
//! shell emits: it parses each MarkEvent and determines the transition that
//! mark causes, if any. It is pure — the server stamps the seqs and puts the
//! transitions on the wire as `cmd_state`, `await_reply` and `status_reply`.
//!
//! The trust rule: a `command_end` counts only if it closes a `command_start`
//! already seen; an unmatched one resets to `at_prompt` rather than being
//! believed. A `command_start` while one is open is believed wholesale — latest
//! wins, with no attempt to detect nesting — and a `prompt_start` at an idle
//! prompt does nothing.
const std = @import("std");
const proto = @import("term").protocol;
const Engine = @import("engine").Engine;
pub const LiveCommand = struct {
phase: proto.CmdPhase = .at_prompt,
/// Sticky: once any `command_start` has been seen, this session speaks marks
/// and the pgid fallback stops being consulted while a command is open.
marks_seen: bool = false,
start_row: u32 = 0,
end_row: u32 = 0,
exit_code: ?u8 = null,
/// `.reset` means it forcibly settled to `at_prompt`; that is a
/// state change to observe, never a transition to broadcast.
pub const Transition = enum { running, returned, reset };
pub fn apply(self: *LiveCommand, ev: Engine.MarkEvent) ?Transition {
switch (ev.kind) {
.command_start => {
self.marks_seen = true;
self.phase = .running;
self.start_row = ev.row;
// The new command has no end row, and the PREVIOUS command's
// would read as a span running backwards (end < start) to
// anyone who fetched it mid-run — a mid-command `status_reply`
// is exactly that fetch. Collapsing it to the start row says
// what is true under the end <= start convention: the span is
// empty until a `command_end` closes it.
self.end_row = ev.row;
self.exit_code = null;
return .running;
},
.command_end => {
if (self.phase != .running) {
// Ending a command that never started: a nested program
// echoing marks it has no business emitting. Believe nothing.
self.phase = .at_prompt;
return .reset;
}
self.phase = .returned;
self.end_row = ev.row;
self.exit_code = ev.exit_code;
return .returned;
},
.prompt_start => {
// After a return this is the prompt redrawing: back to rest.
// Mid-run it is a Ctrl-C redraw — the shell saying the command
// is over without ever ending it.
if (self.phase == .running) {
self.phase = .returned;
self.end_row = ev.row;
self.exit_code = null; // interrupted: no honest code
return .returned;
}
self.phase = .at_prompt;
return null;
},
}
}
/// The window in which the pgid fallback must NOT race the marks.
pub fn marksOpen(self: *const LiveCommand) bool {
return self.marks_seen and self.phase == .running;
}
};
test "a command that starts then ends goes running then returned, with rows and code" {
var t = LiveCommand{};
try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
try std.testing.expectEqual(@as(?LiveCommand.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
try std.testing.expectEqual(@as(u32, 10), t.start_row);
try std.testing.expectEqual(@as(u32, 14), t.end_row);
try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
}
test "an unmatched command_end resets and is not believed" {
var t = LiveCommand{};
try std.testing.expectEqual(@as(?LiveCommand.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
try std.testing.expectEqual(@as(?u8, null), t.exit_code);
}
test "a prompt_start closes an open command without a code (Ctrl-C at a prompt redraw)" {
var t = LiveCommand{};
_ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
try std.testing.expectEqual(@as(?LiveCommand.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
try std.testing.expectEqual(@as(?u8, null), t.exit_code);
try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
try std.testing.expectEqual(@as(u32, 6), t.end_row);
// The next `prompt_start` settles back to rest with no transition.
try std.testing.expectEqual(@as(?LiveCommand.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
}
test "marksOpen guards the pgid race window" {
var t = LiveCommand{};
try std.testing.expect(!t.marksOpen());
_ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
try std.testing.expect(t.marksOpen());
_ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 });
try std.testing.expect(!t.marksOpen());
// Sticky across the next prompt: the session still speaks marks.
_ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null });
try std.testing.expect(t.marks_seen);
}
test "back-to-back commands: the second command_start reopens cleanly" {
var t = LiveCommand{};
_ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
_ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null }));
try std.testing.expectEqual(@as(u32, 4), t.start_row);
try std.testing.expectEqual(@as(?u8, null), t.exit_code);
// Nothing of the finished command survives into the running one — the END
// ROW especially, which left at the finished command's value describes a
// span ending before it starts, and that is what a mid-command
// `status_reply` would hand an agent. Equal rows are the empty span every
// consumer already reads as "no output yet".
try std.testing.expectEqual(@as(u32, 4), t.end_row);
try std.testing.expect(t.end_row <= t.start_row);
}
test "a nested command_start while running is believed wholesale: latest wins" {
var t = LiveCommand{};
_ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 9, .exit_code = null }));
try std.testing.expectEqual(@as(u32, 9), t.start_row);
try std.testing.expectEqual(@as(?u8, null), t.exit_code);
}
test "an unmatched command_end after a completed one resets phase but never absorbs its payload" {
var t = LiveCommand{};
_ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
_ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 1 });
try std.testing.expectEqual(@as(?LiveCommand.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 9, .exit_code = 7 }));
try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
// The stray's row and exit code never land: the finished command's
// fields are left exactly as the real `command_end` set them.
try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
try std.testing.expectEqual(@as(u32, 0), t.start_row);
try std.testing.expectEqual(@as(u32, 2), t.end_row);
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}