From 81c230a09c4bc10e45d2400824b719fd6762d0c1 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 31 Jul 2026 01:08:13 -0400 Subject: [PATCH] =?UTF-8?q?test:=20live=20S2=20exit=20gate=20=E2=80=94=20d?= =?UTF-8?q?ead=20host=20torn=20down,=20clean=20stop=20stays=20clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the real core loop through CoreController with the pixelpass override pointed at fake shell scripts (a host that emits its ticket and dies; one that lives until signalled). The command loop has no unit seam, so this is the only harness reaching the fault handler. Half 1 pins the whole death path: ScreenShareStopped arrives BEFORE the "ended unexpectedly" error. Half 2 stops a share deliberately and then requires silence while the retired host's late stdout EOF lands as a stale fault. The `exec sleep` in the living host is load-bearing: it makes SIGINT close stdout so the stale fault actually arrives, keeping the staleness assertion non-vacuous. Both core-side mutants verified killed: swallowing the forwarder's fault times out half 1; disabling the staleness gate panics half 2 with the spurious re-emitted ScreenShareStopped. Co-Authored-By: Claude Fable 5 --- tests/screenshare_host_fault.rs | 197 ++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/screenshare_host_fault.rs diff --git a/tests/screenshare_host_fault.rs b/tests/screenshare_host_fault.rs new file mode 100644 index 0000000..6a90dab --- /dev/null +++ b/tests/screenshare_host_fault.rs @@ -0,0 +1,197 @@ +//! S2 exit gate: a pixelpass host that dies mid-share must be torn down — +//! reaped, pulled off presence, `ScreenShareStopped` emitted **before** the +//! explanatory error — and a host stopped *deliberately* must NOT produce that +//! error when its stdout EOF arrives late (the staleness gate). +//! +//! Drives the real core loop end to end through `CoreController`, with the +//! pixelpass override pointed at fake shell scripts: one that emits a ticket +//! and dies, one that emits a ticket and lives until signalled. This is the +//! only harness that reaches the core's fault handler — the command loop has +//! no unit seam — so these two halves are what kill the "forwarder drops the +//! Eof" and "handler ignores the generation" mutants. +//! +//! Live: joins a real (solo) room, so it needs a working audio backend and +//! network access for the endpoint bind. +//! `cargo test --test screenshare_host_fault -- --ignored` + +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::Duration; + +use peerspeak::core::CoreController; +use peerspeak::core::messages::{CoreCommand, UiEvent}; + +const EVENT_TIMEOUT: Duration = Duration::from_secs(20); +/// How long to listen for events that must NOT arrive. Comfortably past the +/// fake host's exit plus the drain/forwarder hop, so a stale fault that WOULD +/// be mishandled has arrived by the end of it. +const QUIET_WINDOW: Duration = Duration::from_secs(3); + +fn write_fake_pixelpass(dir: &std::path::Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, body).expect("write fake pixelpass"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake pixelpass"); + path +} + +/// Skip events until `pick` matches, panicking after [`EVENT_TIMEOUT`]. +/// Unrelated events (identity, presence, chat plumbing) flow on this channel +/// too, so gates scan rather than assert exact sequences. +async fn wait_for( + rx: &mut tokio::sync::mpsc::Receiver, + what: &str, + mut pick: impl FnMut(&UiEvent) -> Option, +) -> T { + let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT; + loop { + let ev = tokio::time::timeout_at(deadline, rx.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {what}")) + .unwrap_or_else(|| panic!("ui channel closed waiting for {what}")); + if let Some(v) = pick(&ev) { + return v; + } + } +} + +#[tokio::test] +#[ignore = "live: joins a real solo room (audio backend + network bind)"] +async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() { + let dir = std::env::temp_dir().join(format!("peerspeak-hostfault-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + // Half 1's host: emits its ticket, then dies on its own — the S2 defect + // scenario. Plain `sleep` (no exec) so the shell itself exits and closes + // stdout with no orphan holding the pipe. + let dying_host = write_fake_pixelpass( + &dir, + "pixelpass-dies", + "#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-dies\"}'\nsleep 1\n", + ); + // Half 2's host: lives until signalled. `exec` so the SIGINT from Stop + // Share hits the sleep itself — the process dies AND its stdout closes, + // which is exactly what makes the late Eof arrive and exercise the + // staleness gate rather than vacuously never sending a fault. + let living_host = write_fake_pixelpass( + &dir, + "pixelpass-lives", + "#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-lives\"}'\nexec sleep 600\n", + ); + + let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256); + let controller = CoreController::new(ui_tx); + + assert!(controller.send(CoreCommand::SetPixelpassPath(Some( + dying_host.to_string_lossy().into_owned() + )))); + assert!(controller.send(CoreCommand::Join { + name: "host-fault-gate".into(), + ticket: "create".into(), + room_name: "s2".into(), + input_device: None, + output_device: None, + echo_cancellation: false, + avatar: Default::default(), + })); + wait_for(&mut ui_rx, "RoomJoined", |ev| match ev { + UiEvent::RoomJoined { .. } => Some(()), + UiEvent::Error(e) => panic!("join failed: {e}"), + _ => None, + }) + .await; + + // ── Half 1: the host dies mid-share ───────────────────────────────────── + assert!(controller.send(CoreCommand::StartScreenShare { + audio_app: None, + settings: Default::default(), + quality: Default::default(), + })); + wait_for( + &mut ui_rx, + "ScreenShareStarted (dying host)", + |ev| match ev { + UiEvent::ScreenShareStarted => Some(()), + UiEvent::Error(e) => panic!("share start failed: {e}"), + _ => None, + }, + ) + .await; + + // The fake host exits ~1s in. The contract: ScreenShareStopped FIRST (it + // clears the UI's sharing state), the explanatory error only after. + wait_for(&mut ui_rx, "ScreenShareStopped after host death", |ev| { + match ev { + UiEvent::ScreenShareStopped => Some(()), + // An error arriving first is the exact ordering defect S2 fixes: + // the UI would show "sharing" next to the explanation. + 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}" + ); + + // ── Half 2: a deliberate stop must stay clean ─────────────────────────── + assert!(controller.send(CoreCommand::SetPixelpassPath(Some( + living_host.to_string_lossy().into_owned() + )))); + assert!(controller.send(CoreCommand::StartScreenShare { + audio_app: None, + settings: Default::default(), + quality: Default::default(), + })); + wait_for( + &mut ui_rx, + "ScreenShareStarted (living host)", + |ev| match ev { + UiEvent::ScreenShareStarted => Some(()), + UiEvent::Error(e) => panic!("second share start failed: {e}"), + _ => None, + }, + ) + .await; + + assert!(controller.send(CoreCommand::StopScreenShare)); + wait_for( + &mut ui_rx, + "ScreenShareStopped after Stop Share", + |ev| match ev { + UiEvent::ScreenShareStopped => Some(()), + UiEvent::Error(e) => panic!("clean stop produced an error: {e}"), + _ => None, + }, + ) + .await; + + // The stopped host's stdout EOF is arriving about now as a *stale* fault + // (its generation was retired when Stop Share cleared the share). Without + // the staleness gate the handler would emit a second ScreenShareStopped + // and a spurious "ended unexpectedly" error — listen long enough for that + // mishandling to have shown up, and require silence. + let deadline = tokio::time::Instant::now() + QUIET_WINDOW; + while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await { + match ev { + UiEvent::ScreenShareStopped => { + panic!("stale host fault re-emitted ScreenShareStopped after a clean stop") + } + UiEvent::Error(e) if e.contains("unexpectedly") => { + panic!("stale host fault surfaced as an error after a clean stop: {e}") + } + _ => {} + } + } + + assert!(controller.send(CoreCommand::Leave)); + std::fs::remove_dir_all(&dir).ok(); +}