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
2 changes: 1 addition & 1 deletion completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ _pty() {
fi
;;
gc)
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --keep-max-age --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
;;
tag)
if [[ "${cur}" == -* ]]; then
Expand Down
1 change: 1 addition & 0 deletions completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ complete -c pty -n '__pty_using_command recover' -l snapshot -d 'Captured capabi
complete -c pty -n '__pty_using_command rm remove' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command gc' -l dry-run -s n -d 'Preview without changing anything'
complete -c pty -n '__pty_using_command gc' -l idle-days -d 'Reap permanents with no attach in N days'
complete -c pty -n '__pty_using_command gc' -l keep-max-age -d 'Keep-tag retention for dead sessions (default 7d; 0 = now)'
complete -c pty -n '__pty_using_command gc' -l fast-fail-window -d 'Fast-fail window (seconds; default 60)'
complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -d 'Consecutive fast fails before flapping (default 3)'
complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc'
Expand Down
1 change: 1 addition & 0 deletions completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _pty() {
_arguments \
'(n --dry-run){n,--dry-run}[Preview without changing anything]' \
'--idle-days[Reap permanents with no attach in N days]' \
'--keep-max-age[Keep-tag retention for dead sessions (default 7d; 0 = now)]' \
'--fast-fail-window[Fast-fail window (seconds; default 60)]' \
'--fast-fail-limit[Consecutive fast fails before flapping (default 3)]' \
'--print-launchd-plist[Emit a launchd plist that runs pty gc]' \
Expand Down
1 change: 1 addition & 0 deletions crates/pty-conformance/src/conformance_map_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const SUITES: &[(&str, Kind, &str)] = &[
("gc-flap-clear-badge-root-len", Kind::Cli, "root-length half in pty_root.rs; badge and restart bookkeeping-strip halves in gc_badge.rs; the flapping machinery itself is dropped in docs/parity.md §12"),
("gc-flapping", Kind::NotPortable, "dropped in docs/parity.md §12 (gc flapping classifier)"),
("gc-generation-guard", Kind::NotPortable, "dropped in docs/parity.md §12 (gc permanent respawn)"),
("gc-keep-expiry", Kind::Cli, "the keep-tag retention window in the sweep (Node PR #173)"),
("gc-parent-child", Kind::Cli, "the library reapSkipped case (:92) is left out"),
("gc-permanent", Kind::NotPortable, "dropped in docs/parity.md §12 (gc permanent respawn)"),
("gc", Kind::Cli, "debris, orphan tag prune, dry-run, launchd plist (with the plist half of pty-root.test.ts)"),
Expand Down
224 changes: 224 additions & 0 deletions crates/pty-conformance/tests/gc_keep_expiry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//! Port of tests/gc-keep-expiry.test.ts: `keep=true` buys a DEAD session a
//! bounded retention window against `pty gc`, not immortality. Agents tag a
//! session they are debugging right now and never come back to untag it, so
//! an unbounded exemption turns the registry into an append-only log.
//!
//! The policy pinned here: exempt while the session has been dead for less
//! than `--keep-max-age` (default 7d), swept and reported separately once
//! past it, `0` sweeps the whole backlog, and a RUNNING keep session is never
//! a candidate no matter what the flag says.
//!
//! Records are written straight into the root rather than spawned: the policy
//! is a comparison against `exitedAt`/`createdAt`, so a fabricated record is
//! both exact about age and free of daemon-startup waits.

use pty_conformance::*;

const DAY: i64 = 24 * 60 * 60;

/// An exited session, dead for `dead_for_secs`, tagged `keep=true`.
fn write_exited_keep(rig: &Rig, name: &str, dead_for_secs: i64) {
write_fake_metadata(
rig.root(),
name,
FakeMeta::created(-dead_for_secs)
.exited(-dead_for_secs, 0)
.tag("keep", "true"),
);
}

/// node: tests/gc-keep-expiry.test.ts:101
#[test]
fn keeps_an_exited_keep_session_younger_than_the_default_window() {
let rig = Rig::new();
let name = unique_id("gke");
write_exited_keep(&rig, &name, 2 * DAY);

let out = rig.pty(&["gc"]);
expect_status(&out, 0);
let stdout = out.stdout();
expect_contains(&stdout, &format!("Kept (keep tag): {name}"));
expect_not_contains(&stdout, "keep expired");
assert!(rig.meta_path(&name).exists());
}

/// node: tests/gc-keep-expiry.test.ts:113
#[test]
fn sweeps_an_expired_keep_session_apart_from_the_plain_sweep() {
let rig = Rig::new();
let expired = unique_id("gke");
let stale = unique_id("gke");
write_exited_keep(&rig, &expired, 30 * DAY);
// A same-age session WITHOUT the tag: proves the two buckets stay
// distinct rather than one absorbing the other.
write_fake_metadata(
rig.root(),
&stale,
FakeMeta::created(-30 * DAY).exited(-30 * DAY, 0),
);

let out = rig.pty(&["gc"]);
expect_status(&out, 0);
let stdout = out.stdout();
expect_contains(
&stdout,
&format!("Removed (keep expired after 7d): {expired}"),
);
expect_contains(&stdout, &format!("Removed: {stale}"));
expect_contains(&stdout, "1 stale session");
expect_contains(&stdout, "1 keep-expired session");
assert!(!rig.meta_path(&expired).exists());
assert!(!rig.meta_path(&stale).exists());
}

/// node: tests/gc-keep-expiry.test.ts:132
#[test]
fn honours_a_custom_window_in_both_flag_spellings() {
let rig = Rig::new();
let spaced = unique_id("gke");
let equals = unique_id("gke");
write_exited_keep(&rig, &spaced, 2 * 3600);
write_exited_keep(&rig, &equals, 2 * 3600);

// 3h window: both sessions are 2h dead, so both survive.
let kept = rig.pty(&["gc", "--keep-max-age", "3h"]);
expect_status(&kept, 0);
let stdout = kept.stdout();
expect_contains(&stdout, &format!("Kept (keep tag): {spaced}"));
expect_contains(&stdout, &format!("Kept (keep tag): {equals}"));
assert!(rig.meta_path(&spaced).exists());

// 1h window: both are past it.
let swept = rig.pty(&["gc", "--keep-max-age=1h"]);
expect_status(&swept, 0);
let stdout = swept.stdout();
expect_contains(
&stdout,
&format!("Removed (keep expired after 1h): {spaced}"),
);
expect_contains(
&stdout,
&format!("Removed (keep expired after 1h): {equals}"),
);
assert!(!rig.meta_path(&spaced).exists());
assert!(!rig.meta_path(&equals).exists());
}

/// node: tests/gc-keep-expiry.test.ts:155
#[test]
fn a_zero_window_sweeps_a_keep_session_that_just_exited() {
let rig = Rig::new();
let name = unique_id("gke");
write_exited_keep(&rig, &name, 0);

let out = rig.pty(&["gc", "--keep-max-age", "0"]);
expect_status(&out, 0);
expect_contains(
&out.stdout(),
&format!("Removed (keep expired after 0s): {name}"),
);
assert!(!rig.meta_path(&name).exists());
}

/// node: tests/gc-keep-expiry.test.ts:166
#[test]
fn anchors_on_created_at_when_there_is_no_exit_record() {
let rig = Rig::new();
let name = unique_id("gke");
// A vanished session (SIGKILLed daemon) never wrote `exitedAt`, so its
// age comes from `createdAt` — the same anchor precedence `pty list
// --older-than` uses.
write_fake_metadata(
rig.root(),
&name,
FakeMeta::created(-30 * DAY).tag("keep", "true"),
);

let out = rig.pty(&["gc"]);
expect_status(&out, 0);
expect_contains(
&out.stdout(),
&format!("Removed (keep expired after 7d): {name}"),
);
assert!(!rig.meta_path(&name).exists());
}

/// node: tests/gc-keep-expiry.test.ts:188
#[test]
fn never_sweeps_a_running_keep_session_even_at_zero() {
let rig = Rig::new();
let name = unique_id("gke");
// The test process itself stands in for a live daemon (the same device
// list_filters.rs uses): an alive pid with no exit record reads as
// status=running. Aged well past the window, so the only thing keeping
// it out of the sweep is that it is still running.
std::fs::write(rig.pid_path(&name), std::process::id().to_string()).unwrap();
write_fake_metadata(
rig.root(),
&name,
FakeMeta::created(-30 * DAY).tag("keep", "true"),
);
assert_eq!(rig.list_entry(&name).expect("listed")["status"], "running");

let out = rig.pty(&["gc", "--keep-max-age", "0"]);
expect_status(&out, 0);
expect_not_contains(&out.stdout(), &name);
assert!(rig.meta_path(&name).exists());
assert_eq!(rig.list_entry(&name).expect("listed")["status"], "running");
}

/// node: tests/gc-keep-expiry.test.ts:214
#[test]
fn dry_run_previews_keep_expiry_without_removing_anything() {
let rig = Rig::new();
let name = unique_id("gke");
write_exited_keep(&rig, &name, 30 * DAY);

let dry = rig.pty(&["gc", "--dry-run"]);
expect_status(&dry, 0);
let stdout = dry.stdout();
expect_contains(
&stdout,
&format!("Would remove (keep expired after 7d): {name}"),
);
expect_contains(&stdout, "1 keep-expired session");
expect_contains(&stdout, "Dry run");
assert!(rig.meta_path(&name).exists());

// A zero-window dry run is equally non-mutating.
let dry_zero = rig.pty(&["gc", "-n", "--keep-max-age", "0"]);
expect_status(&dry_zero, 0);
expect_contains(
&dry_zero.stdout(),
&format!("Would remove (keep expired after 0s): {name}"),
);
assert!(rig.meta_path(&name).exists());

// And the real pass then actually removes it.
let real = rig.pty(&["gc"]);
expect_status(&real, 0);
expect_contains(
&real.stdout(),
&format!("Removed (keep expired after 7d): {name}"),
);
assert!(!rig.meta_path(&name).exists());
}

/// node: tests/gc-keep-expiry.test.ts:239
#[test]
fn rejects_a_unit_less_non_zero_window() {
let rig = Rig::new();
let bare = rig.pty(&["gc", "--keep-max-age", "7"]);
expect_failure(&bare);
expect_contains(
&bare.stderr(),
"--keep-max-age expects a duration like 12h, 7d, or 0",
);

let junk = rig.pty(&["gc", "--keep-max-age=soon"]);
expect_failure(&junk);
expect_contains(
&junk.stderr(),
"--keep-max-age expects a duration like 12h, 7d, or 0",
);
}
6 changes: 3 additions & 3 deletions crates/pty-core/src/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ pub use root::{
metadata_path, pid_path, recovery_revision_path, root_length_check, session_dir, socket_path,
};
pub use tags::{
EXACT_RESERVED_TAG_KEYS, GC_BOOKKEEPING_KEYS, KEEP_FALSEY, KEEP_TAG, extract_filter_tags,
is_keep_requested, is_reserved_tag_key, matches_all_tags, reap_on_exit_default,
should_reap_at_exit, strip_gc_bookkeeping,
DEFAULT_KEEP_MAX_AGE_MS, EXACT_RESERVED_TAG_KEYS, GC_BOOKKEEPING_KEYS, KEEP_FALSEY, KEEP_TAG,
extract_filter_tags, is_keep_expired, is_keep_requested, is_reserved_tag_key, matches_all_tags,
reap_on_exit_default, should_reap_at_exit, strip_gc_bookkeeping,
};
pub use time::{
iso8601, iso8601_from_epoch_ms, local_hms, now_epoch_ms, now_iso8601, parse_iso8601_ms,
Expand Down
50 changes: 47 additions & 3 deletions crates/pty-core/src/registry/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
//! `--filter-tag` matching, the `keep` tag, exit-time reap precedence, and
//! the gc bookkeeping keys a manual restart strips.
//!
//! node: src/tags.ts; src/sessions.ts:1020-1097; src/cli.ts:4081-4100
//! node: src/tags.ts; src/sessions.ts:1020-1109; src/cli.ts:4081-4100

use super::metadata::TagMap;
use super::metadata::{SessionMetadata, TagMap};
use super::time::parse_iso8601_ms;

/// Keys pty itself treats as bookkeeping and hides from the default
/// listing (`pty list --tags` shows them).
Expand Down Expand Up @@ -54,7 +55,9 @@ pub fn extract_filter_tags(args: &mut Vec<String>) -> Result<TagMap, String> {
Ok(tags)
}

/// Tag key that exempts a session from every form of dead-session reaping.
/// Tag key that exempts a session from the daemon's exit-time self-reap
/// unconditionally, and from `pty gc`'s sweep for a bounded window
/// ([`DEFAULT_KEEP_MAX_AGE_MS`]).
///
/// node: src/sessions.ts:1020
pub const KEEP_TAG: &str = "keep";
Expand All @@ -79,6 +82,47 @@ pub fn is_keep_requested(tags: Option<&TagMap>) -> bool {
}
}

/// How long `keep` holds a dead session against `pty gc`'s sweep, unless
/// the operator overrides it with `pty gc --keep-max-age <dur>`. Seven days
/// is long enough that "I killed it Friday, I'll look Monday" still works,
/// and short enough that a fleet of agents tagging every session cannot
/// grow the registry without bound.
///
/// node: src/sessions.ts:1084 (`DEFAULT_KEEP_MAX_AGE_MS`)
pub const DEFAULT_KEEP_MAX_AGE_MS: i64 = 7 * 24 * 60 * 60 * 1000;

/// Has a dead `keep`-tagged session outlived its retention window?
///
/// Age is anchored on `exitedAt` when the daemon wrote an exit record, else
/// `createdAt` (a `vanished` session never wrote one) — the same anchor
/// precedence `pty list --older-than` uses. Metadata carrying neither, or an
/// unparseable timestamp, has no age and therefore never expires: retaining
/// an unaged record is the recoverable failure, deleting it is not.
///
/// `max_age_ms <= 0` expires everything, including unaged records — that is
/// the explicit "sweep the keep backlog now" request, not an inference from
/// a timestamp. Callers must apply this to dead sessions only; a running
/// session is never a sweep candidate regardless of its age.
///
/// node: src/sessions.ts:1098-1109 (`isKeepExpired`)
pub fn is_keep_expired(metadata: Option<&SessionMetadata>, now_ms: i64, max_age_ms: i64) -> bool {
if max_age_ms <= 0 {
return true;
}
let Some(meta) = metadata else {
return false;
};
let anchor = meta
.exited_at
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(meta.created_at.as_str());
match parse_iso8601_ms(anchor) {
Some(ts) => now_ms - ts >= max_age_ms,
None => false,
}
}

/// The config default for exit-time reaping: `PTY_REAP_ON_EXIT` unset →
/// reap; `false|0|no|off` → preserve; anything else → reap.
///
Expand Down
Loading