Gemini's review of the S2 branch found the one hole the harness had not covered (P2, verified reachable): Join tears the old session down — deliberately killing the share host — BEFORE validating the ticket, and an invalid ticket exits the arm early, skipping the late `current_sharing = None`. The killed host's stdout EOF then passed the staleness gate and the user got a spurious "Screen share ended unexpectedly" on top of "invalid room ticket". Pre-S2 the stale value was toothless on this path; the fault handler gave it teeth. The share now dies where the session does: cleared unconditionally right after the teardown block, ahead of every early exit. The live gate grew a third half — share, Join with a garbage ticket, then require silence after the ticket error — and the mutant restoring the old placement is killed by exactly that assertion (spurious re-emitted ScreenShareStopped). Also Gemini's P3: the test's temp dir is now dropped by a guard, so an assertion panic no longer leaks the fake-pixelpass scripts in /tmp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
356 lines
14 KiB
Rust
356 lines
14 KiB
Rust
//! 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<T>(
|
|
rx: &mut tokio::sync::mpsc::Receiver<UiEvent>,
|
|
what: &str,
|
|
mut pick: impl FnMut(&UiEvent) -> Option<T>,
|
|
) -> 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));
|
|
}
|