//! 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); /// Removes the fake-pixelpass dir even when an assertion panics mid-test /// (a plain trailing `remove_dir_all` never runs on an unwind). struct TempDir(PathBuf); impl Drop for TempDir { fn drop(&mut self) { std::fs::remove_dir_all(&self.0).ok(); } } 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_guard = TempDir(std::env::temp_dir().join(format!("peerspeak-hostfault-{}", std::process::id()))); let dir = dir_guard.0.clone(); 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}") } _ => {} } } // ── Half 3: a failed room switch while sharing must not cry "crash" ───── // Join tears the old session down (killing the host, deliberately) BEFORE // it validates the ticket, so an invalid ticket exits the Join arm early. // The share must be retired at the teardown itself — left advertised, the // killed host's EOF passes the staleness gate and a spurious "ended // unexpectedly" lands on top of the ticket error (Gemini review, P2-1). assert!(controller.send(CoreCommand::StartScreenShare { audio_app: None, settings: Default::default(), quality: Default::default(), })); wait_for( &mut ui_rx, "ScreenShareStarted (before failed switch)", |ev| match ev { UiEvent::ScreenShareStarted => Some(()), UiEvent::Error(e) => panic!("third share start failed: {e}"), _ => None, }, ) .await; assert!(controller.send(CoreCommand::Join { name: "host-fault-gate".into(), ticket: "definitely-not-a-ticket".into(), room_name: "s2".into(), input_device: None, output_device: None, echo_cancellation: false, avatar: Default::default(), })); wait_for(&mut ui_rx, "the invalid-ticket error", |ev| match ev { UiEvent::Error(e) if e.contains("invalid room ticket") => Some(()), UiEvent::Error(e) => panic!("unexpected error before the ticket error: {e}"), _ => None, }) .await; // The deliberately-killed host's EOF is arriving about now; it must be // dropped as stale, not reported as a crash. 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!("failed room switch re-emitted ScreenShareStopped for the torn-down share") } UiEvent::Error(e) if e.contains("unexpectedly") => { panic!("deliberate teardown during a failed room switch reported as a crash: {e}") } _ => {} } } } /// 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 /// "couldn't confirm" warning — because SIGKILL would skip pixelpass's own /// teardown (it unloads its capture sink on the way out in sink-owning modes). /// /// The fallback is indistinguishable from success in the event stream (both /// end in a confirmed reap), so the discriminator is TIME: the fallback path /// first waits out the full 2 s grace, while a host honouring SIGINT exits in /// milliseconds. The bound asserts the stop completed inside the grace. /// /// Live: needs `pixelpass` on `$PATH` plus a real solo room (audio + network). #[tokio::test] #[ignore = "live: real pixelpass host + a real solo room (audio backend, network bind)"] async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() { /// Mirrors `core::teardown::STOP_GRACE` (private): the graceful wait /// before the SIGKILL fallback. const STOP_GRACE: Duration = Duration::from_secs(2); let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256); let controller = CoreController::new(ui_tx); // No override: resolve the real binary from $PATH. assert!(controller.send(CoreCommand::SetPixelpassPath(None))); assert!(controller.send(CoreCommand::Join { name: "sigint-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; // Whole-desktop share: no viewers ever connect, so the real host sits idle // after its ticket (capture starts on first viewer) — exactly the state a // Stop Share most often hits. assert!(controller.send(CoreCommand::StartScreenShare { audio_app: None, settings: Default::default(), quality: Default::default(), })); wait_for( &mut ui_rx, "ScreenShareStarted (real pixelpass)", |ev| match ev { UiEvent::ScreenShareStarted => Some(()), UiEvent::Error(e) => panic!("real pixelpass host failed to start: {e}"), _ => None, }, ) .await; let stop_started = std::time::Instant::now(); assert!(controller.send(CoreCommand::StopScreenShare)); wait_for( &mut ui_rx, "ScreenShareStopped (real pixelpass)", |ev| match ev { UiEvent::ScreenShareStopped => Some(()), // An Unconfirmed reap surfaces exactly this way; it means the // SIGINT AND the SIGKILL both failed to end the host. UiEvent::Error(e) => panic!("stop of the real host was not clean: {e}"), _ => None, }, ) .await; let elapsed = stop_started.elapsed(); assert!( elapsed < STOP_GRACE, "stop took {elapsed:?} — at or past the {STOP_GRACE:?} grace, i.e. the \ SIGKILL fallback fired instead of pixelpass honouring SIGINT" ); // And the late stdout EOF from the SIGINTed host must stay silent (same // staleness contract the fake-host half pins). 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 fault from the SIGINTed real host re-emitted ScreenShareStopped") } UiEvent::Error(e) if e.contains("unexpectedly") => { panic!("stale fault from the SIGINTed real host surfaced as an error: {e}") } _ => {} } } assert!(controller.send(CoreCommand::Leave)); }