From bcbdf51fedda9914996baf6e09409074c833039e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:47:52 +0800 Subject: [PATCH] fix(cli): a server that closes the event stream is not a success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- crates/tty7-cli/src/commands.rs | 34 ++++++++++++++++++++++++++++++++- docs/cli/reference.mdx | 4 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index c92612f2..930e85c9 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -1157,7 +1157,25 @@ fn events(json_mode: bool, backend: &mut dyn Backend) -> Result { } 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}; diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 1182f6cc..d7770be5 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -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 | Payload |