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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/pilotty-cli/src/daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ pub mod retention;
pub mod server;
pub mod session;
pub mod terminal;
pub mod tombstone;
30 changes: 30 additions & 0 deletions crates/pilotty-cli/src/daemon/retention.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
169 changes: 161 additions & 8 deletions crates/pilotty-cli/src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -716,12 +718,16 @@ async fn handle_logs(
sessions: &SessionManager,
session: Option<String>,
) -> 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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading