diff --git a/tests/screenshare_host_fault.rs b/tests/screenshare_host_fault.rs index a0e2fe2..f70aa3d 100644 --- a/tests/screenshare_host_fault.rs +++ b/tests/screenshare_host_fault.rs @@ -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 = 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