fix(desktop): stop the bundled runtime from the Windows installer (#16071)

An update started by a desktop build released before the app-side runtime stop can still leave the bundled Node daemon holding node.exe while NSIS replaces the runtime. Clear that lock from the installer pre-install hook by image path, wait boundedly for the image to become writable, and abort before copying unless PowerShell returns an explicit success. Pin the behavior in the desktop release gate.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-11-02 19:40:13 +08:00
1 parent 60f5c1ed95
commit 5df87f7080
2 files changed
+94

No files matched your search

+56
View File
@@ -53,6 +53,7 @@ try {
testZoomHotkeyScript();
await testBootstrapWorkspaceVisibility();
testLegacyApplicationIdentity();
testWindowsInstallerStopsBundledRuntime();
testElectronBridgeWorkflow();
testReleaseMatrixCoversUpdaterPlatforms();
testLinuxReleaseLegsShareGlibcFloor();
@@ -274,6 +275,61 @@ function testLegacyApplicationIdentity() {
assert.match(migrationHook, /\$\{If\} \$R2 != 0\s*\n\s*Abort/);
}
function testWindowsInstallerStopsBundledRuntime() {
const hook = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'windows', 'electron-migration.nsh'),
'utf8',
);
const macroBody = (name) => {
const match = hook.match(
new RegExp(`!macro ${name}\\s([\\s\\S]*?)!macroend`),
);
assert.ok(match, `${name} must define the installer step`);
return match[1];
};
// An update driven by a build that predates the app-side runtime stop can
// only be unlocked by the installer, and the pre-install stage is the only
// hook that runs before the copy stage (#15933).
assert.match(
macroBody('NSIS_HOOK_PREINSTALL'),
/!insertmacro StopBundledRuntime/,
);
assert.ok(
hook.indexOf('!macro StopBundledRuntime') <
hook.indexOf('!insertmacro StopBundledRuntime'),
'StopBundledRuntime must be defined before the hook inserts it',
);
const stop = macroBody('StopBundledRuntime');
assert.match(
stop,
/StrCpy \$R0 "\$INSTDIR\\runtime\\qwen-code\\node\\node\.exe"/,
);
// Matching the image path, not the process name, is what keeps a node.exe
// the user runs elsewhere alive; a name-only kill has to redden the gate.
assert.match(stop, /Get-CimInstance Win32_Process/);
assert.match(
stop,
/\$\$_\.ExecutablePath\.StartsWith\(\$\$root,\[StringComparison\]::OrdinalIgnoreCase\)/,
);
assert.match(stop, /Stop-Process -Id \$\$_\.ProcessId -Force/);
assert.doesNotMatch(stop, /KillProcess|taskkill/i);
// Killing is not unlocking: the step has to wait for the image to become
// writable instead of running into the copy while a handle lingers.
assert.match(
stop,
/\[IO\.File\]::Open\(\$\$exe,\[IO\.FileMode\]::Open,\[IO\.FileAccess\]::Write,/,
);
assert.match(stop, /if\(\$\$locked\)\{exit 1\}/);
// Only an explicit zero continues. A lock that survives the wait, or
// nsExec failing to run PowerShell, must abort before the copy rather than
// falling through to the Ignore dialog. The message gives the user an
// actionable recovery path; a silent install must not show a dialog.
assert.match(
stop,
/\$\{If\} \$R1 != "0"\s*\n\s*\$\{If\} \$\{Silent\}\s*\n\s*Abort\s*\n\s*\$\{Else\}\s*\n\s*Abort "/,
);
}
function testElectronBridgeWorkflow() {
const workflow = fs.readFileSync(
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
@@ -1,7 +1,45 @@
!define ELECTRON_INSTALL_KEY "Software\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3"
!define ELECTRON_UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3"
; Stops the bundled Node runtime that outlived a desktop build released before
; the app-side runtime stop: that build registers no updater `on_before_exit`
; hook, and the updater's `process::exit(0)` skips `RunEvent::Exit`, so its
; daemon still maps $INSTDIR\runtime\qwen-code\node\node.exe when the copy
; stage replaces that file (#15933). The installer that performs that
; transition update is this one, so clearing the lock is its job.
;
; Processes are matched by image path, not by name: `node.exe` processes the
; user runs from anywhere else are left alone. An interactive `qwen` session
; started from this installation runs that same image and holds the same lock,
; so it is stopped too -- the copy cannot succeed while it runs. NSIS cannot
; enumerate by image path itself (`nsis_tauri_utils` matches names only, and
; `wmic` no longer ships with current Windows), so the enumeration goes through
; PowerShell's CIM client. Any result other than exit code 0 -- including a
; lock surviving the wait or nsExec failing to start PowerShell -- stops the
; install before the copy stage.
!macro StopBundledRuntime
StrCpy $R0 "$INSTDIR\runtime\qwen-code\node\node.exe"
${If} ${FileExists} "$R0"
System::Call 'kernel32::SetEnvironmentVariableW(t "QWEN_CODE_DESKTOP_NODE_EXE", t "$R0")'
nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "$$exe=$$env:QWEN_CODE_DESKTOP_NODE_EXE;if(-not $$exe){exit 0};$$root=[IO.Path]::GetDirectoryName($$exe)+[IO.Path]::DirectorySeparatorChar;$$locked=$$true;foreach($$round in 1..2){Get-CimInstance Win32_Process|Where-Object{$$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$root,[StringComparison]::OrdinalIgnoreCase)}|ForEach-Object{Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue};$$deadline=(Get-Date).AddSeconds(5);while((Get-Date) -lt $$deadline){try{$$stream=[IO.File]::Open($$exe,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::Read);$$stream.Close();$$locked=$$false;break}catch{Start-Sleep -Milliseconds 250}};if(-not $$locked){break}};if($$locked){exit 1}else{exit 0}"'
Pop $R1
; Clearing the lock is the point of this step, and failing here beats
; letting the copy stage offer Ignore, which leaves a half-replaced
; runtime nobody verifies. A silent install must not block on a dialog.
${If} $R1 != "0"
${If} ${Silent}
Abort
${Else}
Abort "Qwen Code could not stop the bundled runtime that locks $R0. Close every Qwen Code window, then run the installer again."
${EndIf}
${EndIf}
${EndIf}
!macroend
!macro NSIS_HOOK_PREINSTALL
; Runs before the Tauri installer template's copy stage.
!insertmacro StopBundledRuntime
ReadRegStr $R0 HKCU "${ELECTRON_INSTALL_KEY}" "InstallLocation"
ReadRegStr $R1 HKCU "${ELECTRON_UNINSTALL_KEY}" "DisplayName"
StrCpy $R1 $R1 17