From 421ddf553cd1f574c850c224a340125601b30174 Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Mon, 11 May 2026 16:35:07 +0800 Subject: [PATCH] fix(pack/win): close running app before silent reinstall (#1238) --- e2e/specs/win.spec.ts | 54 ++++++++- tools/pack/src/win/custom-installer.ts | 147 ++++++++++++++++++++++--- 2 files changed, 181 insertions(+), 20 deletions(-) diff --git a/e2e/specs/win.spec.ts b/e2e/specs/win.spec.ts index 76b6780d0b..b8f0fdf5ec 100644 --- a/e2e/specs/win.spec.ts +++ b/e2e/specs/win.spec.ts @@ -125,6 +125,11 @@ type SmokeTiming = { step: string; }; +type DirectInstallerResult = { + code: number | null; + nsisLogTail: string[]; +}; + const shouldRunPackagedWinSmoke = process.platform === 'win32' && process.env.OD_PACKAGED_E2E_WIN === '1'; const winDescribe = shouldRunPackagedWinSmoke ? describe : describe.skip; @@ -172,7 +177,7 @@ winDescribe('packaged windows runtime smoke', () => { ); } - const start = await measureSmokeStep(timings, 'start', async () => runToolsPackJson('start')); + let start = await measureSmokeStep(timings, 'start', async () => runToolsPackJson('start')); started = true; expect(start.namespace).toBe(namespace); @@ -191,6 +196,27 @@ winDescribe('packaged windows runtime smoke', () => { expect(value.health.ok).toBe(true); expect(value.health.version).toEqual(expect.any(String)); + const reinstall = await measureSmokeStep(timings, 'direct reinstall while running', async () => + runDirectInstaller(install.installerPath, install.installDir), + ); + started = false; + expect(reinstall.code).toBe(0); + expect(reinstall.nsisLogTail.join('\n')).toContain('running instances detected before silent install'); + expect(reinstall.nsisLogTail.join('\n')).toContain('running instances close exit=0'); + + start = await measureSmokeStep(timings, 'restart after direct reinstall', async () => + runToolsPackJson('start'), + ); + started = true; + expect(start.namespace).toBe(namespace); + expect(start.source).toBe('installed'); + expectPathInside(start.executablePath, install.installDir); + + const postReinstallInspect = await measureSmokeStep(timings, 'wait healthy inspect after reinstall', async () => + waitForHealthyDesktop(), + ); + expect(postReinstallInspect.status?.state).toBe('running'); + await mkdir(dirname(screenshotPath), { recursive: true }); const screenshot = await measureSmokeStep(timings, 'inspect screenshot', async () => runToolsPackJson('inspect', ['--path', screenshotPath]), @@ -235,6 +261,7 @@ winDescribe('packaged windows runtime smoke', () => { installTiming, logs: summarizeLogs(logs), namespace, + reinstall, screenshot: report.screenshotRelpath, start: { executablePath: start.executablePath, @@ -331,6 +358,29 @@ async function runToolsPackJson(action: string, extraArgs: string[] = []): Pr } } +async function runDirectInstaller(installerPath: string, installDir: string): Promise { + const previousLogLines = await readNsisLogLines(); + const error = await execFileAsync(installerPath, ['/S', `/D=${installDir}`], { + cwd: dirname(installerPath), + env: process.env, + maxBuffer: 20 * 1024 * 1024, + windowsVerbatimArguments: true, + }).then( + () => null, + (caught: unknown) => caught, + ); + const code = isExecError(error) ? Number(error.code) : error == null ? 0 : null; + return { + code, + nsisLogTail: (await readNsisLogLines()).slice(previousLogLines.length), + }; +} + +async function readNsisLogLines(): Promise { + const raw = await readFile(join(outputNamespaceRoot, 'logs', 'nsis.log'), 'utf8').catch(() => ''); + return raw.split(/\r?\n/).filter((line) => line.length > 0); +} + async function waitForHealthyDesktop(): Promise { const timeoutMs = 90_000; const startedAt = Date.now(); @@ -447,7 +497,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value != null && !Array.isArray(value); } -function isExecError(value: unknown): value is { message: string; stderr: string; stdout: string } { +function isExecError(value: unknown): value is { code?: unknown; message: string; stderr: string; stdout: string } { return ( isRecord(value) && typeof value.message === 'string' && diff --git a/tools/pack/src/win/custom-installer.ts b/tools/pack/src/win/custom-installer.ts index 7016a72775..2de5fd77c3 100644 --- a/tools/pack/src/win/custom-installer.ts +++ b/tools/pack/src/win/custom-installer.ts @@ -89,6 +89,80 @@ async function resolveMakensisCommand(config: ToolPackConfig): Promise { throw new Error("makensis is required to build the Windows installer; install NSIS or populate the electron-builder NSIS cache"); } +function createRunningInstancesScript(): string { + return `param( + [ValidateSet("detect", "close")] + [string]$Action, + [string]$Install, + [string]$Registered +) + +$ErrorActionPreference = "Stop" + +$roots = @($Install, $Registered) | + Where-Object { $_ } | + ForEach-Object { + $root = $_.TrimEnd([char]92).ToLowerInvariant() + [pscustomobject]@{ Exact = $root; Prefix = ($root + [char]92) } + } | + Select-Object -Unique Exact, Prefix + +$matches = Get-CimInstance Win32_Process | Where-Object { + $matched = $false + $exe = $_.ExecutablePath + if ($null -ne $exe) { + $exe = $exe.ToLowerInvariant() + foreach ($root in $roots) { + if ($root.Exact -and (($exe -eq $root.Exact) -or $exe.StartsWith($root.Prefix))) { + $matched = $true + break + } + } + } else { + $cmd = $_.CommandLine + if ($null -ne $cmd) { + $cmdLc = $cmd.ToLowerInvariant() + foreach ($root in $roots) { + if ($root.Prefix -and $cmdLc.Contains($root.Prefix)) { + $matched = $true + break + } + } + } + } + $matched +} + +$ids = @($matches | ForEach-Object { $_.ProcessId }) +if ($Action -eq "close") { + foreach ($id in $ids) { + try { [void][System.Diagnostics.Process]::GetProcessById($id).CloseMainWindow() } catch {} + } + Start-Sleep -Milliseconds 1500 + foreach ($id in $ids) { + try { + $p = [System.Diagnostics.Process]::GetProcessById($id) + if (-not $p.HasExited) { + Stop-Process -Id $id -Force -ErrorAction SilentlyContinue + } + } catch {} + } + foreach ($id in $ids) { + try { + $p = [System.Diagnostics.Process]::GetProcessById($id) + if (-not $p.HasExited) { + [void]$p.WaitForExit(5000) + } + } catch {} + } +} + +if ($ids) { + $matches | ForEach-Object { [string]$_.ProcessId + [char]32 + $_.Name } +} +`; +} + async function writeInstallerScript(config: ToolPackConfig, paths: WinPaths): Promise { const identity = resolveWinInstallIdentity(config); const productName = escapeNsisString(identity.displayName); @@ -100,8 +174,10 @@ async function writeInstallerScript(config: ToolPackConfig, paths: WinPaths): Pr const namespace = escapeNsisString(config.namespace); const localDataRoot = `$APPDATA\\${escapeNsisString(PRODUCT_NAME)}\\namespaces\\${escapeNsisString(sanitizeNamespace(config.namespace))}`; const nsisLogPath = escapeNsisString(paths.nsisLogPath); + const runningInstancesScriptPath = join(dirname(paths.installerScriptPath), "running-instances.ps1"); await mkdir(dirname(paths.installerScriptPath), { recursive: true }); + await writeFile(runningInstancesScriptPath, createRunningInstancesScript(), "utf8"); const script = `Unicode true ManifestDPIAware true RequestExecutionLevel user @@ -124,6 +200,9 @@ RequestExecutionLevel user !ifndef APP_VERSION !error "APP_VERSION define is required" !endif +!ifndef RUNNING_INSTANCES_PS1 + !error "RUNNING_INSTANCES_PS1 define is required" +!endif !include "MUI2.nsh" !include "LogicLib.nsh" @@ -180,6 +259,7 @@ Var RemoveDesktopShortcutState Var RemoveLocalDataState Var RunningInstancesOutput Var ExistingInstallLocation +Var RunningInstancesInstallRoot Var LE Var LT Var LX @@ -258,19 +338,15 @@ FunctionEnd Function DetectRunningInstances Push $0 Push $1 - ; Match by ExecutablePath under the install root when available. WMI returns - ; null ExecutablePath for processes the caller cannot fully introspect - ; (insufficient access, processes mid-spawn). For those rows, fall back to - ; CommandLine containing the install-root prefix, which is OD-specific - ; enough to avoid false positives without resorting to global Name matching. - ; Issue #821. - nsExec::ExecToStack 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param($$install, $$registered); $$roots = @($$install, $$registered) | Where-Object { $$_ } | ForEach-Object { $$root = $$_.TrimEnd([char]92).ToLowerInvariant(); [pscustomobject]@{ Exact = $$root; Prefix = ($$root + [char]92) } } | Select-Object -Unique Exact, Prefix; $$matches = Get-CimInstance Win32_Process | Where-Object { $$matched = $$false; $$exe = $$_.ExecutablePath; if ($$null -ne $$exe) { $$exe = $$exe.ToLowerInvariant(); foreach ($$root in $$roots) { if ($$root.Exact -and (($$exe -eq $$root.Exact) -or $$exe.StartsWith($$root.Prefix))) { $$matched = $$true; break } } } else { $$cmd = $$_.CommandLine; if ($$null -ne $$cmd) { $$cmdLc = $$cmd.ToLowerInvariant(); foreach ($$root in $$roots) { if ($$root.Prefix -and $$cmdLc.Contains($$root.Prefix)) { $$matched = $$true; break } } } }; $$matched }; if ($$matches) { $$matches | ForEach-Object { [string]$$_.ProcessId + [char]32 + $$_.Name } } }" "$INSTDIR" "$ExistingInstallLocation"' + InitPluginsDir + File "/oname=$PLUGINSDIR\\running-instances.ps1" "\${RUNNING_INSTANCES_PS1}" + nsExec::ExecToStack 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\\running-instances.ps1" detect "$RunningInstancesInstallRoot" "$ExistingInstallLocation"' Pop $0 Pop $1 \${If} $0 == "0" StrCpy $RunningInstancesOutput $1 \${Else} - StrCpy $RunningInstancesOutput "" + StrCpy $RunningInstancesOutput "__detection_failed__" Push "running instance detection failed exit=$0 output=$1" Call LogInstallerEvent \${EndIf} @@ -281,14 +357,9 @@ FunctionEnd Function CloseRunningInstances Push $0 Push $1 - ; Same matching as DetectRunningInstances (ExecutablePath path-prefix + - ; CommandLine fallback for null ExecutablePath rows). After Stop-Process - ; -Force we now WaitForExit on each PID (5s per process) before returning, - ; so the OS file-handle GC has time to release the lock on $INSTDIR\$exeName - ; before the installer proceeds into MUI_PAGE_INSTFILES. Without this wait - ; the next overwrite can race the kill and trigger NSIS's native "file in - ; use" Retry/Cancel dialog. Issue #821. - nsExec::ExecToStack 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param($$install, $$registered); $$roots = @($$install, $$registered) | Where-Object { $$_ } | ForEach-Object { $$root = $$_.TrimEnd([char]92).ToLowerInvariant(); [pscustomobject]@{ Exact = $$root; Prefix = ($$root + [char]92) } } | Select-Object -Unique Exact, Prefix; $$matches = Get-CimInstance Win32_Process | Where-Object { $$matched = $$false; $$exe = $$_.ExecutablePath; if ($$null -ne $$exe) { $$exe = $$exe.ToLowerInvariant(); foreach ($$root in $$roots) { if ($$root.Exact -and (($$exe -eq $$root.Exact) -or $$exe.StartsWith($$root.Prefix))) { $$matched = $$true; break } } } else { $$cmd = $$_.CommandLine; if ($$null -ne $$cmd) { $$cmdLc = $$cmd.ToLowerInvariant(); foreach ($$root in $$roots) { if ($$root.Prefix -and $$cmdLc.Contains($$root.Prefix)) { $$matched = $$true; break } } } }; $$matched }; $$ids = @($$matches | ForEach-Object { $$_.ProcessId }); foreach ($$id in $$ids) { try { [void][System.Diagnostics.Process]::GetProcessById($$id).CloseMainWindow() } catch {} }; Start-Sleep -Milliseconds 1500; foreach ($$id in $$ids) { try { $$p = [System.Diagnostics.Process]::GetProcessById($$id); if (-not $$p.HasExited) { Stop-Process -Id $$id -Force -ErrorAction SilentlyContinue } } catch {} }; foreach ($$id in $$ids) { try { $$p = [System.Diagnostics.Process]::GetProcessById($$id); if (-not $$p.HasExited) { [void]$$p.WaitForExit(5000) } } catch {} }; if ($$ids) { $$ids -join ([char]32) } }" "$INSTDIR" "$ExistingInstallLocation"' + InitPluginsDir + File "/oname=$PLUGINSDIR\\running-instances.ps1" "\${RUNNING_INSTANCES_PS1}" + nsExec::ExecToStack 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\\running-instances.ps1" close "$RunningInstancesInstallRoot" "$ExistingInstallLocation"' Pop $0 Pop $1 Push "running instances close exit=$0 output=$1" @@ -300,14 +371,29 @@ FunctionEnd Function .onInit SetShellVarContext current ReadRegStr $ExistingInstallLocation HKCU "${registryKey}" "InstallLocation" + StrCpy $RunningInstancesInstallRoot "" + \${If} $ExistingInstallLocation != "" + IfFileExists "$ExistingInstallLocation\\${exeName}" valid_existing_location invalid_existing_location +invalid_existing_location: + Push "ignoring registered install location without expected exe: $ExistingInstallLocation" + Call LogInstallerEvent + StrCpy $ExistingInstallLocation "" +valid_existing_location: + \${EndIf} IfSilent silent_check no_existing_install silent_check: Call DetectRunningInstances \${If} $RunningInstancesOutput != "" - Push "install aborted: running instances detected: $RunningInstancesOutput" + Push "running instances detected before silent install: $RunningInstancesOutput" Call LogInstallerEvent - Abort "$(RunningInstancesSilentAbort)" + Call CloseRunningInstances + Call DetectRunningInstances + \${If} $RunningInstancesOutput != "" + Push "install aborted: running instances still detected before silent install: $RunningInstancesOutput" + Call LogInstallerEvent + Abort "$(RunningInstancesSilentAbort)" + \${EndIf} \${EndIf} IfFileExists "$INSTDIR\\${exeName}" existing_install no_existing_install @@ -327,6 +413,7 @@ FunctionEnd Function RunningInstancesPage IfSilent done + StrCpy $RunningInstancesInstallRoot "" Call DetectRunningInstances \${If} $RunningInstancesOutput == "" Abort @@ -354,6 +441,7 @@ done: FunctionEnd Function RunningInstancesPageLeave + StrCpy $RunningInstancesInstallRoot "" Call CloseRunningInstances Call DetectRunningInstances \${If} $RunningInstancesOutput != "" @@ -364,6 +452,27 @@ Function RunningInstancesPageLeave \${EndIf} FunctionEnd +Function GuardRunningInstancesBeforeInstall + StrCpy $RunningInstancesInstallRoot "" + IfFileExists "$INSTDIR\\${exeName}" 0 detect_running_instances + StrCpy $RunningInstancesInstallRoot "$INSTDIR" +detect_running_instances: + Call DetectRunningInstances + \${If} $RunningInstancesOutput == "" + Return + \${EndIf} + + Push "running instances detected at install section: $RunningInstancesOutput" + Call LogInstallerEvent + Call CloseRunningInstances + Call DetectRunningInstances + \${If} $RunningInstancesOutput != "" + Push "install aborted: running instances still detected before file changes: $RunningInstancesOutput" + Call LogInstallerEvent + Abort "$(RunningInstancesCloseFailed)" + \${EndIf} +FunctionEnd + Function DirectoryPageLeave IfSilent done IfFileExists "$INSTDIR\\${exeName}" existing_install done @@ -450,6 +559,7 @@ Section "Install" SetShellVarContext current Push "install section start" Call LogInstallerEvent + Call GuardRunningInstancesBeforeInstall !insertmacro LOG_PATH_STATE "install_dir_before_install" "$INSTDIR" !insertmacro LOG_PATH_STATE "installed_exe_before_install" "$INSTDIR\\${exeName}" @@ -563,6 +673,7 @@ export async function buildCustomWinNsisInstaller( `/DSEVEN_Z_EXE=${winResources.sevenZipExe}`, `/DSEVEN_Z_DLL=${winResources.sevenZipDll}`, `/DAPP_ICON=${paths.winIconPath}`, + `/DRUNNING_INSTANCES_PS1=${join(dirname(paths.installerScriptPath), "running-instances.ps1")}`, paths.installerScriptPath, ], { cwd: dirname(paths.installerScriptPath),