From 1dea7484ea79458b81a263c46ae55aa2dc3b77ea Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:27:49 +0530 Subject: [PATCH] fix(startup-recovery): scope Windows pre-CEF reap to the wedged GUI instance (#3900) (#4644) Co-authored-by: Claude Opus 4.8 (1M context) --- app/src-tauri/src/lib.rs | 23 +- app/src-tauri/src/process_kill.rs | 51 +++ app/src-tauri/src/process_recovery.rs | 478 ++++++++++++++++++-------- 3 files changed, 405 insertions(+), 147 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 210fe0d28..94fc2cd73 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2694,14 +2694,23 @@ pub fn run() { #[cfg(target_os = "macos")] process_recovery::reap_stale_openhuman_processes(); - // ── Windows pre-CEF proactive stale-process reap (issue #3605) ──────── + // ── Windows pre-CEF proactive stale-process reap (issues #3605, #3900) ─ // The Win32 mutex above already guaranteed we are the only *top-level* - // instance past that point — a concurrent secondary saw - // `ERROR_ALREADY_EXISTS` and exited before reaching here. So any - // surviving `openhuman.exe` / `openhuman-core.exe` is a *wedged prior - // instance* left behind by an update or hard exit, not a legitimate - // peer. Reap it proactively (TERM then KILL) — the cross-platform - // analogue of the macOS reap above. + // GUI instance past that point — a concurrent secondary saw + // `ERROR_ALREADY_EXISTS` and exited before reaching here. So a surviving + // GUI `OpenHuman.exe` browser process is a *wedged prior instance* left + // behind by an update or hard exit, not a legitimate peer. Reap it + // proactively (TERM then KILL) — the cross-platform analogue of the macOS + // reap above. + // + // The reap is deliberately narrow (issue #3900): it targets ONLY the + // wedged GUI browser process. It never touches `OpenHuman.exe core` / + // `mcp` CLI/MCP sessions or the standalone `openhuman-core.exe` (which + // never take the CEF mutex and may be an active user session), never + // touches CEF `--type=` helper subprocesses (the OS job object reaps + // those with their parent), never touches this process's ancestors (the + // old app that spawned us during an update relaunch), and force-kills + // without `/T` so a tree walk can never reach the freshly launched app. // // This must run BEFORE `cef_singleton_wait::wait_for_cache_release()`: // a wedged prior process that never exits on its own keeps the CEF diff --git a/app/src-tauri/src/process_kill.rs b/app/src-tauri/src/process_kill.rs index 03880b1ec..1d491ab30 100644 --- a/app/src-tauri/src/process_kill.rs +++ b/app/src-tauri/src/process_kill.rs @@ -187,6 +187,33 @@ pub(crate) fn kill_pid_force(pid: u32) -> Result<(), String> { classify_taskkill_force_status(output.status.code(), &output.stderr, pid) } +/// Force-kill `pid` WITHOUT the `/T` tree flag, so only the target process is +/// terminated and its child tree is left untouched. +/// +/// The pre-CEF stale reap (issue #3900) targets a wedged GUI browser process +/// specifically. `/T` is both unnecessary and dangerous there: the OS job +/// object already reaps that process's CEF helper children when it exits, and a +/// tree walk could reach the freshly launched app if the stale process is an +/// ancestor of ours during an auto-update relaunch. Callers must revalidate the +/// pid still owns the resource before calling. Shares the "already gone" +/// success classification with [`kill_pid_force`]. +#[cfg(windows)] +pub(crate) fn kill_pid_force_no_tree(pid: u32) -> Result<(), String> { + if is_protected_windows_pid(pid) { + return Err(format!( + "refusing to force-kill protected windows pid {pid}" + )); + } + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let output = std::process::Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|e| format!("taskkill spawn: {e}"))?; + classify_taskkill_force_status(output.status.code(), &output.stderr, pid) +} + /// Classify a `taskkill /F /T /PID ` exit. Exit code 128 ("process not /// found") means the process already exited between the pid lookup and the /// force-kill — the resource is freeing on its own, treat as success. Same @@ -323,6 +350,30 @@ mod windows_tests { assert!(kill_pid_force(4).is_err()); } + #[test] + fn kill_pid_force_no_tree_refuses_protected_pids() { + assert!(kill_pid_force_no_tree(0).is_err()); + assert!(kill_pid_force_no_tree(4).is_err()); + } + + /// The non-tree force-kill (issue #3900) terminates the target and is + /// idempotent on an already-gone pid, exactly like `kill_pid_force`. + #[test] + fn kill_pid_force_no_tree_terminates_real_process_and_is_idempotent() { + let mut child = std::process::Command::new("cmd") + .args(["/C", "timeout", "/T", "30", "/NOBREAK"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()) + .spawn() + .expect("spawn child process"); + let pid = child.id(); + + kill_pid_force_no_tree(pid).expect("force-kill running process (no tree)"); + let _ = child.wait(); + kill_pid_force_no_tree(pid).expect("force-kill of already-gone pid is success"); + } + /// End-to-end-on-Windows: spawn a real child process, force-kill it, and /// verify it exits. Also covers the "process already gone" case by /// killing the same PID twice — the second call must succeed (this is diff --git a/app/src-tauri/src/process_recovery.rs b/app/src-tauri/src/process_recovery.rs index 6e4fa0013..a926b1ba4 100644 --- a/app/src-tauri/src/process_recovery.rs +++ b/app/src-tauri/src/process_recovery.rs @@ -622,13 +622,19 @@ mod linux_imp { } } -/// Windows implementation: use sysinfo to enumerate openhuman processes. +/// Windows implementation: enumerate processes via WMIC (command line included) +/// and reap only a wedged GUI CEF-lock-holder — never a legitimate `core`/`mcp` +/// CLI session, a CEF helper subprocess, or an ancestor of the current process +/// (issue #3900, hardening the #3605 pre-CEF reap). #[cfg(target_os = "windows")] mod windows_imp { - use crate::core_process; - use crate::process_kill::{kill_pid_force, kill_pid_term}; + use std::collections::{HashMap, HashSet}; + use std::path::Path; use std::time::Duration; + use crate::core_process; + use crate::process_kill::{kill_pid_force_no_tree, kill_pid_term}; + pub(crate) use super::ProcessInfo; const TERM_GRACE: Duration = Duration::from_millis(500); @@ -643,10 +649,10 @@ mod windows_imp { let self_pid = std::process::id(); log::debug!( - "[startup-recovery] windows: scanning processes for stale OpenHuman (self_pid={self_pid})" + "[startup-recovery] windows: scanning for a wedged GUI CEF-lock-holder (self_pid={self_pid})" ); - let stale = match enumerate_openhuman_processes() { + let all = match enumerate_all_processes() { Ok(procs) => procs, Err(err) => { log::warn!("[startup-recovery] windows: failed to enumerate processes: {err}"); @@ -654,21 +660,22 @@ mod windows_imp { } }; + let stale = select_reapable_gui_instances(&all, self_pid); if stale.is_empty() { - log::info!("[startup-recovery] windows: no stale OpenHuman processes found"); + log::info!("[startup-recovery] windows: no stale OpenHuman GUI instance to reap"); return; } log::info!( - "[startup-recovery] windows: found {} stale OpenHuman process(es), sending terminate", + "[startup-recovery] windows: found {} stale OpenHuman GUI instance(s), sending terminate", stale.len() ); for proc in &stale { match kill_pid_term(proc.pid) { Ok(()) => log::warn!( - "[startup-recovery] windows: TERM stale OpenHuman pid={} exe={}", + "[startup-recovery] windows: TERM stale OpenHuman GUI pid={} cmd={}", proc.pid, - proc.argv0 + proc.command ), Err(err) => log::warn!( "[startup-recovery] windows: failed to terminate pid={}: {err}", @@ -679,34 +686,47 @@ mod windows_imp { std::thread::sleep(TERM_GRACE); - let after_term = match enumerate_openhuman_processes() { + // Re-validate before force-kill: only SIGKILL pids that are STILL present + // AND still classify as a reapable GUI instance. A pid that already + // exited — or whose number was reused by an unrelated process during the + // grace — must not be killed. This closes the PID-reuse window and honors + // `kill_pid_force`'s "revalidate ownership" contract. + let after = match enumerate_all_processes() { Ok(procs) => procs, Err(err) => { log::warn!( - "[startup-recovery] windows: failed to re-enumerate after terminate: {err}" + "[startup-recovery] windows: failed to re-enumerate after terminate; skipping SIGKILL escalation: {err}" ); return; } }; + let still_reapable: HashSet = select_reapable_gui_instances(&after, self_pid) + .into_iter() + .map(|p| p.pid) + .collect(); - let stale_pids: std::collections::HashSet = stale.iter().map(|p| p.pid).collect(); let mut kill_count = 0usize; - for proc in &after_term { - if stale_pids.contains(&proc.pid) { - match kill_pid_force(proc.pid) { - Ok(()) => { - kill_count += 1; - log::warn!( - "[startup-recovery] windows: force-killed stale OpenHuman pid={} exe={}", - proc.pid, - proc.argv0 - ); - } - Err(err) => log::warn!( - "[startup-recovery] windows: failed to force-kill pid={}: {err}", - proc.pid - ), + for proc in &stale { + if !still_reapable.contains(&proc.pid) { + continue; + } + // Non-tree kill (`/F` without `/T`): the CEF helper children of the + // wedged browser process are reaped by the OS job object when it + // exits, so `/T` is unnecessary — and a tree kill could reach the + // freshly launched app during an update relaunch (issue #3900 P1). + match kill_pid_force_no_tree(proc.pid) { + Ok(()) => { + kill_count += 1; + log::warn!( + "[startup-recovery] windows: force-killed stale OpenHuman GUI pid={} cmd={}", + proc.pid, + proc.command + ); } + Err(err) => log::warn!( + "[startup-recovery] windows: failed to force-kill pid={}: {err}", + proc.pid + ), } } @@ -718,20 +738,36 @@ mod windows_imp { ); } + /// Diagnostics listing of OpenHuman-owned processes (GUI, CLI `core`/`mcp`, + /// standalone core, and CEF helpers), excluding the current process. Backs + /// the `process_diagnostics_list_owned` command — this is a *listing*, not a + /// kill list; the reap uses [`select_reapable_gui_instances`]. pub(crate) fn enumerate_openhuman_processes() -> Result, String> { + let self_pid = std::process::id(); + Ok(enumerate_all_processes()? + .into_iter() + .filter(|p| p.pid != self_pid) + .filter(|p| is_openhuman_process(&p.argv0)) + .collect()) + } + + /// Enumerate every running process with its parent pid, executable path, and + /// full command line. The command line (unavailable from the older + /// `Caption,ProcessId,…` query) is what lets the reap tell a wedged GUI + /// instance apart from a `core`/`mcp` CLI session or a CEF `--type=` helper. + /// Includes the current process so its ancestor chain can be walked. + fn enumerate_all_processes() -> Result, String> { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let self_pid = std::process::id(); - - // Use WMIC to enumerate processes with their parent PIDs and executable paths. - // Output format: Caption,ProcessId,ParentProcessId,ExecutablePath + // `/format:list` (Key=Value blocks) instead of CSV: command lines and + // paths routinely contain commas, which corrupts CSV field splitting. let output = std::process::Command::new("wmic") .args([ "process", "get", - "Caption,ProcessId,ParentProcessId,ExecutablePath", - "/format:csv", + "Caption,CommandLine,ExecutablePath,ParentProcessId,ProcessId", + "/format:list", ]) .creation_flags(CREATE_NO_WINDOW) .output() @@ -742,144 +778,306 @@ mod windows_imp { } let stdout = String::from_utf8_lossy(&output.stdout); - Ok(parse_wmic_output(&stdout, self_pid)) + Ok(parse_wmic_list_output(&stdout)) } - fn parse_wmic_output(stdout: &str, self_pid: u32) -> Vec { - let mut results = Vec::new(); - let mut lines = stdout.lines(); + /// Parse `wmic ... /format:list` output: records are blocks of `Key=Value` + /// lines separated by a blank line. Robust to commas inside `CommandLine` / + /// `ExecutablePath` (the reason we dropped the CSV format). + fn parse_wmic_list_output(stdout: &str) -> Vec { + // Normalize CR (wmic list emits `\r\r\n`) then split records on blanks. + stdout + .replace('\r', "") + .split("\n\n") + .filter_map(parse_wmic_list_record) + .collect() + } - // Skip header lines until we find the CSV header row. - let header = loop { - match lines.next() { - Some(line) if line.trim().starts_with("Node,") => break line, - Some(_) => continue, - None => return results, - } - }; - - // Find column indices from the header. - let cols: Vec<&str> = header.split(',').collect(); - let idx_caption = cols.iter().position(|c| c.trim() == "Caption"); - let idx_pid = cols.iter().position(|c| c.trim() == "ProcessId"); - let idx_ppid = cols.iter().position(|c| c.trim() == "ParentProcessId"); - let idx_exe = cols.iter().position(|c| c.trim() == "ExecutablePath"); - - let (Some(idx_caption), Some(idx_pid), Some(idx_ppid), Some(idx_exe)) = - (idx_caption, idx_pid, idx_ppid, idx_exe) - else { - log::warn!("[startup-recovery] windows: wmic CSV header missing expected columns"); - return results; - }; - - for line in lines { - let line = line.trim(); - if line.is_empty() { + fn parse_wmic_list_record(record: &str) -> Option { + let mut caption = ""; + let mut command_line = ""; + let mut exe_path = ""; + let mut ppid: u32 = 0; + let mut pid: Option = None; + for line in record.lines() { + let Some((key, value)) = line.split_once('=') else { continue; - } - let fields: Vec<&str> = line.splitn(cols.len(), ',').collect(); - if fields.len() < cols.len() { - continue; - } - - let caption = fields[idx_caption].trim(); - let exe_path = fields[idx_exe].trim(); - let pid: u32 = match fields[idx_pid].trim().parse() { - Ok(p) => p, - Err(_) => continue, }; - let ppid: u32 = fields[idx_ppid].trim().parse().unwrap_or(0); - - if pid == self_pid { - continue; + let value = value.trim(); + match key.trim() { + "Caption" => caption = value, + "CommandLine" => command_line = value, + "ExecutablePath" => exe_path = value, + "ParentProcessId" => ppid = value.parse().unwrap_or(0), + "ProcessId" => pid = value.parse().ok(), + _ => {} } - - let argv0 = if !exe_path.is_empty() { - exe_path.to_string() - } else { - caption.to_string() - }; - - if !is_openhuman_executable(caption, exe_path) { - continue; - } - - log::debug!( - "[startup-recovery] windows: found OpenHuman process pid={pid} argv0={argv0}" - ); - results.push(ProcessInfo { - pid, - ppid, - argv0: argv0.clone(), - command: argv0, - }); } - - results + let pid = pid?; + let argv0 = if !exe_path.is_empty() { + exe_path.to_string() + } else { + caption.to_string() + }; + let command = if !command_line.is_empty() { + command_line.to_string() + } else { + argv0.clone() + }; + Some(ProcessInfo { + pid, + ppid, + argv0, + command, + }) } - fn is_openhuman_executable(caption: &str, exe_path: &str) -> bool { - let caption_lower = caption.to_ascii_lowercase(); - let exe_filename = std::path::Path::new(exe_path) + /// True for OpenHuman-owned processes of any role (GUI, CLI, standalone + /// core, CEF helper). Used only for the diagnostics listing. + fn is_openhuman_process(argv0: &str) -> bool { + let name = exe_file_name(argv0); + name == "openhuman.exe" || name == "openhuman-core.exe" + } + + /// True only for a wedged GUI browser process that is safe to reap: the + /// desktop app binary (`OpenHuman.exe`, never the standalone + /// `openhuman-core.exe`), NOT a CEF helper re-exec (`--type=`), and NOT a + /// `core` / `mcp` / `mcp-server` CLI/MCP session — those never take the CEF + /// mutex and may be an active user session, e.g. a Claude MCP client + /// (issue #3900 P2; see `main.rs` for the subcommand routing). + fn is_reapable_gui_instance(argv0: &str, command_line: &str) -> bool { + if exe_file_name(argv0) != "openhuman.exe" { + return false; + } + if command_line_has_type_flag(command_line) { + return false; + } + !matches!( + first_subcommand(command_line).as_deref(), + Some("core") | Some("mcp") | Some("mcp-server") + ) + } + + /// Lowercased file name of an executable path or caption. + fn exe_file_name(argv0: &str) -> String { + Path::new(argv0) .file_name() .and_then(|n| n.to_str()) - .unwrap_or(exe_path) - .to_ascii_lowercase(); - caption_lower == "openhuman-core.exe" - || caption_lower == "openhuman.exe" - || exe_filename == "openhuman-core.exe" - || exe_filename == "openhuman.exe" + .unwrap_or(argv0) + .to_ascii_lowercase() + } + + /// Whether the command line carries a CEF helper role flag (`--type=…`). + fn command_line_has_type_flag(command_line: &str) -> bool { + command_line + .split_whitespace() + .any(|tok| tok.trim_matches('"').starts_with("--type=")) + } + + /// Extract argv[1] (the subcommand) from a Windows command line, lowercased. + /// Handles a quoted or unquoted program path in argv[0]. `None` when there + /// is no second argument. + fn first_subcommand(command_line: &str) -> Option { + let s = command_line.trim_start(); + let rest = if let Some(after_quote) = s.strip_prefix('"') { + // Quoted argv0: skip to the closing quote. + let end = after_quote.find('"')?; + &after_quote[end + 1..] + } else { + // Unquoted argv0: skip to the first whitespace. + match s.find(char::is_whitespace) { + Some(i) => &s[i..], + None => "", + } + }; + let rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let token = rest[..end].trim_matches('"'); + (!token.is_empty()).then(|| token.to_ascii_lowercase()) + } + + /// PIDs of the current process's ancestor chain (parent, grandparent, …). + /// During an auto-update relaunch the *old* app spawns the new one, so the + /// old (possibly still-wedged) instance is our ancestor; excluding ancestors + /// stops the reap from killing the process that launched us — and, with the + /// non-tree force-kill, closes the #3900 P1 self-termination hole entirely. + fn collect_ancestor_pids(all: &[ProcessInfo], self_pid: u32) -> HashSet { + let ppid_of: HashMap = all.iter().map(|p| (p.pid, p.ppid)).collect(); + let mut ancestors = HashSet::new(); + let mut cursor = ppid_of.get(&self_pid).copied(); + while let Some(pid) = cursor { + // pid 0 = no-parent sentinel; stop. `insert` returning false means a + // cycle (possible under pid reuse) — break to avoid an infinite loop. + if pid == 0 || !ancestors.insert(pid) { + break; + } + cursor = ppid_of.get(&pid).copied(); + } + ancestors + } + + /// The wedged GUI instances that are safe to reap: reapable GUI processes + /// (see [`is_reapable_gui_instance`]) minus the current process and its + /// ancestors, deduplicated by pid. + fn select_reapable_gui_instances(all: &[ProcessInfo], self_pid: u32) -> Vec { + let ancestors = collect_ancestor_pids(all, self_pid); + let mut seen = HashSet::new(); + all.iter() + .filter(|p| p.pid != self_pid) + .filter(|p| !ancestors.contains(&p.pid)) + .filter(|p| is_reapable_gui_instance(&p.argv0, &p.command)) + .filter(|p| seen.insert(p.pid)) + .cloned() + .collect() } #[cfg(test)] mod tests { use super::*; + fn proc(pid: u32, ppid: u32, argv0: &str, command: &str) -> ProcessInfo { + ProcessInfo { + pid, + ppid, + argv0: argv0.to_string(), + command: command.to_string(), + } + } + #[test] - fn parse_wmic_output_finds_openhuman_processes() { - let csv = "\ -Node,Caption,ExecutablePath,ParentProcessId,ProcessId\r\n\ -\r\n\ -DESKTOP-ABC,openhuman-core.exe,C:\\Program Files\\OpenHuman\\openhuman-core.exe,1234,5678\r\n\ -DESKTOP-ABC,chrome.exe,C:\\Program Files\\Google\\Chrome\\chrome.exe,1,9000\r\n\ -"; - let results = parse_wmic_output(csv, 9999); - assert_eq!(results.len(), 1); + fn parse_wmic_list_output_reads_command_line_with_commas() { + // A command line containing commas would corrupt CSV parsing; the + // list format must preserve it intact. + let list = "\r\n\ +Caption=OpenHuman.exe\r\r\n\ +CommandLine=\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" --flag=a,b,c\r\r\n\ +ExecutablePath=C:\\Program Files\\OpenHuman\\OpenHuman.exe\r\r\n\ +ParentProcessId=1234\r\r\n\ +ProcessId=5678\r\r\n\ +\r\r\n\ +Caption=chrome.exe\r\r\n\ +CommandLine=chrome.exe\r\r\n\ +ExecutablePath=C:\\chrome.exe\r\r\n\ +ParentProcessId=1\r\r\n\ +ProcessId=9000\r\r\n"; + let results = parse_wmic_list_output(list); + assert_eq!(results.len(), 2); assert_eq!(results[0].pid, 5678); assert_eq!(results[0].ppid, 1234); - assert!(results[0].argv0.contains("openhuman-core")); + assert!(results[0].command.ends_with("--flag=a,b,c")); + assert_eq!(results[1].pid, 9000); } #[test] - fn parse_wmic_output_excludes_self_pid() { - let csv = "\ -Node,Caption,ExecutablePath,ParentProcessId,ProcessId\r\n\ -\r\n\ -DESKTOP-ABC,openhuman-core.exe,C:\\Program Files\\OpenHuman\\openhuman-core.exe,1,1234\r\n\ -"; - let results = parse_wmic_output(csv, 1234); - assert!(results.is_empty(), "self pid should be excluded"); + fn first_subcommand_handles_quoted_and_unquoted_argv0() { + assert_eq!( + first_subcommand("\"C:\\p\\OpenHuman.exe\" core --port 7788").as_deref(), + Some("core") + ); + assert_eq!( + first_subcommand("C:\\p\\OpenHuman.exe mcp").as_deref(), + Some("mcp") + ); + assert_eq!( + first_subcommand("\"C:\\p\\OpenHuman.exe\"").as_deref(), + None + ); + assert_eq!(first_subcommand("OpenHuman.exe").as_deref(), None); } #[test] - fn is_openhuman_executable_matches_core() { - assert!(is_openhuman_executable( - "openhuman-core.exe", - "C:\\path\\openhuman-core.exe" + fn is_reapable_gui_instance_only_matches_the_gui_browser_process() { + // GUI browser process (no subcommand, no --type=) → reapable. + assert!(is_reapable_gui_instance( + "C:\\p\\OpenHuman.exe", + "\"C:\\p\\OpenHuman.exe\"" )); - assert!(is_openhuman_executable( - "OpenHuman.exe", - "C:\\path\\OpenHuman.exe" + // #3900 P2: CLI core / MCP sessions must never be reaped. + assert!(!is_reapable_gui_instance( + "C:\\p\\OpenHuman.exe", + "\"C:\\p\\OpenHuman.exe\" core --port 7788" )); + assert!(!is_reapable_gui_instance( + "C:\\p\\OpenHuman.exe", + "\"C:\\p\\OpenHuman.exe\" mcp" + )); + assert!(!is_reapable_gui_instance( + "C:\\p\\OpenHuman.exe", + "\"C:\\p\\OpenHuman.exe\" mcp-server" + )); + // CEF helper re-execs carry --type= → not the browser process. + assert!(!is_reapable_gui_instance( + "C:\\p\\OpenHuman.exe", + "\"C:\\p\\OpenHuman.exe\" --type=renderer --enable-features=x" + )); + // The standalone core binary is never a GUI CEF-lock-holder. + assert!(!is_reapable_gui_instance( + "C:\\p\\openhuman-core.exe", + "openhuman-core.exe run" + )); + // Unrelated processes. + assert!(!is_reapable_gui_instance("C:\\chrome.exe", "chrome.exe")); } #[test] - fn is_openhuman_executable_rejects_unrelated() { - assert!(!is_openhuman_executable( - "chrome.exe", - "C:\\Chrome\\chrome.exe" - )); - assert!(!is_openhuman_executable("python.exe", "C:\\python.exe")); + fn collect_ancestor_pids_walks_the_parent_chain() { + // self(500) → parent 400 (old OpenHuman) → grandparent 300 (explorer) + let all = vec![ + proc(300, 1, "explorer.exe", "explorer.exe"), + proc(400, 300, "OpenHuman.exe", "\"OpenHuman.exe\""), + proc(500, 400, "OpenHuman.exe", "\"OpenHuman.exe\""), + ]; + let ancestors = collect_ancestor_pids(&all, 500); + assert!(ancestors.contains(&400)); + assert!(ancestors.contains(&300)); + assert!(!ancestors.contains(&500)); + } + + #[test] + fn select_reapable_excludes_self_ancestors_and_non_gui() { + // 500 = self (new app), 400 = update-relaunch parent (old app, + // still holds the CEF lock), 900 = an unrelated wedged GUI instance + // to reap, 700 = a legit `core` CLI session, 800 = a CEF helper. + let all = vec![ + proc(300, 1, "explorer.exe", "explorer.exe"), + proc(400, 300, "OpenHuman.exe", "\"OpenHuman.exe\""), + proc(500, 400, "OpenHuman.exe", "\"OpenHuman.exe\""), + proc( + 700, + 1, + "OpenHuman.exe", + "\"OpenHuman.exe\" core --port 7788", + ), + proc( + 800, + 900, + "OpenHuman.exe", + "\"OpenHuman.exe\" --type=gpu-process", + ), + proc(900, 1, "OpenHuman.exe", "\"OpenHuman.exe\""), + ]; + let reapable: Vec = select_reapable_gui_instances(&all, 500) + .into_iter() + .map(|p| p.pid) + .collect(); + assert_eq!( + reapable, + vec![900], + "only the unrelated wedged GUI instance is reaped; self (500), \ + ancestor (400), CLI core (700) and CEF helper (800) are spared" + ); + } + + #[test] + fn is_openhuman_process_matches_gui_and_core_only() { + assert!(is_openhuman_process("C:\\p\\OpenHuman.exe")); + assert!(is_openhuman_process("C:\\p\\openhuman-core.exe")); + assert!(is_openhuman_process("OpenHuman.exe")); + assert!(!is_openhuman_process("C:\\Chrome\\chrome.exe")); + assert!(!is_openhuman_process("python.exe")); } } }