diff --git a/crates/lanspread-peer/src/stream_install.rs b/crates/lanspread-peer/src/stream_install.rs index afe0c36..c1fd4ac 100644 --- a/crates/lanspread-peer/src/stream_install.rs +++ b/crates/lanspread-peer/src/stream_install.rs @@ -225,6 +225,7 @@ async fn unrar_listing(program: &Path, archive: &Path) -> eyre::Result, active_outbound_transfers: OutboundTransfers, outbound_transfer_emit: Arc>, + /// Live unrar sidecar processes, so they can be killed if the launcher exits + /// mid-unpack. The shell plugin does not kill spawned children on app exit, + /// and the OS does not cascade-kill child processes, so without this an + /// in-progress unrar keeps running after the launcher closes. + active_unrar_children: Arc>, } +/// Live unrar sidecar children plus a shutdown latch. +/// +/// Children are keyed by a monotonic id (not pid) so a finishing install never +/// deregisters another install's child after a pid is recycled. The +/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill +/// sweep drains the map only once, so a child registered *after* the sweep (an +/// install task caught between `spawn()` and registration, or a later archive in +/// a multi-archive install — `unpack_archives` does not observe the shutdown +/// token) would be orphaned. Once the latch is set under this mutex, registration +/// kills the child immediately instead of inserting it where nothing will reap it. +#[derive(Default)] +struct UnrarChildRegistry { + shutting_down: bool, + children: HashMap, +} + +/// Monotonic id source for [`UnrarChildRegistry`] entries. +static UNRAR_CHILD_SEQ: AtomicU64 = AtomicU64::new(0); + #[derive(Clone, Debug, PartialEq, Eq)] struct InstallSettings { account_name: String, @@ -1303,16 +1335,19 @@ async fn run_unrar_sidecar( paths: &UnrarPaths, started_at_ms: u64, ) -> eyre::Result<()> { - let out = match sidecar + // Spawn (instead of `.output()`) so we keep a killable handle. The shell + // plugin's `output()` drops the `CommandChild` immediately and only drains + // the event channel, leaving the unrar process orphaned if the launcher + // exits before extraction finishes. + let (mut events, child) = match sidecar .arg("x") // extract files .arg(&paths.archive) .arg("-y") // Assume Yes on all queries .arg("-o") // Set overwrite mode .arg(&paths.destination_arg) - .output() - .await + .spawn() { - Ok(out) => out, + Ok(spawned) => spawned, Err(err) => { let stderr = format!("failed to run unrar sidecar: {err}"); record_unpack_failure( @@ -1327,10 +1362,42 @@ async fn run_unrar_sidecar( } }; - let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout)); - let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr)); - let status_code = out.status.code(); - let success = out.status.success(); + // Register the live child so a launcher exit can kill it, and deregister it + // automatically on every exit path via the RAII guard. + let registry = app_handle + .state::() + .active_unrar_children + .clone(); + let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed); + register_unrar_child(®istry, child_id, child); + let _child_guard = UnrarChildGuard { registry, child_id }; + + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut status_code = None; + while let Some(event) = events.recv().await { + match event { + CommandEvent::Stdout(line) => { + stdout_bytes.extend(line); + stdout_bytes.push(b'\n'); + } + CommandEvent::Stderr(line) => { + stderr_bytes.extend(line); + stderr_bytes.push(b'\n'); + } + CommandEvent::Terminated(payload) => { + status_code = payload.code; + } + CommandEvent::Error(err) => { + log::warn!("unrar sidecar event error: {err}"); + } + _ => {} + } + } + + let stdout = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes)); + let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes)); + let success = status_code == Some(0); record_unpack_log( app_handle, @@ -1360,6 +1427,79 @@ async fn run_unrar_sidecar( Ok(()) } +/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown. +/// +/// If shutdown has already begun (or the registry is poisoned), the child is +/// killed immediately instead of inserted, since the exit kill sweep has already +/// run and would never reap a late registration. +fn register_unrar_child( + registry: &Arc>, + child_id: u64, + child: CommandChild, +) { + let Ok(mut guard) = registry.lock() else { + // A poisoned registry means we can no longer guarantee the child is + // killed on exit, so kill it now rather than risk orphaning it. + let pid = child.pid(); + log::warn!("unrar child registry is poisoned; killing pid {pid} immediately"); + if let Err(err) = child.kill() { + log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}"); + } + return; + }; + + if guard.shutting_down { + drop(guard); + let pid = child.pid(); + log::info!("Killing unrar child (pid {pid}) that spawned during shutdown"); + if let Err(err) = child.kill() { + log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}"); + } + return; + } + + guard.children.insert(child_id, child); +} + +/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call +/// returns, regardless of success, error, or early bail. +struct UnrarChildGuard { + registry: Arc>, + child_id: u64, +} + +impl Drop for UnrarChildGuard { + fn drop(&mut self) { + if let Ok(mut guard) = self.registry.lock() { + guard.children.remove(&self.child_id); + } + } +} + +/// Kills every in-progress unrar sidecar and latches the registry into shutdown +/// so any install task that spawns unrar after this point kills it on +/// registration. Called on app exit so a game install that is mid-extraction does +/// not leave `unrar` running after the launcher closes. +fn kill_active_unrar_children(app_handle: &AppHandle) { + let state = app_handle.state::(); + let children = { + let Ok(mut guard) = state.active_unrar_children.lock() else { + log::warn!("unrar child registry is poisoned; cannot kill children on shutdown"); + return; + }; + guard.shutting_down = true; + guard.children.drain().collect::>() + }; + + for (_child_id, child) in children { + let pid = child.pid(); + match child.kill() { + Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"), + Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"), + } + } +} + async fn record_unpack_failure( app_handle: &AppHandle, archive: String, @@ -2249,6 +2389,10 @@ pub fn run() { .expect("error while building tauri application") .run(|app_handle, event| { if matches!(event, tauri::RunEvent::Exit) { + // Kill unrar first: an in-progress extraction would otherwise keep + // running after the launcher closes, and killing it lets the + // install task unwind so the peer runtime can stop promptly. + kill_active_unrar_children(app_handle); shutdown_peer_runtime(app_handle); } });