fix(cli): a server that closes the event stream is not a success

`tty7 events` blocks forever by contract, so returning at all means the
control connection went away with the server — and it returned 0. A reader
whose server stopped mid-run was told nothing had happened; the loop
consuming its lines simply stopped receiving any, with no exit code to
branch on. Verified against an isolated daemon: stopping the server ended
the stream and the CLI exited 0.

It exits 1 now and says so on stderr. Nothing extra goes to stdout, so a
reader parsing NDJSON is never handed a line of a shape it has not seen —
the same split the rest of the verbs use. An interrupted run is unaffected:
a signal takes the process rather than this path.
This commit is contained in:
l0ng-ai
2026-08-16 17:47:52 +08:00
parent c1b3a10561
commit bcbdf51fed
2 changed files with 37 additions and 1 deletions
+33 -1
View File
@@ -1157,7 +1157,25 @@ fn events(json_mode: bool, backend: &mut dyn Backend) -> Result<Outcome> {
}
Ok(())
})?;
report("", Value::Null)
// This verb blocks forever by contract, so returning at all means the
// stream ended under it — and the only thing that ends it is the control
// connection going away with the server. Exiting 0 there told a reader
// that watched a server stop mid-run that nothing had happened, and the
// loop consuming the lines simply stopped receiving any. An interrupted
// run does not come through here: a signal takes the process, not this
// path.
//
// Nothing extra is written to stdout, so a reader parsing NDJSON is not
// handed a line of a shape it has never seen; the news goes to stderr and
// the exit code, which is the rule everywhere else in this CLI.
eprintln!("tty7: the server closed the event stream");
Ok(Outcome::Exit(
1,
Report {
human: String::new(),
json: Value::Null,
},
))
}
fn event_line(event: &ControlEvent) -> String {
@@ -4365,6 +4383,20 @@ mod tests {
);
}
#[test]
fn a_stream_that_ends_under_events_is_not_a_success() {
// `events` blocks forever by contract, so a return means the control
// connection went away with the server. Exiting 0 told a reader that
// watched a server stop mid-run that nothing had happened.
let mut backend = mock();
backend.events.push(ControlEvent::LayoutResync);
let out = run_cli(&["tty7", "events"], &Context::default(), &mut backend);
match out {
Outcome::Exit(1, _) => {}
other => panic!("a closed stream should exit nonzero, got {other:?}"),
}
}
#[test]
fn an_event_line_is_a_sentence_and_never_a_debug_dump() {
use tty7_core::core::machine::{PaneNode, PaneRecord, Tab};
+4
View File
@@ -251,6 +251,10 @@ Without `--json` each line is a sentence meant to be read, and its wording is
not a format to parse — `--json` below is. The kinds and payloads are stable;
the prose is not.
Because it blocks forever, returning at all means the server closed the stream:
that exits 1 and says so on stderr, so a loop consuming the lines does not read
the end of its input as the end of the work.
Each line is one externally tagged object, `{"<kind>": {…}}`:
| Kind | Payload |