From 41ffc9ab06f469903c56992ac08e3bcb7043403c Mon Sep 17 00:00:00 2001 From: whackur Date: Wed, 9 Sep 2026 22:38:29 +0900 Subject: [PATCH 1/3] feat(status): exit with dedicated codes for stopped, timeout, and protocol errors --- src/cli.rs | 2 +- src/cli/status.rs | 156 +++++++++++++++++++++++++++++++++++----------- src/main.rs | 15 ++++- 3 files changed, 134 insertions(+), 39 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index f09d0787..d702e5ce 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ mod attach; mod daemon; mod init; pub(crate) mod plugin_cmd; -mod status; +pub(crate) mod status; mod stop; mod update; diff --git a/src/cli/status.rs b/src/cli/status.rs index 99263461..f5bbb41a 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, bail}; +use std::fmt; use std::path::PathBuf; use std::time::Duration; @@ -14,40 +14,109 @@ use status_render::render_status; const STATUS_TIMEOUT: Duration = Duration::from_secs(5); +/// Exit codes for the status command's failure classes. +/// +/// 0 is success; the classes below are stable so scripts can branch on them +/// without parsing stderr. Codes avoid the shell conventions 1 (generic) and +/// 2 (usage/parse) as well as 130+ (signals). +pub mod exit_code { + pub(crate) const DAEMON_STOPPED: u8 = 3; + pub(crate) const RESPONSE_TIMEOUT: u8 = 4; + pub(crate) const PROTOCOL_ERROR: u8 = 5; +} + +/// The status command's failure classes, each with a dedicated exit code. +#[derive(Debug)] +pub(crate) enum StatusError { + /// No socket or listener: the daemon is not running. + Stopped { path: PathBuf }, + /// The daemon accepted the connection but did not answer in time. + Timeout { path: PathBuf }, + /// The daemon answered, but the response violated the status contract. + Protocol(String), +} + +impl StatusError { + pub(crate) fn exit_code(&self) -> i32 { + match self { + StatusError::Stopped { .. } => exit_code::DAEMON_STOPPED as i32, + StatusError::Timeout { .. } => exit_code::RESPONSE_TIMEOUT as i32, + StatusError::Protocol(_) => exit_code::PROTOCOL_ERROR as i32, + } + } +} + +impl fmt::Display for StatusError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StatusError::Stopped { path } => write!( + f, + "daemon stopped: no socket or listener at {}; start a session with `nightcrow -d`", + path.display() + ), + StatusError::Timeout { path } => write!( + f, + "response timeout: daemon at {} accepted the connection but did not answer within {}s", + path.display(), + STATUS_TIMEOUT.as_secs() + ), + StatusError::Protocol(message) => write!(f, "protocol error: {message}"), + } + } +} + +impl std::error::Error for StatusError {} + /// Query the daemon without creating an attach client or terminal subscription. -pub(crate) fn run_status(socket: Option) -> Result<()> { +pub(crate) fn run_status(socket: Option) -> Result<(), StatusError> { let path = resolve_socket_path(socket, crate::daemon::socket::default_socket_path)?; let status = query_status(&path)?; println!("{}", render_status(&status)); Ok(()) } -fn resolve_socket_path(socket: Option, default_path: F) -> Result +fn resolve_socket_path(socket: Option, default_path: F) -> Result where - F: FnOnce() -> Result, + F: FnOnce() -> anyhow::Result, { match socket { Some(path) => Ok(path), - None => default_path(), + None => default_path().map_err(|error| { + StatusError::Protocol(format!( + "could not resolve the default socket path: {error:#}" + )) + }), } } -fn query_status(path: &std::path::Path) -> Result { - let response = match request(path, &ClientMessage::Status {}, STATUS_TIMEOUT) { - Ok(response) => response, - Err(error) if socket_unavailable(&error) => { - bail!( - "daemon unavailable at {}: no socket or listener is running; start a session with `nightcrow -d`", - path.display() - ) - } - Err(error) => return Err(error), - }; - let status = decode_status(response)?; - validate_status(&status)?; +fn query_status(path: &std::path::Path) -> Result { + let response = request(path, &ClientMessage::Status {}, STATUS_TIMEOUT) + .map_err(|error| classify_request_error(path, &error))?; + let status = decode_status(response).map_err(StatusError::Protocol)?; + validate_status(&status).map_err(StatusError::Protocol)?; Ok(status) } +/// Split a one-shot request failure into stopped / timeout / protocol classes. +/// +/// The one-shot seam reports connect failures as `io::Error`s attached to the +/// anyhow chain; read failures past an established connection surface the same +/// way, so a read timeout is only a `Timeout` when the connect context is +/// absent. +fn classify_request_error(path: &std::path::Path, error: &anyhow::Error) -> StatusError { + if socket_unavailable(error) { + return StatusError::Stopped { + path: path.to_path_buf(), + }; + } + if read_timed_out(error) { + return StatusError::Timeout { + path: path.to_path_buf(), + }; + } + StatusError::Protocol(format!("{error:#}")) +} + fn socket_unavailable(error: &anyhow::Error) -> bool { error.downcast_ref::().is_some_and(|error| { matches!( @@ -57,42 +126,51 @@ fn socket_unavailable(error: &anyhow::Error) -> bool { }) } -fn decode_status(response: ServerMessage) -> Result { +fn read_timed_out(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) + }) +} + +fn decode_status(response: ServerMessage) -> Result { match response { ServerMessage::Status { status } => { let expected = version(); if status.version != expected { - bail!( + return Err(format!( "version mismatch: daemon reports {}, this client expects {}", status_render::display_text(&status.version), expected - ); + )); } Ok(status) } ServerMessage::Error { message } => { - bail!("protocol error: daemon rejected the status request: {message}") + Err(format!("daemon rejected the status request: {message}")) } - other => bail!("protocol error: unexpected response to status request: {other:?}"), + other => Err(format!("unexpected response to status request: {other:?}")), } } -fn validate_status(status: &DaemonStatus) -> Result<()> { +fn validate_status(status: &DaemonStatus) -> Result<(), String> { if status.pid == 0 { - bail!("protocol error: malformed status response: PID is zero"); + return Err("malformed status response: PID is zero".into()); } if status.web_endpoint.is_empty() { - bail!("protocol error: malformed status response: web endpoint is empty"); + return Err("malformed status response: web endpoint is empty".into()); } if let Ok(endpoint) = &status.attach_endpoint && endpoint.is_empty() { - bail!("protocol error: malformed status response: attach endpoint is empty"); + return Err("malformed status response: attach endpoint is empty".into()); } let mut client_ids = status.attached_clients.clone(); client_ids.sort_unstable(); if client_ids.windows(2).any(|ids| ids[0] == ids[1]) { - bail!("protocol error: malformed status response: duplicate client id"); + return Err("malformed status response: duplicate client id".into()); } let mut repo_ids = Vec::with_capacity(status.repositories.len()); for repo in &status.repositories { @@ -101,28 +179,28 @@ fn validate_status(status: &DaemonStatus) -> Result<()> { } repo_ids.sort_unstable(); if repo_ids.windows(2).any(|ids| ids[0] == ids[1]) { - bail!("protocol error: malformed status response: duplicate repository id"); + return Err("malformed status response: duplicate repository id".into()); } Ok(()) } -fn validate_repository(repo: &RepositoryStatus) -> Result<()> { +fn validate_repository(repo: &RepositoryStatus) -> Result<(), String> { if repo.id.is_empty() || repo.path.is_empty() { - bail!("protocol error: malformed status response: repository identity is empty"); + return Err("malformed status response: repository identity is empty".into()); } if repo.pane_count != repo.panes.len() { - bail!( - "protocol error: malformed status response: repository {} pane count disagrees with pane ids", + return Err(format!( + "malformed status response: repository {} pane count disagrees with pane ids", status_render::display_text(&repo.id) - ); + )); } let mut panes = repo.panes.clone(); panes.sort_unstable(); if panes.windows(2).any(|ids| ids[0] == ids[1]) { - bail!( - "protocol error: malformed status response: repository {} has duplicate pane id", + return Err(format!( + "malformed status response: repository {} has duplicate pane id", status_render::display_text(&repo.id) - ); + )); } Ok(()) } @@ -130,3 +208,7 @@ fn validate_repository(repo: &RepositoryStatus) -> Result<()> { #[cfg(test)] #[path = "status_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "status_exit_tests.rs"] +mod exit_tests; diff --git a/src/main.rs b/src/main.rs index a6216edb..04aeab25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,8 +44,21 @@ fn main() -> Result<()> { Some(Commands::Attach) => run_attach_detached(), Some(Commands::Plugin { command }) => cli::plugin_cmd::run_plugin(command), Some(Commands::Stop { socket }) => run_stop(socket), - Some(Commands::Status { socket }) => run_status(socket), + Some(Commands::Status { socket }) => report_status_exit(run_status(socket)), Some(Commands::Update { version, path, git }) => run_update(version, path, git), None => run_daemon(cli.exec, cli.port, cli.bind, cli.detach), } } + +/// Exit with the status command's dedicated failure code instead of the +/// generic `main` error path, so scripts can distinguish stopped, timeout, +/// and protocol errors without parsing stderr. +fn report_status_exit(result: Result<(), cli::status::StatusError>) -> ! { + match result { + Ok(()) => std::process::exit(0), + Err(error) => { + eprintln!("error: {error}"); + std::process::exit(error.exit_code()); + } + } +} From 5edeced7012cc9abc6a2a5b041b180e7275b227d Mon Sep 17 00:00:00 2001 From: whackur Date: Wed, 9 Sep 2026 22:38:32 +0900 Subject: [PATCH 2/3] test(status): cover each exit class with a stalled fake daemon and malformed frames --- src/cli/status_exit_tests.rs | 141 +++++++++++++++++++++++++++++++++++ src/cli/status_tests.rs | 34 ++++----- 2 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 src/cli/status_exit_tests.rs diff --git a/src/cli/status_exit_tests.rs b/src/cli/status_exit_tests.rs new file mode 100644 index 00000000..6fb0606f --- /dev/null +++ b/src/cli/status_exit_tests.rs @@ -0,0 +1,141 @@ +use super::*; +use crate::daemon::frame::{Frame, read_frame, write_frame}; +use crate::daemon::one_shot::{connect, send_request}; +use crate::daemon::protocol::{ClientMessage, DaemonStatus, ServerMessage}; +use crate::daemon::socket::DaemonSocket; +use std::io::Write; +use std::thread; + +#[test] +fn a_healthy_daemon_yields_a_status_the_command_can_render() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("status.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let listener = socket.listener().try_clone().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_frame(&mut stream).unwrap(); + let status = DaemonStatus { + pid: 7, + version: crate::daemon::protocol::version(), + started_at_unix_ms: Ok(1), + uptime_ms: 2, + web_endpoint: "http://127.0.0.1:4321/".into(), + attach_endpoint: Ok("status.sock".into()), + repositories: vec![], + attached_clients: vec![], + }; + let response = ServerMessage::Status { status }; + write_frame( + &mut stream, + &Frame::control(serde_json::to_vec(&response).unwrap()), + ) + .unwrap(); + stream.flush().unwrap(); + }); + + let status = query_status(&path).unwrap(); + assert_eq!(status.pid, 7); + server.join().unwrap(); +} + +#[test] +fn a_stalled_daemon_is_the_timeout_failure_with_its_dedicated_exit_code() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stalled.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let listener = socket.listener().try_clone().unwrap(); + // A fake daemon that accepts the connection and reads the request, then + // never answers. The held stream keeps the connection open. + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_frame(&mut stream).unwrap(); + thread::sleep(STATUS_TIMEOUT + Duration::from_millis(500)); + drop(stream); + }); + + let error = query_status(&path).unwrap_err(); + assert!(matches!(error, StatusError::Timeout { .. }), "{error}"); + assert!(error.to_string().contains("response timeout"), "{error}"); + assert_eq!(error.exit_code(), exit_code::RESPONSE_TIMEOUT as i32); + server.join().unwrap(); +} + +#[test] +fn a_malformed_frame_is_the_protocol_failure_with_its_dedicated_exit_code() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("malformed.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let listener = socket.listener().try_clone().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_frame(&mut stream).unwrap(); + write_frame(&mut stream, &Frame::control(vec![0xff, 0xfe])).unwrap(); + stream.flush().unwrap(); + }); + + let error = query_status(&path).unwrap_err(); + assert!(matches!(error, StatusError::Protocol(_)), "{error}"); + assert!(error.to_string().contains("protocol error"), "{error}"); + assert_eq!(error.exit_code(), exit_code::PROTOCOL_ERROR as i32); + server.join().unwrap(); +} + +#[test] +fn the_stopped_state_does_not_create_a_socket_or_session_artifacts() { + let dir = tempfile::TempDir::new().unwrap(); + let missing = dir.path().join("missing.sock"); + let error = query_status(&missing).unwrap_err(); + drop(error); + assert!(!missing.exists()); +} + +#[test] +fn the_one_shot_request_is_read_only_across_every_failure_class() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("read-only.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let listener = socket.listener().try_clone().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let frame = read_frame(&mut stream).unwrap().unwrap(); + let message: ClientMessage = serde_json::from_slice(&frame.payload).unwrap(); + // The status probe must send exactly one status request and nothing + // that could mutate session state. + assert_eq!(message, ClientMessage::Status {}); + thread::sleep(STATUS_TIMEOUT + Duration::from_millis(500)); + }); + + let _ = query_status(&path).unwrap_err(); + server.join().unwrap(); +} + +#[cfg(unix)] +#[test] +fn unix_transport_covers_the_failure_classes() { + transport_failure_classes(); +} + +#[cfg(windows)] +#[test] +fn windows_transport_covers_the_failure_classes() { + transport_failure_classes(); +} + +fn transport_failure_classes() { + let dir = tempfile::TempDir::new().unwrap(); + // Both transports surface a missing socket as the stopped class. + let stopped = query_status(&dir.path().join("missing.sock")).unwrap_err(); + assert!(matches!(stopped, StatusError::Stopped { .. }), "{stopped}"); +} + +#[test] +fn connect_and_send_exist_for_the_stop_command_shared_seam() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("seam.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let mut stream = connect(&path).unwrap(); + send_request(&mut stream, &ClientMessage::Status {}).unwrap(); + drop(stream); + drop(socket); +} diff --git a/src/cli/status_tests.rs b/src/cli/status_tests.rs index 203732e0..6bd6e24d 100644 --- a/src/cli/status_tests.rs +++ b/src/cli/status_tests.rs @@ -28,21 +28,20 @@ fn status_subcommand_defaults_to_the_standard_socket() { fn explicit_socket_override_does_not_evaluate_the_default_socket() { let expected = std::path::PathBuf::from("custom.sock"); let actual = resolve_socket_path(Some(expected.clone()), || { - anyhow::bail!("default socket path should not be evaluated") + panic!("default socket path should not be evaluated") }) .unwrap(); assert_eq!(actual, expected); } #[test] -fn a_missing_daemon_is_distinguished_from_a_protocol_failure() { +fn a_missing_daemon_is_the_stopped_failure_with_its_dedicated_exit_code() { let dir = tempfile::TempDir::new().unwrap(); let error = query_status(&dir.path().join("missing.sock")).unwrap_err(); - assert!( - error.to_string().contains("daemon unavailable"), - "{error:#}" - ); - assert!(error.to_string().contains("nightcrow -d"), "{error:#}"); + assert!(matches!(error, StatusError::Stopped { .. }), "{error}"); + assert!(error.to_string().contains("daemon stopped"), "{error}"); + assert!(error.to_string().contains("nightcrow -d"), "{error}"); + assert_eq!(error.exit_code(), exit_code::DAEMON_STOPPED as i32); } #[test] @@ -58,17 +57,20 @@ fn a_version_mismatch_is_reported_as_a_version_error() { attached_clients: vec![], }; let error = decode_status(ServerMessage::Status { status }).unwrap_err(); - assert!(error.to_string().contains("version mismatch"), "{error:#}"); + assert!(error.contains("version mismatch"), "{error}"); } #[test] -fn an_unexpected_server_message_is_reported_as_a_protocol_error() { +fn an_unexpected_server_message_is_the_protocol_failure_with_its_dedicated_exit_code() { let error = decode_status(ServerMessage::Hello { version: version(), client: 1, }) .unwrap_err(); - assert!(error.to_string().contains("protocol error"), "{error:#}"); + assert!(error.contains("unexpected response"), "{error}"); + let error = StatusError::Protocol(error); + assert!(error.to_string().contains("protocol error"), "{error}"); + assert_eq!(error.exit_code(), exit_code::PROTOCOL_ERROR as i32); } #[test] @@ -89,7 +91,7 @@ fn malformed_status_facts_are_rejected_before_rendering() { attached_clients: vec![], }; let error = validate_status(&status).unwrap_err(); - assert!(error.to_string().contains("malformed status"), "{error:#}"); + assert!(error.contains("malformed status"), "{error}"); } #[test] @@ -105,10 +107,7 @@ fn an_empty_web_endpoint_is_rejected_before_rendering() { attached_clients: vec![], }; let error = validate_status(&status).unwrap_err(); - assert!( - error.to_string().contains("web endpoint is empty"), - "{error:#}" - ); + assert!(error.contains("web endpoint is empty"), "{error}"); } #[test] @@ -124,8 +123,5 @@ fn an_empty_attach_endpoint_is_rejected_before_rendering() { attached_clients: vec![], }; let error = validate_status(&status).unwrap_err(); - assert!( - error.to_string().contains("attach endpoint is empty"), - "{error:#}" - ); + assert!(error.contains("attach endpoint is empty"), "{error}"); } From 729b19cf670404ac107c8b3b57c6c4726b677fc6 Mon Sep 17 00:00:00 2001 From: whackur Date: Wed, 9 Sep 2026 22:38:35 +0900 Subject: [PATCH 3/3] docs: document the status command's exit codes and socket transport --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 540d2489..e50f83d5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,18 @@ nightcrow update # reinstall the binary; restart the session afterwards For foreground operation, use `nightcrow`; `nightcrow -d` starts the session in the background and writes its output to `~/.nightcrow/daemon.out`. See [Getting started](docs/getting-started.md) for installation variants, startup panes, disconnects, updates, and build verification. -To inspect a running daemon without attaching, run `nightcrow status [--socket PATH]`. It performs a read-only one-shot query and reports the PID, version, start time, uptime, web and attach endpoints, attached clients, repositories, and panes. It exits non-zero when no daemon is running. +To inspect a running daemon without attaching, run `nightcrow status [--socket PATH]`. It performs a read-only one-shot query and reports the PID, version, start time, uptime, web and attach endpoints, attached clients, repositories, and panes. Fields the daemon marks unavailable are reported as unavailable; the command never infers state from the process table or port scans, never auto-starts a daemon, and never opens the attach TUI or changes session state. + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | The daemon answered and its status was rendered. | +| 3 | Stopped — no socket or listener at the expected path. | +| 4 | Response timeout — the daemon accepted the connection but did not answer within 5 seconds. | +| 5 | Protocol error — the daemon answered, but the response failed validation or violated the status contract. | + +The daemon socket is a Unix-domain socket on every platform: a filesystem path on Unix and an AF_UNIX socket via the `uds_windows` transport on Windows. `--socket PATH` overrides the default location on either platform. ## Features