fix(pack/win): close running app before silent reinstall (#1238)

This commit is contained in:
PerishFire
2026-05-11 16:35:07 +08:00
committed by GitHub
parent e859c31574
commit 421ddf553c
2 changed files with 181 additions and 20 deletions
+52 -2
View File
@@ -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<WinStartResult>('start'));
let start = await measureSmokeStep(timings, 'start', async () => runToolsPackJson<WinStartResult>('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<WinStartResult>('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<WinInspectResult>('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<T>(action: string, extraArgs: string[] = []): Pr
}
}
async function runDirectInstaller(installerPath: string, installDir: string): Promise<DirectInstallerResult> {
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<string[]> {
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<WinInspectResult> {
const timeoutMs = 90_000;
const startedAt = Date.now();
@@ -447,7 +497,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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' &&
+129 -18
View File
@@ -89,6 +89,80 @@ async function resolveMakensisCommand(config: ToolPackConfig): Promise<string> {
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<void> {
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),