use std::path::Path;
use super::*;
use crate::{
protocol::{ClipboardKind, Key, Mods},
testutil::{
Scratch, here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until,
write_executable, write_rollout,
},
};
fn sup(rows: u16, cols: u16) -> Supervisor {
let mut s = Supervisor::new(rows, cols, 2000);
s.set_launch_context(LaunchContext::here());
s
}
fn sup_ctx(ctx: LaunchContext) -> Supervisor {
let mut s = Supervisor::new(24, 80, 2000);
s.set_launch_context(ctx);
s
}
fn spawn(s: &mut Supervisor, cmd: impl Into<String>, cwd: PathBuf) {
s.apply(Command::Spawn {
command: cmd.into(),
cwd,
group: None,
});
}
fn spawn_grouped(s: &mut Supervisor, cmd: impl Into<String>, cwd: PathBuf, group: &str) {
s.apply(Command::Spawn {
command: cmd.into(),
cwd,
group: Some(group.into()),
});
}
#[test]
fn scrollback_resolution_precedence_clamp_and_fallback() {
assert_eq!(effective_scrollback(None, None), 2000);
assert_eq!(effective_scrollback(None, Some("500")), 500);
assert_eq!(effective_scrollback(None, Some("0")), 0);
assert_eq!(effective_scrollback(None, Some("999999999")), 100_000);
assert_eq!(effective_scrollback(None, Some("garbage")), 2000);
assert_eq!(effective_scrollback(None, Some("-5")), 2000);
assert_eq!(effective_scrollback(Some(5000), Some("500")), 5000);
assert_eq!(effective_scrollback(Some(999_999_999), None), 100_000);
assert_eq!(effective_scrollback(Some(0), Some("500")), 0);
}
#[test]
fn session_config_groups_by_dir_in_spawn_order() {
let mut s = sup(24, 80);
spawn(&mut s, "a", here());
spawn(&mut s, "b", PathBuf::from("/tmp"));
spawn(&mut s, "c", here());
let cfg = s.session_config();
assert_eq!(
cfg[&path::abbreviate(&here())],
vec![
SessionEntry {
cmd: "a".into(),
group: None,
name: None,
},
SessionEntry {
cmd: "c".into(),
group: None,
name: None,
},
]
);
assert_eq!(
cfg["/tmp"],
vec![SessionEntry {
cmd: "b".into(),
group: None,
name: None,
}]
);
}
#[test]
fn tick_emits_snapshot_and_watched_screen() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
s.tick();
let evs = s.drain();
assert_eq!(evs.len(), 1, "only a Tasks snapshot while unwatched");
let id = match &evs[0] {
Event::Tasks(v) => {
assert_eq!(v.len(), 1);
v[0].id
}
_ => panic!("expected a Tasks snapshot"),
};
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
s.tick();
let evs = s.drain();
assert!(evs.iter().any(|e| matches!(e, Event::Tasks(_))));
assert!(
evs.iter()
.any(|e| matches!(e, Event::Screen(sv) if sv.id == id)),
"watching a task should stream its Screen"
);
}
#[test]
fn watched_screen_not_resent_when_unchanged() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let mut id = 0;
for _ in 0..5 {
s.tick();
for e in s.drain() {
if let Event::Tasks(v) = e
&& let Some(t) = v.first()
{
id = t.id;
}
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(id != 0, "task never appeared");
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
s.tick();
assert!(
s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"first watched tick sends a full screen"
);
s.tick();
assert!(
!s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"unchanged screen must not be resent"
);
}
#[test]
fn decset_1007_flip_resends_watched_screen() {
let dir = scratch("flip_1007");
let ready = dir.join("ready");
let flag = dir.join("flag");
let mut s = sup(24, 80);
let cmd = format!(
"printf '\\033[?1049h'; touch {r}; until [ -e {f} ]; do sleep 0.05; done; \
printf '\\033[?1007l'; sleep 30",
r = ready.display(),
f = flag.display()
);
let id = spawn_ready(&mut s, cmd, here(), &ready);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
let open = wait_until(Duration::from_secs(5), || {
s.tick();
s.drain()
.iter()
.any(|e| matches!(e, Event::Screen(sv) if sv.alt_screen && sv.alt_scroll))
});
assert!(open, "the gate-open screen never arrived");
std::fs::write(&flag, b"").unwrap();
let closed = wait_until(Duration::from_secs(5), || {
s.tick();
s.drain()
.iter()
.any(|e| matches!(e, Event::Screen(sv) if sv.alt_screen && !sv.alt_scroll))
});
assert!(closed, "the ?1007l flip never re-sent the screen");
}
#[test]
fn tick_flushes_a_stalled_sync_update() {
let mut s = sup(24, 80);
spawn(
&mut s,
"printf 'begin\\033[?2026hstalled'; sleep 30",
here(),
);
let mut preview = String::new();
wait_until(Duration::from_secs(5), || {
s.tick();
for e in s.drain() {
if let Event::Tasks(v) = e
&& let Some(t) = v.first()
{
preview = t.preview.text.clone();
}
}
preview.contains("stalled")
});
assert!(
preview.contains("stalled"),
"the stalled sync frame never flushed; preview: {preview:?}"
);
}
#[test]
fn watched_task_clipboard_stores_are_forwarded() {
let dir = scratch("clip_fwd");
let ready = dir.join("ready");
let flag = dir.join("flag");
let mut s = sup(24, 80);
let cmd = format!(
"touch {r}; until [ -e {f} ]; do sleep 0.05; done; \
printf '\\033]52;c;aGVsbG8=\\007\\033]52;p;cHJp\\007\\033]52;s;d29ybGQ=\\007'; sleep 30",
r = ready.display(),
f = flag.display()
);
let id = spawn_ready(&mut s, cmd, here(), &ready);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
std::fs::write(&flag, b"").unwrap();
let mut copies = Vec::new();
let ok = wait_until(Duration::from_secs(5), || {
s.tick();
copies.extend(s.drain().into_iter().filter_map(|e| match e {
Event::ClipboardCopy { id, kind, text } => Some((id, kind, text)),
_ => None,
}));
copies.len() >= 3
});
assert!(ok, "the clipboard stores never arrived; got {copies:?}");
assert_eq!(
copies,
vec![
(id, ClipboardKind::Clipboard, "hello".to_string()),
(id, ClipboardKind::Primary, "pri".to_string()),
(id, ClipboardKind::Selection, "world".to_string()),
]
);
}
#[test]
fn watch_purges_stores_captured_before_the_watch() {
let mut s = sup(24, 80);
spawn(
&mut s,
"printf '\\033]52;c;c3RhbGU=\\007MARKER'; sleep 30",
here(),
);
let parsed = wait_until(Duration::from_secs(5), || {
s.tasks.first().is_some_and(|t| {
let (formatted, _, _) = t.formatted();
String::from_utf8_lossy(&formatted).contains("MARKER")
})
});
assert!(parsed, "the marker never reached the grid");
let id = s.tasks[0].id;
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
let mut saw_screen = false;
for _ in 0..3 {
s.tick();
for e in s.drain() {
match e {
Event::ClipboardCopy { .. } => {
panic!("a store captured before the watch must not fire after it")
}
Event::Screen(_) => saw_screen = true,
_ => {}
}
}
}
assert!(saw_screen, "watching the task should stream its screen");
}
#[test]
fn backgrounded_clipboard_store_is_discarded_not_deferred() {
let mut s = sup(24, 80);
spawn(
&mut s,
"printf '\\033]52;c;c3RhbGU=\\007COPIED'; sleep 30",
here(),
);
let mut id = 0;
let parsed = wait_until(Duration::from_secs(5), || {
s.tick();
let mut seen = false;
for e in s.drain() {
match e {
Event::Tasks(v) => {
if let Some(t) = v.first() {
id = t.id;
seen = t.preview.text.contains("COPIED");
}
}
Event::ClipboardCopy { .. } => {
panic!("an unwatched task's store must not be forwarded")
}
_ => {}
}
}
seen
});
assert!(parsed, "the marker never reached the grid");
s.tick();
let _ = s.drain();
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
let mut saw_screen = false;
for _ in 0..3 {
s.tick();
for e in s.drain() {
match e {
Event::ClipboardCopy { .. } => {
panic!("a store buffered while backgrounded must never fire on watch")
}
Event::Screen(_) => saw_screen = true,
_ => {}
}
}
}
assert!(saw_screen, "watching the task should stream its screen");
}
#[test]
fn peeked_stores_never_forward_and_die_at_the_attach_transition() {
let dir = scratch("clip_peek");
let ready = dir.join("ready");
let flag1 = dir.join("flag1");
let flag2 = dir.join("flag2");
let flag3 = dir.join("flag3");
let mut s = sup(24, 80);
let cmd = format!(
"touch {r}; until [ -e {f1} ]; do sleep 0.05; done; \
printf '\\033]52;c;cGVlazE=\\007M1'; \
until [ -e {f2} ]; do sleep 0.05; done; \
printf '\\033]52;c;cGVlazI=\\007M2'; \
until [ -e {f3} ]; do sleep 0.05; done; \
printf '\\033]52;c;cG9zdA==\\007M3'; sleep 30",
r = ready.display(),
f1 = flag1.display(),
f2 = flag2.display(),
f3 = flag3.display()
);
let id = spawn_ready(&mut s, cmd, here(), &ready);
s.apply(Command::Watch {
id: Some(id),
attached: false,
});
std::fs::write(&flag1, b"").unwrap();
let parsed = wait_until(Duration::from_secs(5), || {
s.tasks.first().is_some_and(|t| {
let (formatted, _, _) = t.formatted();
String::from_utf8_lossy(&formatted).contains("M1")
})
});
assert!(parsed, "the first marker never reached the grid");
let mut saw_screen = false;
for _ in 0..3 {
s.tick();
for e in s.drain() {
match e {
Event::ClipboardCopy { .. } => {
panic!("a peeked task's store must never forward")
}
Event::Screen(_) => saw_screen = true,
_ => {}
}
}
}
assert!(
saw_screen,
"peeking the task should still stream its screen"
);
std::fs::write(&flag2, b"").unwrap();
let parsed = wait_until(Duration::from_secs(5), || {
s.tasks.first().is_some_and(|t| {
let (formatted, _, _) = t.formatted();
String::from_utf8_lossy(&formatted).contains("M2")
})
});
assert!(parsed, "the second marker never reached the grid");
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
for _ in 0..3 {
s.tick();
for e in s.drain() {
if let Event::ClipboardCopy { .. } = e {
panic!("a store captured during peek must not fire after attach")
}
}
}
std::fs::write(&flag3, b"").unwrap();
let mut copies = Vec::new();
let ok = wait_until(Duration::from_secs(5), || {
s.tick();
copies.extend(s.drain().into_iter().filter_map(|e| match e {
Event::ClipboardCopy { id, kind, text } => Some((id, kind, text)),
_ => None,
}));
!copies.is_empty()
});
assert!(ok, "the post-attach store never arrived");
assert_eq!(
copies,
vec![(id, ClipboardKind::Clipboard, "post".to_string())]
);
}
#[test]
fn oversized_watched_store_yields_notice_and_no_copy() {
let dir = scratch("clip_oversize");
let ready = dir.join("ready");
let flag = dir.join("flag");
let mut s = sup(24, 80);
let cmd = format!(
"touch {r}; until [ -e {f} ]; do sleep 0.05; done; \
printf '\\033]52;c;'; \
dd if=/dev/zero bs=1024 count=3072 2>/dev/null | base64 | tr -d '\\n'; \
printf '\\007'; sleep 30",
r = ready.display(),
f = flag.display()
);
let id = spawn_ready(&mut s, cmd, here(), &ready);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
std::fs::write(&flag, b"").unwrap();
let mut notice = None;
let ok = wait_until(Duration::from_secs(10), || {
s.tick();
for e in s.drain() {
match e {
Event::ClipboardCopy { .. } => {
panic!("an over-cap store must be dropped, not forwarded")
}
Event::Status(msg) => notice = Some(msg),
_ => {}
}
}
notice.is_some()
});
assert!(ok, "the oversized-store notice never arrived");
assert_eq!(
notice.as_deref(),
Some("clipboard copy dropped: 3 MiB exceeds the 1 MiB limit")
);
}
fn scratch(tag: &str) -> Scratch {
crate::testutil::temp(&format!("sup_{tag}"))
}
fn spawn_ready(s: &mut Supervisor, command: String, cwd: PathBuf, ready: &Path) -> u64 {
spawn(s, command, cwd);
assert!(
wait_until(Duration::from_secs(5), || ready.exists()),
"task never signalled ready"
);
first_id(s)
}
fn first_id(s: &mut Supervisor) -> u64 {
s.tick();
match s.drain().first() {
Some(Event::Tasks(v)) => v[0].id,
_ => panic!("expected a Tasks snapshot"),
}
}
fn view_of(s: &mut Supervisor, id: u64) -> TaskView {
s.tick();
for e in s.drain() {
if let Event::Tasks(v) = e
&& let Some(t) = v.iter().find(|t| t.id == id)
{
return t.clone();
}
}
panic!("task {id} missing from the snapshot");
}
fn config_ctx(config: &Path, cwd: PathBuf, extra: &[(&str, &str)]) -> LaunchContext {
let mut env: Vec<(std::ffi::OsString, std::ffi::OsString)> = vec![(
"FLEETCOM_CONFIG_DIR".into(),
config.as_os_str().to_os_string(),
)];
for (k, v) in extra {
env.push(((*k).into(), (*v).into()));
}
LaunchContext { env, cwd }
}
fn wait_for_lifecycle(
s: &mut Supervisor,
id: u64,
pred: impl Fn(crate::protocol::Lifecycle) -> bool,
) {
let ok = wait_until(Duration::from_secs(5), || {
s.tick();
s.drain().iter().any(|e| {
matches!(e, Event::Tasks(v)
if v.iter().any(|t| t.id == id && pred(t.lifecycle)))
})
});
assert!(ok, "task {id} never reached the expected lifecycle");
}
#[test]
fn kill_delivers_term_before_kill() {
use crate::protocol::Lifecycle;
let dir = scratch("term_first");
let (ready, trapped) = (dir.join("ready"), dir.join("trapped"));
let mut s = sup(24, 80);
let id = spawn_ready(
&mut s,
format!(
"trap 'echo t > {t}; exit 0' TERM; echo r > {r}; while :; do sleep 0.1; done",
t = trapped.display(),
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
s.apply(Command::Kill { id });
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
assert!(trapped.exists(), "the TERM trap never ran");
}
#[test]
fn term_ignoring_task_escalates_to_kill() {
use crate::protocol::Lifecycle;
let dir = scratch("escalate");
let ready = dir.join("ready");
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(150));
let id = spawn_ready(
&mut s,
format!(
"trap '' TERM; echo r > {r}; while :; do sleep 0.1; done",
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
s.apply(Command::Kill { id });
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Failed);
}
#[test]
fn shutdown_returns_early_when_tasks_respect_term() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 300", here());
let t0 = Instant::now();
s.apply(Command::Shutdown);
assert!(
t0.elapsed() < Duration::from_secs(1),
"shutdown waited the full grace for a TERM-respecting task"
);
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.is_empty()))
);
}
#[test]
fn shutdown_survives_a_child_that_never_reads_stdin() {
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(200));
spawn(&mut s, "sleep 300", here());
let id = first_id(&mut s);
s.apply(Command::Paste {
id,
bytes: b"x\n".repeat(1 << 19),
});
let t0 = Instant::now();
s.apply(Command::Shutdown);
assert!(
t0.elapsed() < Duration::from_secs(2),
"shutdown blocked behind a PTY write to a non-reading child"
);
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.is_empty()))
);
}
#[test]
fn overfull_writer_queue_refuses_message_with_notice() {
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(200));
spawn(&mut s, "sleep 300", here());
let id = first_id(&mut s);
let big = b"x\n".repeat(4 << 20);
s.apply(Command::Input {
id,
bytes: big.clone(),
});
s.apply(Command::Input {
id,
bytes: big.clone(),
});
s.apply(Command::Input { id, bytes: big });
let evs = s.drain();
assert!(
evs.iter().any(|e| matches!(e, Event::Status(m)
if m.contains(&format!("task {id}")) && m.contains("8 MiB"))),
"no refusal notice for the overflowing message; got {evs:?}"
);
let t0 = Instant::now();
s.apply(Command::Shutdown);
assert!(
t0.elapsed() < Duration::from_secs(2),
"supervisor wedged after a writer-queue refusal"
);
}
#[test]
fn shutdown_is_bounded_by_grace() {
let dir = scratch("shutdown_bound");
let ready = dir.join("ready");
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(200));
spawn_ready(
&mut s,
format!(
"trap '' TERM; echo r > {r}; while :; do sleep 0.1; done",
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
let t0 = Instant::now();
s.apply(Command::Shutdown);
let elapsed = t0.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"shutdown took {elapsed:?}: not bounded by the 200 ms grace"
);
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.is_empty()))
);
}
#[test]
fn clear_watch_stops_screen_stream_and_resets_dedup() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
s.tick();
assert!(
s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"watching should stream a Screen"
);
s.clear_watch();
s.tick();
assert!(
!s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"a disconnected client's watch must not keep streaming"
);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
s.tick();
assert!(
s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"re-watch after clear_watch must resend the full screen"
);
}
#[test]
fn clear_watch_snaps_the_watched_task_live() {
let mut s = sup(6, 80);
spawn(&mut s, "seq 1 200; sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
let scrolled = wait_until(Duration::from_secs(5), || {
s.tick();
let _ = s.drain();
s.apply(Command::Scrollback {
id,
action: ScrollAction::Up(3),
});
s.tasks[0].scroll_offset() > 0
});
assert!(scrolled, "the task never accrued scrollback");
s.clear_watch();
assert_eq!(
s.tasks[0].scroll_offset(),
0,
"disconnect must return the watched task's viewport to live"
);
}
#[test]
fn rerun_replaces_finished_task_in_place() {
use crate::protocol::Lifecycle;
let dir = scratch("rerun");
let marker = dir.join("marker");
let mut s = sup(24, 80);
spawn(
&mut s,
format!("echo run >> {}", marker.display()),
dir.to_path_buf(),
);
let id = first_id(&mut s);
s.apply(Command::Tag { id, on: true });
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
s.apply(Command::Restart { id });
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
let runs = std::fs::read_to_string(&marker).unwrap().lines().count();
assert_eq!(runs, 2, "rerun must re-execute the command");
s.tick();
let tagged = s
.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.iter().any(|t| t.id == id && t.tagged)));
assert!(tagged, "rerun must carry the tag over");
}
#[test]
fn group_names_normalize_at_the_boundary() {
let n = |s: &str| normalize_group(Some(s.to_string()));
assert_eq!(normalize_group(None), None);
assert_eq!(n("\x1b[31mapi\x07"), Some("[31mapi".into()));
assert_eq!(n(" backend "), Some("backend".into()));
assert_eq!(n(" \t \x1b \x7f \u{9b} "), None);
assert_eq!(n(""), None);
assert_eq!(n(&"\u{e9}".repeat(80)), Some("\u{e9}".repeat(64)));
assert_eq!(n(&format!(" {} ", "x".repeat(64))), Some("x".repeat(64)));
assert_eq!(n("Unassigned"), None);
assert_eq!(n(" Unassigned "), None);
assert_eq!(n("unassigned"), Some("unassigned".into()));
assert_eq!(n("UNASSIGNED"), Some("UNASSIGNED".into()));
assert_eq!(n("Api"), Some("Api".into()));
}
#[test]
fn display_names_normalize_at_the_boundary() {
let n = |s: &str| normalize_label(Some(s.to_string()));
assert_eq!(normalize_label(None), None);
assert_eq!(n("\x1b[31mapi\x07"), Some("[31mapi".into()));
assert_eq!(n(" backend "), Some("backend".into()));
assert_eq!(n(" \t \x1b \x7f \u{9b} "), None);
assert_eq!(n(""), None);
assert_eq!(n(&"\u{e9}".repeat(80)), Some("\u{e9}".repeat(64)));
assert_eq!(n(&format!(" {} ", "x".repeat(64))), Some("x".repeat(64)));
assert_eq!(n("Unassigned"), Some("Unassigned".into()));
}
#[test]
fn set_group_round_trips_and_clears() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::SetGroup {
id,
group: Some(" api ".into()),
});
assert_eq!(view_of(&mut s, id).group, Some("api".into()));
s.apply(Command::SetGroup { id, group: None });
assert_eq!(view_of(&mut s, id).group, None);
s.apply(Command::SetGroup {
id: 999,
group: Some("ghost".into()),
});
assert!(s.drain().is_empty(), "unknown-id SetGroup must stay silent");
assert_eq!(view_of(&mut s, id).group, None);
}
#[test]
fn set_name_round_trips_and_clears() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::SetName {
id,
name: Some(" api \x1b[2J ".into()),
});
assert_eq!(view_of(&mut s, id).name, Some("api [2J".into()));
s.apply(Command::SetName {
id,
name: Some("Unassigned".into()),
});
assert_eq!(view_of(&mut s, id).name, Some("Unassigned".into()));
s.apply(Command::SetName { id, name: None });
assert_eq!(view_of(&mut s, id).name, None);
s.apply(Command::SetName {
id: 999,
name: Some("ghost".into()),
});
assert!(s.drain().is_empty(), "unknown-id SetName must stay silent");
assert_eq!(view_of(&mut s, id).name, None);
}
#[test]
fn kill_with_an_unknown_id_leaves_the_live_task_alone() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let id = first_id(&mut s);
let before = view_of(&mut s, id).lifecycle;
s.apply(Command::Kill { id: 999 });
assert!(s.drain().is_empty(), "unknown-id Kill must stay silent");
assert!(
!s.tasks[0].overdue(Instant::now(), Duration::ZERO),
"unknown-id Kill must not signal the live task"
);
assert_eq!(view_of(&mut s, id).lifecycle, before);
}
#[test]
fn tag_with_an_unknown_id_leaves_the_live_task_alone() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::Tag { id, on: true });
assert!(view_of(&mut s, id).tagged);
s.apply(Command::Tag { id: 999, on: false });
assert!(s.drain().is_empty(), "unknown-id Tag must stay silent");
assert!(
view_of(&mut s, id).tagged,
"unknown-id Tag must not clear the live task's flag"
);
}
#[test]
fn scrollback_with_an_unknown_id_leaves_the_live_task_alone() {
let mut s = sup(6, 80);
spawn(&mut s, "seq 1 200; sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
let scrolled = wait_until(Duration::from_secs(5), || {
s.tick();
let _ = s.drain();
s.apply(Command::Scrollback {
id,
action: ScrollAction::Up(3),
});
s.tasks[0].scroll_offset() > 0
});
assert!(scrolled, "the task never accrued scrollback");
let offset = s.tasks[0].scroll_offset();
s.apply(Command::Scrollback {
id: 999,
action: ScrollAction::Live,
});
assert!(
s.drain().is_empty(),
"unknown-id Scrollback must stay silent"
);
assert_eq!(
s.tasks[0].scroll_offset(),
offset,
"unknown-id Scrollback must not snap the live task's viewport"
);
}
#[test]
fn spawn_carries_a_normalized_group_from_birth() {
let mut s = sup(24, 80);
spawn_grouped(&mut s, "sleep 30", here(), " ui\x1b[2J ");
s.tick();
match s.drain().first() {
Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("ui[2J")),
_ => panic!("expected a Tasks snapshot"),
}
}
#[test]
fn rerun_carries_the_group_over() {
use crate::protocol::Lifecycle;
let mut s = sup(24, 80);
spawn_grouped(&mut s, "true", here(), "infra");
let id = first_id(&mut s);
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
s.apply(Command::Restart { id });
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
s.tick();
let carried = s.drain().iter().any(|e| {
matches!(e, Event::Tasks(v)
if v.iter().any(|t| t.id == id && t.group.as_deref() == Some("infra")))
});
assert!(carried, "rerun must carry the group over");
}
#[test]
fn rerun_carries_the_name_over() {
use crate::protocol::Lifecycle;
let mut s = sup(24, 80);
spawn(&mut s, "true", here());
let id = first_id(&mut s);
s.apply(Command::SetName {
id,
name: Some("smoke".into()),
});
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
s.apply(Command::Restart { id });
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
s.tick();
let carried = s.drain().iter().any(|e| {
matches!(e, Event::Tasks(v)
if v.iter().any(|t| t.id == id && t.name.as_deref() == Some("smoke")))
});
assert!(carried, "rerun must carry the name over");
}
#[test]
fn rerun_refuses_running_task_and_unknown_id() {
use crate::protocol::Lifecycle;
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
let id = first_id(&mut s);
s.apply(Command::Restart { id });
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m.contains("still running"))),
"a running task must be refused"
);
s.tick();
let alive = s.drain().iter().any(|e| {
matches!(e, Event::Tasks(v) if v.iter().any(
|t| t.id == id && matches!(t.lifecycle, Lifecycle::Active | Lifecycle::Idle)
))
});
assert!(alive, "the refused task must keep running");
s.apply(Command::Restart { id: 999 });
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m.contains("no task"))),
);
}
#[test]
fn rerun_watched_task_resends_screen() {
use crate::protocol::Lifecycle;
let mut s = sup(24, 80);
spawn(&mut s, "true", here());
let id = first_id(&mut s);
wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok);
s.apply(Command::Watch {
id: Some(id),
attached: true,
});
s.tick();
assert!(
s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"first watched tick sends a full screen"
);
s.apply(Command::Restart { id });
s.tick();
assert!(
s.drain().iter().any(|e| matches!(e, Event::Screen(_))),
"rerun of the watched task must resend the screen"
);
}
#[test]
fn resize_clamps_hostile_dimensions() {
let mut s = sup(24, 80);
spawn(&mut s, "sleep 30", here());
s.apply(Command::Resize { rows: 0, cols: 0 });
s.tick(); let _ = s.drain();
assert_eq!((s.rows, s.cols), (1, 1), "zero dims clamp to the floor");
s.apply(Command::Resize {
rows: u16::MAX,
cols: u16::MAX,
});
s.tick(); let _ = s.drain();
assert!(s.rows >= 1 && s.rows <= MAX_DIM);
assert!(s.cols >= 1 && s.cols <= MAX_DIM);
assert!(
u32::from(s.rows) * u32::from(s.cols) <= MAX_CELLS,
"accepted geometry {}x{} exceeds MAX_CELLS ({MAX_CELLS}): its \
worst-case Screen frame would not fit MAX_FRAME",
s.rows,
s.cols,
);
s.apply(Command::Resize {
rows: MAX_DIM,
cols: MAX_DIM,
});
assert_eq!(u32::from(s.rows), u32::from(MAX_DIM));
assert_eq!(u32::from(s.cols), MAX_CELLS / u32::from(MAX_DIM));
s.apply(Command::Resize {
rows: 67,
cols: 302,
});
assert_eq!((s.rows, s.cols), (67, 302));
}
#[test]
fn worst_case_screen_frame_fits_max_frame() {
use std::fmt::Write as _;
use alacritty_terminal::{
event::VoidListener,
index::{Column, Line},
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use crate::{
ansi,
frame::{KIND_SCREEN, MAX_FRAME},
protocol::{ScreenView, encode_event},
};
const SGR_A: &str =
"\x1b[0;1;2;3;4:5;7;8;9;38;2;255;254;253;48;2;252;251;250;58;2;249;248;247m";
const SGR_B: &str =
"\x1b[0;1;2;3;4:5;7;8;9;38;2;155;154;153;48;2;152;151;150;58;2;149;148;147m";
let rows = usize::from(MAX_DIM);
let cols = (MAX_CELLS / u32::from(MAX_DIM)) as usize;
assert_eq!(rows * cols, MAX_CELLS as usize, "geometry covers the bound");
let mut input = String::with_capacity(rows * cols * 100);
for row in 1..=rows {
for col in 1..=cols {
let sgr = if (row * cols + col).is_multiple_of(2) {
SGR_A
} else {
SGR_B
};
let _ = write!(input, "\x1b[{row};{col}H{sgr} \x1b[{row};{col}H\t");
}
}
let mut term =
alacritty_terminal::Term::new(Config::default(), &TermSize::new(cols, rows), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, input.as_bytes());
let probe = &term.grid()[Line(0)][Column(0)];
assert_eq!(probe.c, '\t', "cells must take the expensive tab path");
assert!(
probe.underline_color().is_some(),
"cells must carry an underline color"
);
let (formatted, cursor, hide) = ansi::formatted(&term);
let lines: Vec<String> = ansi::contents(&term).lines().map(str::to_string).collect();
let (kind, payload) = encode_event(&Event::Screen(ScreenView {
id: 1,
lines,
formatted,
cursor,
hide_cursor: hide,
wants_mouse: false,
alt_screen: false,
alt_scroll: false,
scrollback: 0,
}));
assert_eq!(kind, KIND_SCREEN);
let per_cell = payload.len() as f64 / MAX_CELLS as f64;
assert!(
per_cell >= 85.0,
"worst-case construction degenerated: {per_cell:.1} bytes/cell"
);
assert!(
payload.len() + payload.len() / 4 <= MAX_FRAME as usize,
"worst-case Screen frame no longer fits MAX_FRAME with 25 % \
headroom: ansi::formatted emits {per_cell:.1} bytes/cell, \
MAX_CELLS is {MAX_CELLS}, MAX_FRAME is {MAX_FRAME}; shrink \
MAX_CELLS, cheapen the serializer, or raise MAX_FRAME",
);
}
fn reap_until(
s: &mut Supervisor,
budget: Duration,
mut pred: impl FnMut(&mut Supervisor) -> bool,
) -> bool {
wait_until(budget, || {
s.reap();
pred(s)
})
}
fn hello_with_sh(s: &mut Supervisor, cwd: PathBuf) {
s.set_launch_context(LaunchContext { env: sh_env(), cwd });
}
#[test]
fn remove_sweeps_stragglers_of_an_exited_leader() {
use nix::sys::signal::kill;
let dir = scratch("remove_sweep");
let (spid, ready) = (dir.join("spid"), dir.join("ready"));
let mut s = sup(24, 80);
hello_with_sh(&mut s, dir.to_path_buf());
let id = spawn_ready(
&mut s,
format!(
"trap '' HUP; sleep 300 & echo $! > {sp}; echo r > {r}",
sp = spid.display(),
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
let straggler = read_pid(&spid);
assert!(reap_until(&mut s, Duration::from_secs(5), |s| {
s.tasks.iter().all(|t| t.id != id || t.finished.is_some())
}));
assert!(kill(straggler, None).is_ok(), "straggler should be alive");
s.apply(Command::Remove { id });
assert!(
reap_until(&mut s, Duration::from_secs(5), |_| kill(straggler, None)
.is_err()),
"Remove never swept the straggler"
);
assert!(
reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()),
"graveyard entry was never collected"
);
}
#[test]
fn rerun_sweeps_stragglers_of_the_old_run() {
use nix::sys::signal::kill;
let dir = scratch("rerun_sweep");
let (spid, ready) = (dir.join("spid"), dir.join("ready"));
let mut s = sup(24, 80);
hello_with_sh(&mut s, dir.to_path_buf());
let id = spawn_ready(
&mut s,
format!(
"trap '' HUP; sleep 300 & echo $! > {sp}; echo r > {r}",
sp = spid.display(),
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
let old_straggler = read_pid(&spid);
assert!(reap_until(&mut s, Duration::from_secs(5), |s| {
s.tasks.iter().all(|t| t.id != id || t.finished.is_some())
}));
assert!(kill(old_straggler, None).is_ok());
s.apply(Command::Restart { id });
assert!(
reap_until(&mut s, Duration::from_secs(5), |_| kill(
old_straggler,
None
)
.is_err()),
"rerun never swept the old run's straggler"
);
assert!(s.tasks.iter().any(|t| t.id == id));
}
#[test]
fn kill_escalation_reaches_term_ignoring_straggler_after_leader_exit() {
use nix::sys::signal::kill;
let dir = scratch("kill_escalate_straggler");
let (spid, ready) = (dir.join("spid"), dir.join("ready"));
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(150));
hello_with_sh(&mut s, dir.to_path_buf());
let id = spawn_ready(
&mut s,
format!(
"trap '' HUP; (trap '' TERM; exec sleep 300) & echo $! > {sp}; echo r > {r}",
sp = spid.display(),
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
let straggler = read_pid(&spid);
assert!(reap_until(&mut s, Duration::from_secs(5), |s| {
s.tasks.iter().all(|t| t.id != id || t.finished.is_some())
}));
s.apply(Command::Kill { id }); assert!(
reap_until(&mut s, Duration::from_secs(5), |_| kill(straggler, None)
.is_err()),
"reap-driven escalation never KILLed the straggler"
);
}
#[test]
fn shutdown_waits_for_graveyard_grace() {
use nix::sys::signal::kill;
let dir = scratch("shutdown_graveyard");
let (spid, ready) = (dir.join("spid"), dir.join("ready"));
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(400));
hello_with_sh(&mut s, dir.to_path_buf());
let id = spawn_ready(
&mut s,
format!(
"trap '' HUP; (trap '' TERM; exec sleep 300) & echo $! > {sp}; echo r > {r}",
sp = spid.display(),
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
let straggler = read_pid(&spid);
assert!(reap_until(&mut s, Duration::from_secs(5), |s| {
s.tasks.iter().all(|t| t.id != id || t.finished.is_some())
}));
s.apply(Command::Remove { id }); let alive_mid_grace = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
kill(straggler, None).is_ok()
});
s.apply(Command::Shutdown);
assert!(
alive_mid_grace.join().unwrap(),
"straggler was KILLed before its grace elapsed"
);
assert!(
reap_until(&mut s, Duration::from_secs(5), |_| kill(straggler, None)
.is_err()),
"straggler survived shutdown"
);
}
#[test]
fn shutdown_holds_the_grace_for_members_of_an_exited_leader() {
use nix::sys::signal::{Signal, kill};
let dir = scratch("shutdown_leaderless");
let (spid, ready) = (dir.join("spid"), dir.join("ready"));
let mut s = sup(24, 80);
s.set_kill_grace(Duration::from_millis(400));
hello_with_sh(&mut s, dir.to_path_buf());
let id = spawn_ready(
&mut s,
format!(
"trap '' HUP; (trap '' TERM; exec sleep 300) & echo $! > {sp}; echo r > {r}",
sp = spid.display(),
r = ready.display()
),
dir.to_path_buf(),
&ready,
);
let straggler = read_pid(&spid);
assert!(reap_until(&mut s, Duration::from_secs(5), |s| {
s.tasks.iter().all(|t| t.id != id || t.finished.is_some())
}));
assert!(kill(straggler, None).is_ok(), "straggler should be alive");
let t0 = Instant::now();
s.apply(Command::Shutdown);
let elapsed = t0.elapsed();
let survived = kill(straggler, None).is_ok();
let _ = kill(straggler, Signal::SIGKILL);
assert!(
elapsed >= Duration::from_millis(400),
"shutdown returned in {elapsed:?} with a non-empty group: the grace was skipped"
);
assert!(
elapsed < Duration::from_secs(2),
"shutdown took {elapsed:?}: not bounded by the 400 ms grace"
);
assert!(
survived,
"the straggler was KILLed instead of receiving the TERM grace"
);
}
#[test]
fn shutdown_is_prompt_when_every_group_is_already_empty() {
let mut s = sup(24, 80);
for _ in 0..2 {
spawn(&mut s, "true", here());
}
assert!(reap_until(&mut s, Duration::from_secs(5), |s| {
s.tasks.len() == 2 && s.tasks.iter().all(|t| t.finished.is_some())
}));
let t0 = Instant::now();
s.apply(Command::Shutdown);
assert!(
t0.elapsed() < Duration::from_millis(500),
"shutdown of already-empty groups took {:?}: the early exit is gone",
t0.elapsed()
);
}
#[test]
fn session_commands_use_the_launch_context_config_dir() {
let dir = scratch("sess_root");
let config = dir.join("config");
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
s.apply(Command::SaveSession { name: "ctx".into() });
assert!(
config.join("sessions").join("ctx.json").is_file(),
"save must land under the launch context's config dir"
);
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m.starts_with("saved 'ctx'"))),
);
s.apply(Command::ListSessions);
let evs = s.drain();
assert!(
evs.iter()
.any(|e| matches!(e, Event::Sessions { names, .. } if names == &["ctx".to_string()])),
"list must see the recipe save just wrote; got {evs:?}"
);
s.apply(Command::LoadSession { name: "ctx".into() });
let evs = s.drain();
assert!(
evs.iter()
.any(|e| matches!(e, Event::Status(m) if m.starts_with("loaded 'ctx'"))),
"load must find the recipe under the same root; got {evs:?}"
);
}
#[test]
fn load_session_restores_saved_groups_and_names() {
let dir = scratch("sess_labels");
let config = dir.join("config");
let ctx = config_ctx(&config, dir.to_path_buf(), &[]);
let mut s = sup_ctx(ctx.clone());
spawn(&mut s, "sleep 31", dir.to_path_buf());
let id = first_id(&mut s);
s.apply(Command::SetName {
id,
name: Some("web".into()),
});
spawn_grouped(&mut s, "sleep 30", dir.to_path_buf(), "api");
s.apply(Command::SaveSession {
name: "fleet".into(),
});
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m.starts_with("saved 'fleet': 2 command(s)"))),
"save must still count commands"
);
let mut fresh = sup_ctx(ctx);
fresh.apply(Command::LoadSession {
name: "fleet".into(),
});
fresh.tick();
let evs = fresh.drain();
let tasks = evs
.iter()
.find_map(|e| match e {
Event::Tasks(v) => Some(v),
_ => None,
})
.expect("a Tasks snapshot after load");
let by_cmd = |cmd: &str| {
let t = tasks
.iter()
.find(|t| t.command == cmd)
.unwrap_or_else(|| panic!("task '{cmd}' missing after load"));
(t.group.clone(), t.name.clone())
};
assert_eq!(
by_cmd("sleep 30"),
(Some("api".into()), None),
"the {{cmd,group}} member must restore its group and stay unnamed"
);
assert_eq!(
by_cmd("sleep 31"),
(None, Some("web".into())),
"the {{cmd,name}} member must restore its name and stay ungrouped"
);
}
#[test]
fn load_session_renormalizes_hand_edited_groups() {
let dir = scratch("sess_norm");
let config = dir.join("config");
std::fs::create_dir_all(config.join("sessions")).unwrap();
std::fs::write(
config.join("sessions").join("edited.json"),
format!(
r#"{{"{}": [{{"cmd": "sleep 30", "group": " x ", "name": " y "}}]}}"#,
dir.display()
),
)
.unwrap();
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
s.apply(Command::LoadSession {
name: "edited".into(),
});
s.tick();
let evs = s.drain();
let restored = evs.iter().any(|e| {
matches!(e, Event::Tasks(v)
if v.iter().any(|t| t.group.as_deref() == Some("x")
&& t.name.as_deref() == Some("y")))
});
assert!(
restored,
"loaded group and name must come back normalized; got {evs:?}"
);
}
#[test]
fn load_surfaces_parse_errors_instead_of_absence() {
let dir = scratch("sess_parse_err");
let config = dir.join("config");
std::fs::create_dir_all(config.join("sessions")).unwrap();
std::fs::write(config.join("sessions").join("broken.json"), "{not json").unwrap();
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
s.apply(Command::LoadSession {
name: "broken".into(),
});
let evs = s.drain();
assert!(
evs.iter().any(
|e| matches!(e, Event::Status(m) if m.starts_with("session 'broken' failed to load:"))
),
"a parse failure must carry load_in's error; got {evs:?}"
);
assert!(
!evs.iter()
.any(|e| matches!(e, Event::Status(m) if m.contains("not found"))),
"a parse failure must not read as absence; got {evs:?}"
);
}
#[test]
fn load_missing_session_reads_as_not_found() {
let dir = scratch("sess_missing");
let config = dir.join("config");
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
s.apply(Command::LoadSession {
name: "ghost".into(),
});
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m == "session 'ghost' not found")),
"a missing recipe must still read as not found"
);
}
#[test]
fn load_reports_admit_failures_not_clean_success() {
let dir = scratch("sess_admit_fail");
let config = dir.join("config");
std::fs::create_dir_all(config.join("sessions")).unwrap();
std::fs::write(
config.join("sessions").join("fleet.json"),
format!(r#"{{"{}": ["true", "true"]}}"#, dir.display()),
)
.unwrap();
let mut s = sup_ctx(config_ctx(
&config,
dir.to_path_buf(),
&[("SHELL", "/nonexistent/no-such-shell")],
));
s.apply(Command::LoadSession {
name: "fleet".into(),
});
let evs = s.drain();
assert!(
evs.iter().any(|e| matches!(e, Event::Status(m)
if m.contains("2 failed to spawn") && !m.contains("task(s)"))),
"failed spawns must be reported, never folded into success; got {evs:?}"
);
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.is_empty())),
"no task may exist when every spawn failed"
);
}
#[test]
fn spawn_refuses_over_length_command() {
let mut s = sup(24, 80);
s.apply(Command::Spawn {
command: "x".repeat(MAX_COMMAND_LEN + 1),
cwd: here(),
group: None,
});
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m.contains("command too long"))),
"an over-length spawn must be refused with a notice"
);
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.is_empty())),
"no task may exist after a refused spawn"
);
}
#[test]
fn load_skips_over_length_commands() {
let dir = scratch("sess_cmd_len");
let config = dir.join("config");
std::fs::create_dir_all(config.join("sessions")).unwrap();
std::fs::write(
config.join("sessions").join("big.json"),
format!(
r#"{{"{}": ["true", "{}"]}}"#,
dir.display(),
"x".repeat(MAX_COMMAND_LEN + 1)
),
)
.unwrap();
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
s.apply(Command::LoadSession { name: "big".into() });
let evs = s.drain();
assert!(
evs.iter().any(|e| matches!(e, Event::Status(m)
if m.contains("1 task(s)") && m.contains("1 skipped"))),
"the over-length entry must be counted as skipped; got {evs:?}"
);
}
#[test]
fn spawn_uses_the_launch_context_env_not_the_process_env() {
assert!(
std::env::var_os("USER").is_some(),
"test needs USER set in the process env to prove it doesn't leak"
);
let dir = scratch("hello_env");
let out = dir.join("out");
let mut s = sup_ctx(LaunchContext {
env: vec![("FLEETCOM_MARKER".into(), "xyzzy".into())],
cwd: dir.to_path_buf(),
});
spawn(
&mut s,
format!(
"printf '%s:%s' \"$FLEETCOM_MARKER\" \"${{USER:-unset}}\" > {}",
out.display()
),
dir.to_path_buf(),
);
let ok = reap_until(&mut s, Duration::from_secs(5), |_| {
std::fs::read_to_string(&out).is_ok_and(|c| !c.is_empty())
});
assert!(ok, "the marker task never wrote its output");
assert_eq!(std::fs::read_to_string(&out).unwrap(), "xyzzy:unset");
}
#[test]
fn launch_without_context_is_refused() {
let mut s = Supervisor::new(24, 80, 2000);
spawn(&mut s, "true", here());
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Status(m) if m.contains("no launch context"))),
"context-less spawn must be refused with a status notice"
);
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.is_empty())),
"no task may exist after a refused spawn"
);
s.apply(Command::LoadSession { name: "any".into() });
let evs = s.drain();
assert!(
evs.iter().any(|e| matches!(e, Event::Status(m)
if m.contains("no launch context") || m.contains("not found"))),
"context-less load must not spawn; got {evs:?}"
);
}
#[test]
fn key_command_encodes_against_live_cursor_mode() {
let dir = scratch("key_live_mode");
let (ready, out) = (dir.join("ready"), dir.join("out"));
let mut s = sup(24, 80);
hello_with_sh(&mut s, dir.to_path_buf());
let id = spawn_ready(
&mut s,
format!(
"stty raw 2>/dev/null; printf '\\033[?1h\\033[?1049h'; echo r > {r}; cat > {o}",
r = ready.display(),
o = out.display()
),
dir.to_path_buf(),
&ready,
);
let app_cursor_on = |s: &Supervisor| {
s.tasks
.iter()
.find(|t| t.id == id)
.is_some_and(|t| t.input_hints().1)
};
assert!(
wait_until(Duration::from_secs(5), || app_cursor_on(&s)),
"child never entered application-cursor mode"
);
s.apply(Command::Key {
id,
code: Key::Up,
mods: Mods::default(),
});
let up: &[u8] = b"\x1bOA";
assert!(
wait_until(Duration::from_secs(5), || {
std::fs::read(&out).is_ok_and(|b| b == up)
}),
"Up under app-cursor must arrive as SS3 ESC O A; got {:?}",
std::fs::read(&out)
);
s.apply(Command::Key {
id,
code: Key::Left,
mods: Mods {
alt: true,
..Mods::default()
},
});
let total: &[u8] = b"\x1bOA\x1b[1;3D";
assert!(
wait_until(Duration::from_secs(5), || {
std::fs::read(&out).is_ok_and(|b| b == total)
}),
"Alt+Left must force CSI 1;3D under app-cursor; got {:?}",
std::fs::read(&out)
);
s.apply(Command::Kill { id });
}
fn recovery_sup(config: &Path, cwd: PathBuf, debounce: Duration, cadence: Duration) -> Supervisor {
let mut s = sup_ctx(config_ctx(config, cwd, &[]));
s.set_recovery_timing(debounce, cadence);
s
}
fn recovery_files(config: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(config.join("sessions").join("recovery"))
.map(|it| {
it.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
names.sort();
names
}
fn take_dirty(s: &mut Supervisor) -> bool {
std::mem::replace(&mut s.recovery.dirty, false)
}
#[test]
fn recovery_arms_on_structural_mutations_not_tag() {
let mut s = sup(24, 80);
assert!(!s.recovery.dirty, "a fresh supervisor starts clean");
spawn(&mut s, "sleep 30", here());
assert!(take_dirty(&mut s), "Spawn must arm");
let id = first_id(&mut s);
s.recovery.dirty = false; s.apply(Command::Tag { id, on: true });
assert!(
!take_dirty(&mut s),
"Tag is not recipe state and must not arm"
);
s.apply(Command::SetGroup {
id,
group: Some("api".into()),
});
assert!(take_dirty(&mut s), "SetGroup must arm");
s.apply(Command::SetName {
id,
name: Some("server".into()),
});
assert!(take_dirty(&mut s), "SetName must arm");
s.apply(Command::Restart { id });
assert!(take_dirty(&mut s), "Restart must arm");
s.apply(Command::LoadSession {
name: "ghost".into(),
});
assert!(take_dirty(&mut s), "LoadSession must arm");
s.apply(Command::LoadRecovery {
stem: "20990101-000000-1".into(),
});
assert!(take_dirty(&mut s), "LoadRecovery must arm");
s.apply(Command::Remove { id });
assert!(take_dirty(&mut s), "Remove must arm");
}
#[test]
fn list_sessions_includes_recovery_snapshots_newest_first() {
let dir = scratch("recovery_list_wire");
let config = dir.join("config");
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
let rec = config.join("sessions").join("recovery");
let entry = |cmd: &str| SessionEntry {
cmd: cmd.into(),
group: None,
name: None,
};
let mut one = SessionConfig::new();
one.insert("~/a".into(), vec![entry("vim")]);
let mut two = SessionConfig::new();
two.insert("~/a".into(), vec![entry("vim"), entry("top")]);
session::save_recovery_in(
&rec,
"20260714-093015-11",
"autosaved 2026-07-14 09:30",
&one,
)
.unwrap();
session::save_recovery_in(
&rec,
"20260715-070000-22",
"autosaved 2026-07-15 07:00",
&two,
)
.unwrap();
s.apply(Command::ListSessions);
let evs = s.drain();
let (names, recovery) = evs
.iter()
.find_map(|e| match e {
Event::Sessions { names, recovery } => Some((names, recovery)),
_ => None,
})
.expect("a Sessions reply");
assert!(names.is_empty(), "no recipes were saved; got {names:?}");
let summary: Vec<(&str, &str, u32)> = recovery
.iter()
.map(|r| (r.stem.as_str(), r.label.as_str(), r.tasks))
.collect();
assert_eq!(
summary,
vec![
("20260715-070000-22", "autosaved 2026-07-15 07:00", 2),
("20260714-093015-11", "autosaved 2026-07-14 09:30", 1),
],
"snapshots must list newest first with labels and task counts"
);
}
#[test]
fn load_recovery_materializes_the_fleet_and_notices() {
let dir = scratch("recovery_load_wire");
let config = dir.join("config");
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
let mut cfg = SessionConfig::new();
cfg.insert(
dir.to_string_lossy().into_owned(),
vec![
SessionEntry {
cmd: "sleep 30".into(),
group: Some("api".into()),
name: None,
},
SessionEntry {
cmd: "sleep 31".into(),
group: None,
name: Some("web".into()),
},
],
);
session::save_recovery_in(
&config.join("sessions").join("recovery"),
"20260714-093015-11",
"autosaved 2026-07-14 09:30",
&cfg,
)
.unwrap();
s.apply(Command::LoadRecovery {
stem: "20260714-093015-11".into(),
});
let evs = s.drain();
assert!(
evs.iter().any(|e| matches!(
e,
Event::Status(m) if m == "loaded recovery snapshot; save to name it"
)),
"a clean load must report exactly the rename-steering notice; got {evs:?}"
);
assert_eq!(s.tasks.len(), 2, "both snapshot commands must spawn");
let by_cmd = |s: &Supervisor, cmd: &str| {
let t = s
.tasks
.iter()
.find(|t| t.command == cmd)
.unwrap_or_else(|| panic!("task '{cmd}' missing after load"));
(t.group.clone(), t.name.clone())
};
assert_eq!(by_cmd(&s, "sleep 30"), (Some("api".into()), None));
assert_eq!(by_cmd(&s, "sleep 31"), (None, Some("web".into())));
}
#[test]
fn load_recovery_refuses_unknown_and_traversal_stems() {
let dir = scratch("recovery_load_refuse");
let config = dir.join("config");
let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[]));
s.apply(Command::LoadRecovery {
stem: "20990101-000000-1".into(),
});
assert!(
s.drain().iter().any(|e| matches!(
e,
Event::Status(m) if m == "recovery snapshot '20990101-000000-1' not found"
)),
"an unknown stem must read as not-found"
);
s.apply(Command::LoadRecovery {
stem: "../x".into(),
});
assert!(
s.drain().iter().any(|e| matches!(
e,
Event::Status(m) if m.starts_with("recovery snapshot '../x' failed to load:")
)),
"a traversal stem must be refused, not probed"
);
assert!(s.tasks.is_empty(), "refused loads must spawn nothing");
}
#[test]
fn recovery_debounce_coalesces_a_mutation_burst() {
let dir = scratch("recovery_debounce");
let config = dir.join("config");
let mut s = recovery_sup(
&config,
dir.to_path_buf(),
Duration::from_millis(500),
Duration::from_secs(600),
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
spawn(&mut s, "sleep 31", dir.to_path_buf());
spawn(&mut s, "sleep 32", dir.to_path_buf());
s.tick();
assert!(
recovery_files(&config).is_empty(),
"a write inside the debounce window defeats coalescing"
);
assert!(
wait_until(Duration::from_secs(5), || {
s.tick();
!recovery_files(&config).is_empty()
}),
"the debounced snapshot never landed"
);
let files = recovery_files(&config);
assert_eq!(
files.len(),
1,
"a burst must produce one snapshot: {files:?}"
);
let text =
std::fs::read_to_string(config.join("sessions").join("recovery").join(&files[0])).unwrap();
for cmd in ["sleep 30", "sleep 31", "sleep 32"] {
assert!(text.contains(cmd), "snapshot must carry {cmd:?}: {text}");
}
assert!(!s.recovery.dirty, "a completed pass clears the flag");
}
#[test]
fn recovery_empty_fleet_never_writes() {
let dir = scratch("recovery_empty");
let config = dir.join("config");
let mut s = recovery_sup(
&config,
dir.to_path_buf(),
Duration::from_millis(100),
Duration::from_millis(200),
);
assert!(
!wait_until(Duration::from_millis(600), || {
s.tick();
!recovery_files(&config).is_empty()
}),
"an idle empty fleet must never write"
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
let id = s.tasks[0].id;
s.apply(Command::Remove { id });
assert!(
!wait_until(Duration::from_millis(600), || {
s.tick();
!recovery_files(&config).is_empty()
}),
"a fleet emptied before the pass must never write"
);
}
#[test]
fn recovery_write_failure_notices_once_and_keeps_supervising() {
let dir = scratch("recovery_fail");
let config = dir.join("config");
std::fs::create_dir_all(config.join("sessions")).unwrap();
std::fs::write(config.join("sessions").join("recovery"), "not a dir").unwrap();
let mut s = recovery_sup(
&config,
dir.to_path_buf(),
Duration::from_millis(10),
Duration::from_millis(50),
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
let mut notices = 0usize;
let deadline = Instant::now() + Duration::from_millis(500);
while Instant::now() < deadline {
s.tick();
notices += s
.drain()
.iter()
.filter(|e| matches!(e, Event::Status(m) if m.contains("recovery snapshot failed")))
.count();
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(notices, 1, "persistent failure must notice exactly once");
s.tick();
assert!(
s.drain()
.iter()
.any(|e| matches!(e, Event::Tasks(v) if v.len() == 1)),
"a failing writer must never disturb supervision"
);
}
#[test]
fn recovery_maintenance_writes_detached_and_queues_nothing() {
let dir = scratch("recovery_detached");
let config = dir.join("config");
let mut s = recovery_sup(
&config,
dir.to_path_buf(),
Duration::from_millis(50),
Duration::from_secs(600),
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
assert!(
wait_until(Duration::from_secs(5), || {
s.reap();
s.recovery_maintenance();
!recovery_files(&config).is_empty()
}),
"the detached maintenance pass never wrote"
);
assert!(
s.drain().is_empty(),
"the idle path must not queue events; nothing drains them"
);
}
#[test]
fn recovery_dedup_is_per_destination_root() {
let dir = scratch("recovery_root_switch");
let (config_a, config_b) = (dir.join("cfg_a"), dir.join("cfg_b"));
let mut s = recovery_sup(
&config_a,
dir.to_path_buf(),
Duration::from_millis(50),
Duration::from_secs(600),
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
assert!(
wait_until(Duration::from_secs(5), || {
s.recovery_maintenance();
!recovery_files(&config_a).is_empty()
}),
"root A never received the first snapshot"
);
s.set_launch_context(config_ctx(&config_b, dir.to_path_buf(), &[]));
s.recovery.dirty = true;
s.recovery.last_mutation = Some(Instant::now());
assert!(
wait_until(Duration::from_secs(5), || {
s.recovery_maintenance();
!recovery_files(&config_b).is_empty()
}),
"an unchanged recipe must still write to a root without a snapshot"
);
assert!(
!recovery_files(&config_a).is_empty(),
"the old root keeps its snapshot"
);
}
#[test]
fn recovery_failed_write_retries_until_success() {
let dir = scratch("recovery_retry");
let config = dir.join("config");
std::fs::create_dir_all(config.join("sessions")).unwrap();
std::fs::write(config.join("sessions").join("recovery"), "not a dir").unwrap();
let mut s = recovery_sup(
&config,
dir.to_path_buf(),
Duration::from_millis(10),
Duration::from_millis(50),
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
assert!(
wait_until(Duration::from_secs(5), || {
s.recovery_maintenance();
s.recovery.failing
}),
"the blocked root never produced a failed pass"
);
assert!(
s.recovery.last_written.is_none(),
"a failed write must not advance the dedup record"
);
std::fs::remove_file(config.join("sessions").join("recovery")).unwrap();
assert!(
wait_until(Duration::from_secs(5), || {
s.recovery_maintenance();
!recovery_files(&config).is_empty()
}),
"the cadence never retried after the root became writable"
);
assert!(!s.recovery.failing, "a successful write clears the latch");
}
#[test]
fn recovery_rewrites_after_a_sibling_prune_deletes_the_snapshot() {
let dir = scratch("recovery_sibling_prune");
let config = dir.join("config");
let mut s = recovery_sup(
&config,
dir.to_path_buf(),
Duration::from_millis(50),
Duration::from_millis(100),
);
spawn(&mut s, "sleep 30", dir.to_path_buf());
assert!(
wait_until(Duration::from_secs(5), || {
s.recovery_maintenance();
!recovery_files(&config).is_empty()
}),
"the first snapshot never landed"
);
let rec = config.join("sessions").join("recovery");
for name in recovery_files(&config) {
std::fs::remove_file(rec.join(name)).unwrap();
}
assert!(
wait_until(Duration::from_secs(5), || {
s.recovery_maintenance();
!recovery_files(&config).is_empty()
}),
"an unchanged recipe must rewrite an externally deleted snapshot"
);
}
#[path = "supervisor_capture_tests.rs"]
mod capture;