test: presence gate — a host fault clears the ticket for peers, promptly
The 🟡 S2 gap: presence-side ticket removal on fault was asserted by no gate, and the "same code path as StopScreenShare" argument turned out false — the fault handler duplicates the presence statements, so a mutant deleting them passed all 644 tests. Nothing on the sharer's own UiEvent channel can witness presence; it is only observable from another node. The gate runs a real second core as an observer in a SEPARATE PROCESS (this test binary re-invoked as `presence_probe_helper`, its own XDG_CONFIG_HOME): two in-process cores would load the same identity.key and collapse into one node id, and swapping the env var between spawns races other threads' getenv. The observer asserts the sharer's PeerState.sharing goes Some → None on fault. The fake host is a wedge — valid-shaped ticket (the OBSERVER's gossip ingest sanitizes peer tickets; a garbage one is nulled to None and the gate goes vacuous), ~1 s of life, then closes stdout while trapping SIGINT — so the reap burns the full stop grace and TIME discriminates the ordering, like the SIGINT gate: presence-first clears in ~1 s, the old reap-then-presence ordering in ~3 s, asserted < the 2 s grace. Mutation-verified both ways: presence removal deleted ⇒ observer times out; old ordering restored ⇒ 3002 ms measured, assert fires. Standalone (a plain --ignored sweep) the helper no-ops; the probe is kill_on_drop so a parent panic can't orphan it (Gemini P3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,240 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
|
||||
}
|
||||
}
|
||||
|
||||
/// S2 presence gate: a host fault must pull the share ticket off PRESENCE —
|
||||
/// what remote peers actually see — and must do it BEFORE the reap wait, not
|
||||
/// after. Nothing on the sharer's own `UiEvent` channel can witness either
|
||||
/// half (presence is only observable from another node), so this test runs a
|
||||
/// real second core as an OBSERVER and asserts the sharer's `PeerState.sharing`
|
||||
/// goes `Some` → `None` on fault.
|
||||
///
|
||||
/// The observer runs in a SEPARATE PROCESS (`presence_probe_helper`, this same
|
||||
/// test binary re-invoked): two in-process cores would load the same
|
||||
/// `identity.key` and collapse into one node id, and swapping `XDG_CONFIG_HOME`
|
||||
/// between spawns in-process races other threads' getenv.
|
||||
///
|
||||
/// The fake host is a WEDGE — it closes stdout (the fault) but ignores SIGINT
|
||||
/// and lives until the SIGKILL fallback — so `stop_host` burns the full 2 s
|
||||
/// grace and TIME becomes the discriminator, exactly like the SIGINT gate:
|
||||
/// with presence-removal-first the observer sees the ticket clear ~1 s after
|
||||
/// it appeared (the wedge's pre-fault lifetime); with the old
|
||||
/// reap-then-presence ordering, only after ~3 s. The bound also makes the
|
||||
/// "presence removal deleted" mutant fail by timeout instead of passing
|
||||
/// vacuously.
|
||||
///
|
||||
/// Live: two real solo-room cores (audio backend + network bind each).
|
||||
#[tokio::test]
|
||||
#[ignore = "live: two real cores in one room (audio backend + network bind), observer subprocess"]
|
||||
async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
|
||||
/// Mirrors `core::teardown::STOP_GRACE` (private): the wait the wedge
|
||||
/// forces before the SIGKILL fallback reaps it.
|
||||
const STOP_GRACE_MS: u128 = 2000;
|
||||
|
||||
let dir_guard = TempDir(
|
||||
std::env::temp_dir().join(format!("peerspeak-presence-gate-{}", std::process::id())),
|
||||
);
|
||||
let dir = dir_guard.0.clone();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Emits its ticket, shares for ~1 s, then closes stdout (the fault) while
|
||||
// staying alive and ignoring SIGINT, so the reap must wait out the grace.
|
||||
// The trailing sleep is NOT exec'd on purpose: it forks after stdout is
|
||||
// closed, so it holds no pipe (the vacuous-staleness trap doesn't apply),
|
||||
// and it merely idles out after the SIGKILL reaps the shell.
|
||||
//
|
||||
// The fake ticket must pass `screenshare::sanitize_ticket` (`endpoint` +
|
||||
// alphanumerics): the OBSERVER's gossip ingest sanitizes peer-advertised
|
||||
// tickets, and a garbage one is nulled to `sharing: None` there — the
|
||||
// probe would never see the share appear and the gate would go vacuous.
|
||||
let wedged_host = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-wedges",
|
||||
"#!/bin/sh\ntrap '' INT\n\
|
||||
echo '{\"event\":\"ticket\",\"value\":\"endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm\"}'\n\
|
||||
sleep 1\nexec 1>&-\nsleep 30\n",
|
||||
);
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||
let controller = CoreController::new(ui_tx);
|
||||
|
||||
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||
wedged_host.to_string_lossy().into_owned()
|
||||
))));
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "presence-gate".into(),
|
||||
ticket: "create".into(),
|
||||
room_name: "s2-presence".into(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
let room_ticket = wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||
UiEvent::RoomJoined { ticket, .. } => Some(ticket.clone()),
|
||||
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// The observer, in its own process with its own config dir (fresh
|
||||
// identity). It prints `PROBE …` lines this test parses.
|
||||
let probe_config = dir.join("probe-config");
|
||||
std::fs::create_dir_all(&probe_config).unwrap();
|
||||
let probe = tokio::process::Command::new(std::env::current_exe().unwrap())
|
||||
.kill_on_drop(true)
|
||||
.args([
|
||||
"presence_probe_helper",
|
||||
"--exact",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("PEERSPEAK_PROBE_TICKET", &room_ticket)
|
||||
.env("XDG_CONFIG_HOME", &probe_config)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn the presence probe");
|
||||
|
||||
// Only share once the probe is in the room, so it witnesses the ticket
|
||||
// APPEARING before the fault clears it (otherwise `Some` → `None` could
|
||||
// both predate its join and the gate would go vacuous).
|
||||
wait_for(&mut ui_rx, "the probe's PeerJoined", |ev| match ev {
|
||||
UiEvent::PeerJoined { .. } => Some(()),
|
||||
UiEvent::Error(e) => panic!("waiting for the probe: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app: None,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStarted (wedged host)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStarted => Some(()),
|
||||
UiEvent::Error(e) => panic!("share start failed: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Sharer-side contract, unchanged by the reorder: Stopped first, the
|
||||
// explanatory error only after.
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStopped after the wedge faults",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStopped => Some(()),
|
||||
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
|
||||
UiEvent::Error(e) => Some(e.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
err.contains("unexpectedly"),
|
||||
"the error should say the share ended unexpectedly, got: {err}"
|
||||
);
|
||||
|
||||
let out = tokio::time::timeout(Duration::from_secs(60), probe.wait_with_output())
|
||||
.await
|
||||
.expect("probe process outlived its budget")
|
||||
.expect("probe process wait");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"probe failed ({}).\nstdout:\n{stdout}\nstderr:\n{stderr}",
|
||||
out.status
|
||||
);
|
||||
let cleared_ms: u128 = stdout
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("PROBE sharing-cleared "))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("probe never saw the ticket clear from presence.\nstdout:\n{stdout}")
|
||||
})
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("probe delta should be integer millis");
|
||||
// Presence-removal-first: ~1000 ms (the wedge's pre-fault lifetime).
|
||||
// Reap-then-presence: ~3000 ms (lifetime + the full stop grace). The
|
||||
// grace itself splits them with ~1 s of jitter headroom on each side.
|
||||
assert!(
|
||||
cleared_ms < STOP_GRACE_MS,
|
||||
"presence kept advertising the dead share for {cleared_ms} ms after it appeared — \
|
||||
at or past the wedge lifetime + stop grace, i.e. the ticket was only removed \
|
||||
AFTER the reap wait instead of before it"
|
||||
);
|
||||
|
||||
assert!(controller.send(CoreCommand::Leave));
|
||||
}
|
||||
|
||||
/// Observer half of `a_host_fault_pulls_the_ticket_off_presence_within_the_grace`,
|
||||
/// run BY that test as a subprocess. Standalone (no `PEERSPEAK_PROBE_TICKET` in
|
||||
/// the env — e.g. a plain `--ignored` sweep) it is a no-op pass.
|
||||
#[tokio::test]
|
||||
#[ignore = "helper: spawned by the presence gate as a subprocess; standalone it no-ops"]
|
||||
async fn presence_probe_helper() {
|
||||
let Ok(room_ticket) = std::env::var("PEERSPEAK_PROBE_TICKET") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||
let controller = CoreController::new(ui_tx);
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "presence-probe".into(),
|
||||
ticket: room_ticket,
|
||||
room_name: String::new(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
wait_for(&mut ui_rx, "RoomJoined (probe)", |ev| match ev {
|
||||
UiEvent::RoomJoined { .. } => Some(()),
|
||||
UiEvent::Error(e) => panic!("probe join failed: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Watch the sharer's presence: record when its `sharing` ticket appears,
|
||||
// report the delta when it clears. Timings on both ends are local-loopback
|
||||
// arrival times, so the parent's bound compares like with like.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
let mut seen_at: Option<std::time::Instant> = None;
|
||||
loop {
|
||||
let ev = tokio::time::timeout_at(deadline, ui_rx.recv())
|
||||
.await
|
||||
.expect("probe timed out watching for the sharing transition")
|
||||
.expect("probe ui channel closed");
|
||||
let sharing = match &ev {
|
||||
UiEvent::PeerJoined { state, .. } | UiEvent::PeerUpdated { state, .. } => {
|
||||
state.sharing.is_some()
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
match (&seen_at, sharing) {
|
||||
(None, true) => {
|
||||
seen_at = Some(std::time::Instant::now());
|
||||
println!("PROBE sharing-seen");
|
||||
}
|
||||
(Some(t0), false) => {
|
||||
println!("PROBE sharing-cleared {}", t0.elapsed().as_millis());
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(controller.send(CoreCommand::Leave));
|
||||
}
|
||||
|
||||
/// The long-owed Stop Share SIGINT gate (0c half (ii)), against the REAL
|
||||
/// pixelpass binary: a Stop Share must end the host through the graceful
|
||||
/// SIGINT path — child exits within [`STOP_GRACE`], no SIGKILL fallback, no
|
||||
|
||||
Reference in New Issue
Block a user