From 72f078784441e751b8da0f7df317ce3b10f89efe Mon Sep 17 00:00:00 2001 From: msmps <7691252+msmps@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:47:07 +0100 Subject: [PATCH] feat(session): preserve finalized session evidence --- README.md | 6 + crates/pilotty-cli/src/daemon/mod.rs | 1 + crates/pilotty-cli/src/daemon/retention.rs | 30 +++ crates/pilotty-cli/src/daemon/server.rs | 169 ++++++++++++- crates/pilotty-cli/src/daemon/session.rs | 265 ++++++++++++++++++++- crates/pilotty-cli/src/daemon/tombstone.rs | 195 +++++++++++++++ crates/pilotty-core/src/error.rs | 41 +++- crates/pilotty-core/src/protocol.rs | 1 + 8 files changed, 687 insertions(+), 21 deletions(-) create mode 100644 crates/pilotty-cli/src/daemon/tombstone.rs diff --git a/README.md b/README.md index 5bbecea..a1a705f 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,12 @@ pilotty logs -s myapp > output.bin # Save raw ANSI evidence without metadata Retention is bounded to 2 MiB per session by default, and reports when older bytes were dropped. +When a session exits, the daemon keeps bounded final evidence in memory for up to 10 +minutes: exit metadata, the final full screen, and the last 64 KiB of raw output. +`snapshot` and `logs` continue to work during that window. Commands that require a live +process return `SESSION_EXITED`. Tombstones disappear when they expire, are evicted, or +the daemon restarts, and never keep the daemon running. + ### Screen Capture ```bash diff --git a/crates/pilotty-cli/src/daemon/mod.rs b/crates/pilotty-cli/src/daemon/mod.rs index ecfa133..847043e 100644 --- a/crates/pilotty-cli/src/daemon/mod.rs +++ b/crates/pilotty-cli/src/daemon/mod.rs @@ -7,3 +7,4 @@ pub mod retention; pub mod server; pub mod session; pub mod terminal; +pub mod tombstone; diff --git a/crates/pilotty-cli/src/daemon/retention.rs b/crates/pilotty-cli/src/daemon/retention.rs index 54de23a..a1277ce 100644 --- a/crates/pilotty-cli/src/daemon/retention.rs +++ b/crates/pilotty-cli/src/daemon/retention.rs @@ -72,6 +72,22 @@ impl RetentionRing { } } +impl RetentionSnapshot { + pub(crate) fn into_tail(self, capacity: usize) -> Self { + let start = self.bytes.len().saturating_sub(capacity); + let bytes = self.bytes[start..].to_vec(); + let retained_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let dropped_bytes = self.total_bytes.saturating_sub(retained_bytes); + Self { + bytes, + total_bytes: self.total_bytes, + retained_bytes, + dropped_bytes, + truncated: dropped_bytes > 0, + } + } +} + #[cfg(test)] mod tests { use crate::daemon::retention::RetentionRing; @@ -117,4 +133,18 @@ mod tests { assert_eq!(snapshot.dropped_bytes, 8); assert!(snapshot.truncated); } + + #[test] + fn tombstone_tail_preserves_total_accounting() { + let mut retention = RetentionRing::new(10); + retention.append(b"0123456789"); + + let tail = retention.snapshot().into_tail(4); + + assert_eq!(tail.bytes, b"6789"); + assert_eq!(tail.total_bytes, 10); + assert_eq!(tail.retained_bytes, 4); + assert_eq!(tail.dropped_bytes, 6); + assert!(tail.truncated); + } } diff --git a/crates/pilotty-cli/src/daemon/server.rs b/crates/pilotty-cli/src/daemon/server.rs index 6fd723b..935ce2c 100644 --- a/crates/pilotty-cli/src/daemon/server.rs +++ b/crates/pilotty-cli/src/daemon/server.rs @@ -18,8 +18,10 @@ use tokio::task::JoinSet; use tracing::{debug, error, info, warn}; use crate::daemon::paths; +use crate::daemon::pty::TermSize; use crate::daemon::retention::DEFAULT_RETAIN_BYTES; -use crate::daemon::session::{ObservationEvent, SessionId, SessionManager}; +use crate::daemon::session::{ObservationEvent, SessionEvidence, SessionId, SessionManager}; +use crate::daemon::tombstone::Tombstone; const RETAIN_BYTES_ENV: &str = "PILOTTY_RETAIN_BYTES"; @@ -716,12 +718,16 @@ async fn handle_logs( sessions: &SessionManager, session: Option, ) -> Response { - let session_id = match sessions.resolve_session(session.as_deref()).await { - Ok(id) => id, + let evidence = match sessions.resolve_evidence(session.as_deref()).await { + Ok(evidence) => evidence, Err(error) => return Response::error(request_id, error), }; - match sessions.session_logs(&session_id).await { + let logs = match evidence { + SessionEvidence::Live(session_id) => sessions.session_logs(&session_id).await, + SessionEvidence::Exited(tombstone) => Ok(tombstone.output), + }; + match logs { Ok(logs) => Response::success( request_id, ResponseData::Logs { @@ -756,13 +762,18 @@ async fn handle_snapshot( ) -> Response { use std::time::{Duration, Instant}; - // Resolve session first - let session_id = match sessions.resolve_session(session.as_deref()).await { - Ok(id) => id, + let format = format.unwrap_or(SnapshotFormat::Full); + let evidence = match sessions.resolve_evidence(session.as_deref()).await { + Ok(evidence) => evidence, Err(e) => return Response::error(request_id, e), }; + let session_id = match evidence { + SessionEvidence::Live(id) => id, + SessionEvidence::Exited(tombstone) => { + return exited_snapshot_response(request_id, *tombstone, format) + } + }; - let format = format.unwrap_or(SnapshotFormat::Full); let with_elements = matches!(format, SnapshotFormat::Full); let timeout = Duration::from_millis(timeout_ms); // Retain the shipped minimum settle window. @@ -933,6 +944,49 @@ async fn handle_snapshot( } } +fn exited_snapshot_response( + request_id: &str, + tombstone: Tombstone, + format: SnapshotFormat, +) -> Response { + match format { + SnapshotFormat::Full => Response::success( + request_id, + ResponseData::ScreenState(tombstone.final_screen), + ), + SnapshotFormat::Compact => Response::success( + request_id, + ResponseData::ScreenState(ScreenState { + snapshot_id: tombstone.final_screen.snapshot_id, + size: tombstone.final_screen.size, + cursor: tombstone.final_screen.cursor, + text: None, + elements: None, + content_hash: None, + }), + ), + SnapshotFormat::Text => { + let text = tombstone.final_screen.text.unwrap_or_default(); + let content = format_text_snapshot( + &text, + tombstone.final_screen.cursor.row, + tombstone.final_screen.cursor.col, + TermSize { + cols: tombstone.final_screen.size.cols, + rows: tombstone.final_screen.size.rows, + }, + ); + Response::success( + request_id, + ResponseData::Snapshot { + format: SnapshotFormat::Text, + content, + }, + ) + } + } +} + /// Format a plain text snapshot with cursor position indicator. fn format_text_snapshot( text: &str, @@ -1488,6 +1542,105 @@ mod tests { )); } + #[tokio::test] + async fn finalized_session_serves_evidence_and_rejects_input() { + let sessions = Arc::new(SessionManager::new()); + sessions + .create_session( + vec![ + "sh".to_string(), + "-c".to_string(), + "printf recovered-evidence; exit 9".to_string(), + ], + Some("finalized".to_string()), + None, + None, + ) + .await + .expect("create exiting session"); + sessions.spawn_cleaner(); + timeout(Duration::from_secs(3), async { + while !sessions.is_empty().await { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("finalize session"); + + let shutdown = Arc::new(Notify::new()); + let input = handle_request( + Request::new( + "input", + Command::Type { + text: "nope".to_string(), + session: Some("finalized".to_string()), + }, + ), + sessions.clone(), + shutdown.clone(), + ) + .await; + assert!(matches!( + input.error.map(|error| error.code), + Some(ErrorCode::SessionExited) + )); + + let legacy_input = handle_request( + Request { + id: "legacy-input".to_string(), + command: Command::Type { + text: "nope".to_string(), + session: Some("finalized".to_string()), + }, + protocol: 0, + }, + sessions.clone(), + shutdown.clone(), + ) + .await; + assert!(matches!( + legacy_input.error.map(|error| error.code), + Some(ErrorCode::InvalidInput) + )); + + let snapshot = handle_request( + Request::new( + "snapshot", + Command::Snapshot { + session: Some("finalized".to_string()), + format: Some(SnapshotFormat::Full), + await_change: None, + settle_ms: 0, + timeout_ms: 1000, + }, + ), + sessions.clone(), + shutdown.clone(), + ) + .await; + assert!(matches!( + snapshot.data, + Some(ResponseData::ScreenState(ScreenState { text: Some(text), .. })) + if text.contains("recovered-evidence") + )); + + let logs = handle_request( + Request::new( + "logs", + Command::Logs { + session: Some("finalized".to_string()), + }, + ), + sessions, + shutdown, + ) + .await; + assert!(matches!( + logs.data, + Some(ResponseData::Logs { bytes, .. }) if bytes.ends_with(b"recovered-evidence") + )); + } + #[tokio::test] async fn logs_returns_bounded_ordered_raw_output_over_the_socket() { let temp_dir = std::env::temp_dir(); diff --git a/crates/pilotty-cli/src/daemon/session.rs b/crates/pilotty-cli/src/daemon/session.rs index 41b31c4..a2ba00c 100644 --- a/crates/pilotty-cli/src/daemon/session.rs +++ b/crates/pilotty-cli/src/daemon/session.rs @@ -15,11 +15,15 @@ use pilotty_core::elements::classify::{detect, ClassifyContext}; use pilotty_core::elements::Element; use pilotty_core::error::ApiError; use pilotty_core::protocol::SessionInfo; -use pilotty_core::snapshot::compute_content_hash; +use pilotty_core::snapshot::{compute_content_hash, CursorState, ScreenState, TerminalSize}; use crate::daemon::pty::{AsyncPtyHandle, PtySession, TermSize}; use crate::daemon::retention::{RetentionRing, RetentionSnapshot, DEFAULT_RETAIN_BYTES}; use crate::daemon::terminal::TerminalEmulator; +use crate::daemon::tombstone::{ + ExitMetadata, Tombstone, TombstoneStore, TOMBSTONE_CAPACITY, TOMBSTONE_OUTPUT_BYTES, + TOMBSTONE_TTL, +}; /// Unique identifier for a session. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -135,6 +139,7 @@ struct Session { name: Option, /// Command that was spawned. command: Vec, + cwd: Option, /// When the session was created. created_at: DateTime, /// Async handle for PTY I/O. @@ -254,10 +259,74 @@ impl Session { .unwrap_or(false) } - async fn shutdown(&self) { + async fn shutdown(&self) -> bool { self.pty.terminate(); let output_complete = self.wait_for_output_close().await; self.finish_pump(output_complete).await; + let _exit = self.observe_process_exit(); + output_complete + } + + async fn final_tombstone( + &self, + snapshot_id: u64, + output_complete: bool, + killed_by_client: bool, + ) -> Tombstone { + let snapshot = self.snapshot(true).await; + let output = self + .retention + .lock() + .await + .snapshot() + .into_tail(TOMBSTONE_OUTPUT_BYTES); + let process_exit = self + .process_exit + .lock() + .ok() + .and_then(|exit| exit.as_ref().cloned()); + let exit = process_exit + .as_ref() + .map(|exit| ExitMetadata { + code: Some(exit.status.exit_code()), + signal: exit.status.signal().map(ToOwned::to_owned), + success: exit.status.success(), + killed_by_client, + }) + .unwrap_or(ExitMetadata { + code: None, + signal: None, + success: false, + killed_by_client, + }); + let ended_at_monotonic = Instant::now(); + Tombstone { + id: self.id.clone(), + name: self.name.clone(), + command: self.command.clone(), + cwd: self.cwd.clone(), + created_at: self.created_at, + ended_at: Utc::now(), + ended_at_monotonic, + exit, + output_complete, + final_screen: ScreenState { + snapshot_id, + size: TerminalSize { + cols: snapshot.size.cols, + rows: snapshot.size.rows, + }, + cursor: CursorState { + row: snapshot.cursor_pos.0, + col: snapshot.cursor_pos.1, + visible: snapshot.cursor_visible, + }, + text: Some(snapshot.text), + elements: snapshot.elements, + content_hash: Some(snapshot.content_hash), + }, + output, + } } } @@ -276,6 +345,12 @@ pub(crate) struct SessionObserver { pump_state: watch::Receiver, } +#[derive(Clone)] +pub(crate) enum SessionEvidence { + Live(SessionId), + Exited(Box), +} + impl SessionObserver { /// Capture the current screen after marking the current pump state as observed. /// Output arriving after that mark remains visible to `wait_for_update`. @@ -376,6 +451,7 @@ const MAX_SESSIONS: usize = 100; /// Thread-safe via interior mutability with RwLock. pub struct SessionManager { sessions: RwLock>>, + tombstones: Mutex, default_retain_bytes: usize, /// Global snapshot counter for unique snapshot IDs. snapshot_counter: AtomicU64, @@ -397,6 +473,7 @@ impl SessionManager { pub(crate) fn with_default_retain_bytes(default_retain_bytes: usize) -> Self { Self { sessions: RwLock::new(HashMap::new()), + tombstones: Mutex::new(TombstoneStore::new(TOMBSTONE_CAPACITY, TOMBSTONE_TTL)), default_retain_bytes, snapshot_counter: AtomicU64::new(1), } @@ -490,6 +567,7 @@ impl SessionManager { id: id.clone(), name, command, + cwd, created_at: Utc::now(), pty, retention, @@ -502,13 +580,13 @@ impl SessionManager { let mut sessions = self.sessions.write().await; if sessions.len() >= MAX_SESSIONS { drop(sessions); - session.shutdown().await; + let _output_complete = session.shutdown().await; return Err(ApiError::session_limit_reached(MAX_SESSIONS)); } if let Some(ref n) = session.name { if sessions.values().any(|s| s.name.as_deref() == Some(n)) { drop(sessions); - session.shutdown().await; + let _output_complete = session.shutdown().await; return Err(ApiError::duplicate_session_name(n)); } } @@ -542,7 +620,14 @@ impl SessionManager { .await .remove(id) .ok_or_else(|| ApiError::session_not_found(&id.0))?; - session.shutdown().await; + let output_complete = session.shutdown().await; + let tombstone = session + .final_tombstone(self.next_snapshot_id(), output_complete, true) + .await; + self.tombstones + .lock() + .await + .insert(tombstone, Instant::now()); Ok(()) } @@ -583,12 +668,13 @@ impl SessionManager { /// /// Returns an error if no matching session is found. pub async fn resolve_session(&self, identifier: Option<&str>) -> Result { - match identifier { + let unresolved = match identifier { None => { // Return default session - self.find_by_name("default") - .await - .ok_or_else(|| ApiError::session_not_found("default")) + if let Some(id) = self.find_by_name("default").await { + return Ok(id); + } + "default" } Some(id_or_name) => { let sessions = self.sessions.read().await; @@ -599,15 +685,52 @@ impl SessionManager { } // Then try as session name - sessions + if let Some(id) = sessions .values() .find(|s| s.name.as_deref() == Some(id_or_name)) .map(|s| s.id.clone()) - .ok_or_else(|| ApiError::session_not_found(id_or_name)) + { + return Ok(id); + } + id_or_name } + }; + + match self.resolve_tombstone(unresolved).await { + Some(tombstone) => Err(ApiError::session_exited( + unresolved, + &tombstone.exit.description(), + )), + None => Err(ApiError::session_not_found(unresolved)), } } + pub(crate) async fn resolve_evidence( + &self, + identifier: Option<&str>, + ) -> Result { + match self.resolve_session(identifier).await { + Ok(id) => Ok(SessionEvidence::Live(id)), + Err(error) if error.code == pilotty_core::error::ErrorCode::SessionExited => { + let identifier = identifier.unwrap_or("default"); + self.resolve_tombstone(identifier) + .await + .map(Box::new) + .map(SessionEvidence::Exited) + .ok_or_else(|| ApiError::session_not_found(identifier)) + } + Err(error) => Err(error), + } + } + + async fn resolve_tombstone(&self, identifier: &str) -> Option { + let mut tombstones = self.tombstones.lock().await; + let now = Instant::now(); + tombstones + .get(&SessionId::from(identifier), now) + .or_else(|| tombstones.newest_by_name(identifier, now)) + } + /// Write bytes to a session's PTY. pub async fn write_to_session(&self, id: &SessionId, data: &[u8]) -> Result<(), ApiError> { let session = self.session(id).await?; @@ -742,11 +865,16 @@ impl SessionManager { for (id, session, output_complete) in finalizing_sessions { session.finish_pump(output_complete).await; + let tombstone = session + .final_tombstone(manager.next_snapshot_id(), output_complete, false) + .await; + let mut tombstones = manager.tombstones.lock().await; let mut sessions = manager.sessions.write().await; let is_same_session = sessions .get(&id) .is_some_and(|current| Arc::ptr_eq(current, &session)); if is_same_session { + tombstones.insert(tombstone, Instant::now()); sessions.remove(&id); info!( "Finalized session {} ({:?}), status: {}, output complete: {}", @@ -757,6 +885,12 @@ impl SessionManager { ); } } + + manager + .tombstones + .lock() + .await + .purge_expired(Instant::now()); } }); } @@ -1276,7 +1410,7 @@ mod tests { vec![ "python3".to_string(), "-c".to_string(), - "import os,signal,time; signal.signal(signal.SIGHUP, signal.SIG_IGN); pid=os.fork(); os._exit(7) if pid else time.sleep(3)".to_string(), + "import os,signal,time; signal.signal(signal.SIGHUP, signal.SIG_IGN); pid=os.fork(); os._exit(7) if pid else (os.setsid(), os.write(1, b'descendant-open'), time.sleep(3))".to_string(), ], Some("inherited-pty".to_string()), None, @@ -1293,6 +1427,113 @@ mod tests { }) .await .expect("an exited session must not stay live while a descendant holds the PTY open"); + + match manager + .resolve_evidence(Some("inherited-pty")) + .await + .expect("resolve finalized evidence") + { + SessionEvidence::Exited(tombstone) => { + if cfg!(target_os = "linux") { + assert!(!tombstone.output_complete); + } + } + SessionEvidence::Live(_) => panic!("session should have finalized"), + } + } + + #[tokio::test] + async fn natural_exit_preserves_final_screen_output_and_status() { + let manager = Arc::new(SessionManager::new()); + manager + .create_session( + vec![ + "sh".to_string(), + "-c".to_string(), + "printf final-evidence; exit 7".to_string(), + ], + Some("natural-exit".to_string()), + None, + Some("/tmp".to_string()), + ) + .await + .expect("create exiting session"); + manager.spawn_cleaner(); + + let tombstone = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Ok(SessionEvidence::Exited(tombstone)) = + manager.resolve_evidence(Some("natural-exit")).await + { + break tombstone; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("session finalization"); + + assert_eq!(tombstone.exit.code, Some(7)); + assert!(!tombstone.exit.success); + assert!(!tombstone.exit.killed_by_client); + assert!(tombstone.output_complete); + assert_eq!(tombstone.cwd.as_deref(), Some("/tmp")); + assert!(tombstone + .final_screen + .text + .as_deref() + .is_some_and(|text| text.contains("final-evidence"))); + assert!(tombstone.output.bytes.ends_with(b"final-evidence")); + assert!(manager.is_empty().await); + } + + #[tokio::test] + async fn tombstones_do_not_count_as_live_sessions_or_prevent_name_reuse() { + let manager = SessionManager::new(); + let old_id = manager + .create_session( + vec!["cat".to_string()], + Some("reusable".to_string()), + None, + None, + ) + .await + .expect("create session"); + manager.kill_session(&old_id).await.expect("kill session"); + + assert!(manager.is_empty().await); + assert!(manager.list_sessions().await.is_empty()); + + let new_id = manager + .create_session( + vec!["cat".to_string()], + Some("reusable".to_string()), + None, + None, + ) + .await + .expect("reuse tombstoned name"); + + assert!(matches!( + manager + .resolve_evidence(Some("reusable")) + .await + .expect("live name wins"), + SessionEvidence::Live(id) if id == new_id + )); + match manager + .resolve_evidence(Some(&old_id.0)) + .await + .expect("resolve killed session") + { + SessionEvidence::Exited(tombstone) => { + assert!(tombstone.exit.killed_by_client); + assert!(tombstone.exit.code.is_some() || tombstone.exit.signal.is_some()); + assert_eq!(tombstone.id, old_id); + } + SessionEvidence::Live(_) => panic!("old session should be exited"), + } + manager.kill_session(&new_id).await.expect("remove session"); } #[tokio::test] diff --git a/crates/pilotty-cli/src/daemon/tombstone.rs b/crates/pilotty-cli/src/daemon/tombstone.rs new file mode 100644 index 0000000..be68e86 --- /dev/null +++ b/crates/pilotty-cli/src/daemon/tombstone.rs @@ -0,0 +1,195 @@ +//! Bounded, short-lived evidence for finalized sessions. + +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use pilotty_core::snapshot::ScreenState; + +use crate::daemon::retention::RetentionSnapshot; +use crate::daemon::session::SessionId; + +pub(crate) const TOMBSTONE_CAPACITY: usize = 100; +pub(crate) const TOMBSTONE_TTL: Duration = Duration::from_secs(10 * 60); +pub(crate) const TOMBSTONE_OUTPUT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExitMetadata { + pub(crate) code: Option, + pub(crate) signal: Option, + pub(crate) success: bool, + pub(crate) killed_by_client: bool, +} + +impl ExitMetadata { + pub(crate) fn description(&self) -> String { + if self.killed_by_client { + return "killed by client".to_string(); + } + if let Some(signal) = &self.signal { + return format!("signal {signal}"); + } + self.code + .map(|code| format!("exit code {code}")) + .unwrap_or_else(|| "exit status unavailable".to_string()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Tombstone { + pub(crate) id: SessionId, + pub(crate) name: Option, + pub(crate) command: Vec, + pub(crate) cwd: Option, + pub(crate) created_at: DateTime, + pub(crate) ended_at: DateTime, + pub(crate) ended_at_monotonic: Instant, + pub(crate) exit: ExitMetadata, + pub(crate) output_complete: bool, + pub(crate) final_screen: ScreenState, + pub(crate) output: RetentionSnapshot, +} + +impl Tombstone { + pub(crate) fn ended_at_monotonic(&self) -> Instant { + self.ended_at_monotonic + } +} + +pub(crate) struct TombstoneStore { + entries: HashMap, + insertion_order: VecDeque, + capacity: usize, + ttl: Duration, +} + +impl TombstoneStore { + pub(crate) fn new(capacity: usize, ttl: Duration) -> Self { + Self { + entries: HashMap::new(), + insertion_order: VecDeque::new(), + capacity, + ttl, + } + } + + pub(crate) fn insert(&mut self, tombstone: Tombstone, now: Instant) { + self.purge_expired(now); + if self.entries.remove(&tombstone.id).is_some() { + self.insertion_order.retain(|id| id != &tombstone.id); + } + while self.entries.len() >= self.capacity && self.capacity > 0 { + let Some(oldest) = self.insertion_order.pop_front() else { + break; + }; + self.entries.remove(&oldest); + } + if self.capacity == 0 { + return; + } + self.insertion_order.push_back(tombstone.id.clone()); + self.entries.insert(tombstone.id.clone(), tombstone); + } + + pub(crate) fn get(&mut self, id: &SessionId, now: Instant) -> Option { + self.purge_expired(now); + self.entries.get(id).cloned() + } + + pub(crate) fn newest_by_name(&mut self, name: &str, now: Instant) -> Option { + self.purge_expired(now); + self.insertion_order.iter().rev().find_map(|id| { + self.entries + .get(id) + .filter(|item| item.name.as_deref() == Some(name)) + .cloned() + }) + } + + pub(crate) fn purge_expired(&mut self, now: Instant) { + self.insertion_order.retain(|id| { + let retain = self.entries.get(id).is_some_and(|item| { + now.saturating_duration_since(item.ended_at_monotonic()) < self.ttl + }); + if !retain { + self.entries.remove(id); + } + retain + }); + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use chrono::Utc; + use pilotty_core::snapshot::ScreenState; + + use crate::daemon::retention::RetentionSnapshot; + use crate::daemon::session::SessionId; + use crate::daemon::tombstone::{ExitMetadata, Tombstone, TombstoneStore}; + + fn tombstone(id: &str, name: &str, ended_at: Instant) -> Tombstone { + Tombstone { + id: SessionId::from(id), + name: Some(name.to_string()), + command: vec!["sh".to_string()], + cwd: None, + created_at: Utc::now(), + ended_at: Utc::now(), + ended_at_monotonic: ended_at, + exit: ExitMetadata { + code: Some(0), + signal: None, + success: true, + killed_by_client: false, + }, + output_complete: true, + final_screen: ScreenState::empty(80, 24), + output: RetentionSnapshot { + bytes: vec![], + total_bytes: 0, + retained_bytes: 0, + dropped_bytes: 0, + truncated: false, + }, + } + } + + #[test] + fn capacity_evicts_the_oldest_tombstone() { + let start = Instant::now(); + let mut store = TombstoneStore::new(2, Duration::from_secs(60)); + store.insert(tombstone("one", "same", start), start); + store.insert(tombstone("two", "same", start), start); + store.insert(tombstone("three", "same", start), start); + + assert!(store.get(&SessionId::from("one"), start).is_none()); + assert!(store.get(&SessionId::from("two"), start).is_some()); + assert_eq!( + store.newest_by_name("same", start).map(|item| item.id), + Some(SessionId::from("three")) + ); + } + + #[test] + fn ttl_expires_tombstones() { + let start = Instant::now(); + let mut store = TombstoneStore::new(100, Duration::from_secs(10)); + store.insert(tombstone("one", "expired", start), start); + + assert!(store + .get(&SessionId::from("one"), start + Duration::from_secs(9)) + .is_some()); + assert!(store + .get(&SessionId::from("one"), start + Duration::from_secs(10)) + .is_none()); + assert_eq!(store.len(), 0); + } +} diff --git a/crates/pilotty-core/src/error.rs b/crates/pilotty-core/src/error.rs index 9e5113f..11a8ef7 100644 --- a/crates/pilotty-core/src/error.rs +++ b/crates/pilotty-core/src/error.rs @@ -8,6 +8,7 @@ use std::fmt; #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ErrorCode { SessionNotFound, + SessionExited, CommandFailed, InvalidInput, InternalError, @@ -17,6 +18,7 @@ impl fmt::Display for ErrorCode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ErrorCode::SessionNotFound => write!(f, "SESSION_NOT_FOUND"), + ErrorCode::SessionExited => write!(f, "SESSION_EXITED"), ErrorCode::CommandFailed => write!(f, "COMMAND_FAILED"), ErrorCode::InvalidInput => write!(f, "INVALID_INPUT"), ErrorCode::InternalError => write!(f, "INTERNAL_ERROR"), @@ -49,7 +51,21 @@ impl ApiError { Self { code: ErrorCode::SessionNotFound, message: format!("Session '{}' not found", session_id), - suggestion: Some("Run 'pilotty list-sessions' to see available sessions".into()), + suggestion: Some( + "The session is unknown, expired, or the daemon restarted since it ended. Run 'pilotty list-sessions' to see live sessions." + .into(), + ), + } + } + + pub fn session_exited(session_id: &str, status: &str) -> Self { + Self { + code: ErrorCode::SessionExited, + message: format!("Session '{}' exited ({})", session_id, status), + suggestion: Some( + "Run 'pilotty snapshot' or 'pilotty logs' for final evidence. 'pilotty status' reports exit metadata when available." + .into(), + ), } } @@ -344,4 +360,27 @@ mod tests { suggestion.contains("pilotty stop") && suggestion.contains("retry") })); } + + #[test] + fn session_exited_error_is_versioned_and_actionable() { + let err = ApiError::session_exited("editor", "exit code 7"); + + assert_eq!(err.code, ErrorCode::SessionExited); + assert_eq!(err.minimum_protocol(), crate::protocol::PROTOCOL_VERSION); + assert!(err.message.contains("editor")); + assert!(err.message.contains("exit code 7")); + assert!(err + .suggestion + .as_deref() + .is_some_and(|suggestion| suggestion.contains("pilotty status"))); + } + + #[test] + fn session_not_found_describes_expired_or_unknown_state() { + let err = ApiError::session_not_found("editor"); + + assert!(err.suggestion.as_deref().is_some_and(|suggestion| { + suggestion.contains("expired") && suggestion.contains("daemon restarted") + })); + } } diff --git a/crates/pilotty-core/src/protocol.rs b/crates/pilotty-core/src/protocol.rs index c4a87ad..16c6316 100644 --- a/crates/pilotty-core/src/protocol.rs +++ b/crates/pilotty-core/src/protocol.rs @@ -39,6 +39,7 @@ impl ApiError { | ErrorCode::CommandFailed | ErrorCode::InvalidInput | ErrorCode::InternalError => LEGACY_PROTOCOL_VERSION, + ErrorCode::SessionExited => PROTOCOL_VERSION, } } }