Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions crates/kild-core/src/daemon/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}
4 changes: 2 additions & 2 deletions crates/kild-core/src/sessions/destroy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
74 changes: 2 additions & 72 deletions crates/kild-core/src/sessions/list.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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() {
Expand Down
39 changes: 29 additions & 10 deletions crates/kild-core/src/sessions/stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading