From 25580348e3f560fed3129654217befb96379e377 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 6 Nov 2026 17:26:16 -0800 Subject: [PATCH 1/2] Test the bundled CLI stop under the 32-bit PowerShell the installer starts The NSIS installer is a 32-bit program, so the hook's powershell.exe is the SysWOW64 one. Run the hook's own script with that PowerShell against a 64-bit process from a cli-bundle folder, and require that the process is gone and its file deletable as soon as the script returns, while a copy outside cli-bundle keeps running. The existing 64-bit test now checks the same thing. --- desktop/src-tauri/src/recorder_install.rs | 46 ++++++++++++++++++----- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/recorder_install.rs b/desktop/src-tauri/src/recorder_install.rs index aa8ebea34d..e01ea5c2ab 100644 --- a/desktop/src-tauri/src/recorder_install.rs +++ b/desktop/src-tauri/src/recorder_install.rs @@ -1108,10 +1108,33 @@ mod tests { #[cfg(windows)] #[test] fn the_bundled_cli_stop_ends_only_what_runs_from_this_installations_cli_bundle() { + bundled_cli_stop_under("System32"); + } + + /// The installer is a 32-bit program, so the powershell.exe its hook starts is the 32-bit one + /// in SysWOW64, and the bundled node.exe is a 64-bit process. Get-Process in a 32-bit + /// PowerShell has no Path for a 64-bit process: the stop matched nothing, node.exe stayed + /// locked, and an upgrade of a Desktop that had run Intelligence stopped at "Error opening file + /// for writing". The fixture here is 64-bit too, as cargo builds it on x64 Windows. + #[cfg(windows)] + #[test] + fn the_bundled_cli_stop_works_from_the_32_bit_powershell_the_installer_starts() { + bundled_cli_stop_under("SysWOW64"); + } + + /// Runs the hook's script with the Windows PowerShell in %SystemRoot%\`system`. When it + /// returns, the process from cli-bundle must already be gone and its file free to replace: the + /// installer writes node.exe right after the hook, and a process still exiting keeps it locked. + #[cfg(windows)] + fn bundled_cli_stop_under(system: &str) { let script = bundled_cli_stop_script().replace("$$", "$"); + let powershell = Path::new(&std::env::var_os("SystemRoot").unwrap()) + .join(system) + .join(r"WindowsPowerShell\v1.0\powershell.exe"); + assert!(powershell.is_file(), "{}", powershell.display()); let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("target") - .join(format!("hook-test-{}", std::process::id())) + .join(format!("hook-test-{system}-{}", std::process::id())) .join("O'Brien [OpenBot]"); let runtime = base.join("cli-bundle").join("runtime"); std::fs::create_dir_all(&runtime).unwrap(); @@ -1127,7 +1150,7 @@ mod tests { std::fs::copy(&inside, &beside).unwrap(); let mut bundled = std::process::Command::new(&inside).spawn().unwrap(); let mut other = std::process::Command::new(&beside).spawn().unwrap(); - let status = std::process::Command::new("powershell.exe") + let status = std::process::Command::new(&powershell) .args([ "-NoProfile", "-NonInteractive", @@ -1139,20 +1162,25 @@ mod tests { .env("OPENBOT_CLI_BUNDLE", base.join("cli-bundle")) .status() .unwrap(); - assert!(status.success(), "{script}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); - while bundled.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline { - std::thread::sleep(std::time::Duration::from_millis(100)); - } let stopped = bundled.try_wait().unwrap().is_some(); + let replaceable = std::fs::remove_file(&inside); let spared = other.try_wait().unwrap().is_none(); let _ = bundled.kill(); let _ = other.kill(); let _ = bundled.wait(); let _ = other.wait(); let _ = std::fs::remove_dir_all(base.parent().unwrap()); - assert!(stopped, "the process under cli-bundle is still running"); - assert!(spared, "a process outside cli-bundle was stopped"); + let shell = powershell.display(); + assert!(status.success(), "{shell}: {script}"); + assert!( + stopped, + "{shell}: the process under cli-bundle is still running" + ); + assert!( + replaceable.is_ok(), + "{shell}: the bundled node.exe is still locked: {replaceable:?}" + ); + assert!(spared, "{shell}: a process outside cli-bundle was stopped"); } #[test] From c2497ba0c72d7a631bd0ba44d6b5ede9441f0abf Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 6 Nov 2026 17:28:11 -0800 Subject: [PATCH 2/2] Find the bundled CLI by WMI in the installer hook and wait for it to exit The NSIS installer is a 32-bit program, so its powershell.exe is the 32-bit one under WOW64. There Get-Process has no Path for a 64-bit process, so the stop matched nothing, the bundled node.exe stayed locked, and upgrading a Desktop that had run Intelligence failed with "Error opening file for writing". Win32_Process.ExecutablePath is filled in at either bitness. Stop-Process returns before the process has exited and the file stays locked until it has, so the hook now waits for each stopped process, up to 30 seconds. --- desktop/src-tauri/windows/hooks.nsh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/windows/hooks.nsh b/desktop/src-tauri/windows/hooks.nsh index 4311a588a6..0081dc5ae4 100644 --- a/desktop/src-tauri/windows/hooks.nsh +++ b/desktop/src-tauri/windows/hooks.nsh @@ -21,11 +21,16 @@ ; $INSTDIR\cli-bundle\runtime\node.exe, and the uninstaller failed on exactly that. Before either, stop every ; process running from this installation's own cli-bundle, and nothing else: another node.exe is never touched. ; Intelligence brings the service back the next time it starts. +; The installer is a 32-bit program, so the powershell.exe it starts is the 32-bit one (WOW64 redirects System32 to +; SysWOW64), while node.exe is 64-bit. A 32-bit PowerShell's Get-Process has no Path for a 64-bit process, and the +; stop used to match nothing; WMI's Win32_Process has the ExecutablePath at either bitness, so the processes are found +; there. Stop-Process returns before a process has exited, and node.exe stays locked until it has, so the hook waits +; for each one it stopped (30 seconds at most) before the installer goes on to replace the file. ; The folder reaches PowerShell as data, in OPENBOT_CLI_BUNDLE, never inside the script: a profile folder like ; C:\Users\O'Brien would end a quoted string there, and [ and ] are wildcards to -like. !macro OPENBOT_STOP_BUNDLED_CLI System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("OPENBOT_CLI_BUNDLE", "$INSTDIR\cli-bundle")' - nsExec::Exec `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$bundle = $$env:OPENBOT_CLI_BUNDLE.TrimEnd('\') + '\'; Get-Process -ErrorAction SilentlyContinue | Where-Object { $$_.Path -and $$_.Path.StartsWith($$bundle, [System.StringComparison]::OrdinalIgnoreCase) } | Stop-Process -Force -ErrorAction SilentlyContinue"` + nsExec::Exec `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$bundle = $$env:OPENBOT_CLI_BUNDLE.TrimEnd('\') + '\'; Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$bundle, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Get-Process -Id $$_.ProcessId -ErrorAction SilentlyContinue } | Stop-Process -Force -PassThru -ErrorAction SilentlyContinue | ForEach-Object { $$null = $$_.WaitForExit(30000) }"` Pop $0 !macroend