diff --git a/crates/kild-core/src/daemon/client.rs b/crates/kild-core/src/daemon/client.rs index e6dacca1..ba78cc07 100644 --- a/crates/kild-core/src/daemon/client.rs +++ b/crates/kild-core/src/daemon/client.rs @@ -124,6 +124,18 @@ pub enum DaemonClientError { Io(#[from] std::io::Error), } +impl DaemonClientError { + /// Whether this error indicates the daemon is unreachable (not running, + /// connection refused, IO failure, protocol corruption). + /// + /// Only `DaemonError` (a structured error response) proves the daemon is + /// alive and processed the request — all other variants mean the PTY is + /// effectively already dead. + pub fn is_unreachable(&self) -> bool { + !matches!(self, DaemonClientError::DaemonError { .. }) + } +} + impl KildError for DaemonClientError { fn error_code(&self) -> &'static str { match self { @@ -806,4 +818,52 @@ mod tests { let daemon_err: DaemonClientError = ipc_err.into(); assert!(matches!(daemon_err, DaemonClientError::Io(_))); } + + #[test] + fn test_is_unreachable_not_running() { + let err = DaemonClientError::NotRunning { + path: "/tmp/test.sock".to_string(), + }; + assert!(err.is_unreachable(), "NotRunning should be unreachable"); + } + + #[test] + fn test_is_unreachable_connection_failed() { + let err = DaemonClientError::ConnectionFailed { + message: "connection reset".to_string(), + }; + assert!( + err.is_unreachable(), + "ConnectionFailed should be unreachable" + ); + } + + #[test] + fn test_is_unreachable_protocol_error() { + let err = DaemonClientError::ProtocolError { + message: "empty response".to_string(), + }; + assert!(err.is_unreachable(), "ProtocolError should be unreachable"); + } + + #[test] + fn test_is_unreachable_io_error() { + let err = DaemonClientError::Io(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "broken", + )); + assert!(err.is_unreachable(), "Io should be unreachable"); + } + + #[test] + fn test_is_unreachable_daemon_error_is_reachable() { + let err = DaemonClientError::DaemonError { + code: ErrorCode::SessionNotFound, + message: "not found".to_string(), + }; + assert!( + !err.is_unreachable(), + "DaemonError means daemon is alive — should NOT be unreachable" + ); + } } diff --git a/crates/kild-core/src/sessions/destroy.rs b/crates/kild-core/src/sessions/destroy.rs index 234da436..458da9bb 100644 --- a/crates/kild-core/src/sessions/destroy.rs +++ b/crates/kild-core/src/sessions/destroy.rs @@ -216,11 +216,11 @@ fn sweep_ui_daemon_sessions(session_id: &str) { } } } - Err(crate::daemon::client::DaemonClientError::NotRunning { .. }) => { + Err(e) if e.is_unreachable() => { debug!( event = "core.session.destroy_ui_sessions_sweep_skipped", session_id = session_id, - reason = "daemon_not_running" + reason = "daemon_unreachable" ); } Err(e) => { diff --git a/crates/kild-core/src/sessions/list.rs b/crates/kild-core/src/sessions/list.rs index 7abd3ba0..1b3835e4 100644 --- a/crates/kild-core/src/sessions/list.rs +++ b/crates/kild-core/src/sessions/list.rs @@ -1,6 +1,5 @@ use tracing::{error, info, warn}; -use crate::daemon::client::DaemonClientError; use crate::sessions::{errors::SessionError, persistence, types::*}; use kild_config::Config; @@ -69,7 +68,7 @@ pub fn sync_daemon_session_status(session: &mut Session) -> bool { let status = match crate::daemon::client::get_session_status(&daemon_sid) { Ok(s) => s, Err(e) => { - if !is_daemon_unreachable(&e) { + if !e.is_unreachable() { // Daemon sent a structured error (DaemonError) — it's alive but // returned something unexpected. Don't sync to avoid false positives. warn!( @@ -141,18 +140,6 @@ pub fn sync_daemon_session_status(session: &mut Session) -> bool { true } -/// Check if a daemon client error indicates the daemon is unreachable. -/// -/// Connection failures, IO errors, and protocol errors (e.g., empty response from -/// a dying daemon) all indicate the daemon process is not functional. Only -/// `DaemonError` (a structured error response) proves the daemon is alive. -/// -/// Note: `get_session_status()` converts `NotRunning` into `Ok(None)` before -/// returning, so `NotRunning` cannot reach this helper in practice. -fn is_daemon_unreachable(e: &DaemonClientError) -> bool { - !matches!(e, DaemonClientError::DaemonError { .. }) -} - #[cfg(test)] mod tests { use super::*; @@ -271,64 +258,7 @@ mod tests { assert_eq!(session.status, SessionStatus::Active); } - #[test] - fn test_is_daemon_unreachable_connection_failed() { - let err = DaemonClientError::ConnectionFailed { - message: "connection reset".to_string(), - }; - assert!( - is_daemon_unreachable(&err), - "ConnectionFailed should be treated as unreachable" - ); - } - - #[test] - fn test_is_daemon_unreachable_io_error() { - let err = DaemonClientError::Io(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "broken", - )); - assert!( - is_daemon_unreachable(&err), - "IO errors should be treated as unreachable" - ); - } - - #[test] - fn test_is_daemon_unreachable_protocol_error() { - let err = DaemonClientError::ProtocolError { - message: "Empty response from daemon".to_string(), - }; - assert!( - is_daemon_unreachable(&err), - "ProtocolError (empty response from dying daemon) should be treated as unreachable" - ); - } - - #[test] - fn test_is_daemon_unreachable_not_running() { - // NotRunning cannot reach is_daemon_unreachable in practice (absorbed by - // get_session_status into Ok(None)), but the helper classifies it correctly. - let err = DaemonClientError::NotRunning { - path: "/tmp/test.sock".to_string(), - }; - assert!( - is_daemon_unreachable(&err), - "NotRunning should be treated as unreachable" - ); - } - - #[test] - fn test_is_daemon_unreachable_daemon_error_is_reachable() { - let err = DaemonClientError::DaemonError { - code: kild_protocol::ErrorCode::SessionNotFound, - message: "not found".to_string(), - }; - assert!( - !is_daemon_unreachable(&err), - "DaemonError means daemon is alive — should NOT be treated as unreachable" - ); - } + // is_unreachable classification tests live on DaemonClientError in daemon/client.rs #[test] fn test_sync_daemon_marks_stopped_when_daemon_not_running() { diff --git a/crates/kild-core/src/sessions/stop.rs b/crates/kild-core/src/sessions/stop.rs index 25829e3e..7e03a78d 100644 --- a/crates/kild-core/src/sessions/stop.rs +++ b/crates/kild-core/src/sessions/stop.rs @@ -56,12 +56,22 @@ pub fn stop_session(name: &str) -> Result<(), SessionError> { agent = agent_proc.agent() ); if let Err(e) = crate::daemon::client::destroy_daemon_session(daemon_sid, false) { - error!( - event = "core.session.destroy_daemon_failed", - daemon_session_id = daemon_sid, - error = %e - ); - daemon_errors.push(e.to_string()); + if e.is_unreachable() { + // Daemon unreachable — PTY is effectively already dead. + warn!( + event = "core.session.destroy_daemon_unreachable", + daemon_session_id = daemon_sid, + error = %e, + "Daemon unreachable during stop — treating as PTY already dead" + ); + } else { + error!( + event = "core.session.destroy_daemon_failed", + daemon_session_id = daemon_sid, + error = %e + ); + daemon_errors.push(e.to_string()); + } } // Close the attach terminal window so it doesn't linger showing @@ -272,11 +282,20 @@ pub fn stop_teammate(branch: &str, pane_id: &str) -> Result<(), SessionError> { .ok_or_else(pane_not_found)? .to_string(); - crate::daemon::client::destroy_daemon_session(&daemon_session_id, false).map_err(|e| { - SessionError::DaemonError { - message: e.to_string(), + if let Err(e) = crate::daemon::client::destroy_daemon_session(&daemon_session_id, false) { + if e.is_unreachable() { + warn!( + event = "core.session.stop_teammate_daemon_unreachable", + daemon_session_id = %daemon_session_id, + error = %e, + "Daemon unreachable during teammate stop — treating as PTY already dead" + ); + } else { + return Err(SessionError::DaemonError { + message: e.to_string(), + }); } - })?; + } info!( event = "core.session.stop_teammate_completed",