From a123afaf538005e5ac1d9ad9956ea4c262355ea8 Mon Sep 17 00:00:00 2001 From: coso Date: Sat, 26 Sep 2026 11:49:28 +0800 Subject: [PATCH] fix(release): make Windows Squirrel app stop idempotent --- .../lib/windows-squirrel-n-minus-one.mjs | 41 +++++++++++++---- .../electron/windows-squirrel-rc-smoke.mjs | 3 ++ .../windows-squirrel-rc-smoke.test.mjs | 45 +++++++++++++++++++ 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/scripts/electron/lib/windows-squirrel-n-minus-one.mjs b/scripts/electron/lib/windows-squirrel-n-minus-one.mjs index f2dc951a1..753977768 100644 --- a/scripts/electron/lib/windows-squirrel-n-minus-one.mjs +++ b/scripts/electron/lib/windows-squirrel-n-minus-one.mjs @@ -195,19 +195,40 @@ export async function findReadyElectronUpdaterPage(pages) { return null; } -export async function stopInstalledApp(executable) { - const script = [ +export function buildStopInstalledAppScript() { + const matchingProcesses = + "@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [String]::Equals([System.IO.Path]::GetFullPath($_.ExecutablePath), $target, [StringComparison]::OrdinalIgnoreCase) })"; + return [ + "$ErrorActionPreference = 'Stop'", "$target = [System.IO.Path]::GetFullPath($env:LIME_TARGET_EXECUTABLE)", - "$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [String]::Equals([System.IO.Path]::GetFullPath($_.ExecutablePath), $target, [StringComparison]::OrdinalIgnoreCase) })", - "$processes | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop }", + `$processes = ${matchingProcesses}`, + "$processes | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch { if ($_.FullyQualifiedErrorId -notmatch '^NoProcessFoundForGivenId(?:,|$)') { throw } } }", 'Write-Output "stopped=$($processes.Count)"', ].join("; "); - const result = await runProcess( +} + +export async function stopInstalledApp( + executable, + { + runProcessImpl = runProcess, + waitForProcessExitImpl = waitForWindowsProcessExit, + timeoutMs = 30_000, + } = {}, +) { + const result = await runProcessImpl( "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", script], + [ + "-NoProfile", + "-NonInteractive", + "-Command", + buildStopInstalledAppScript(), + ], { - env: { ...process.env, LIME_TARGET_EXECUTABLE: executable }, - timeoutMs: 30_000, + env: { + ...process.env, + LIME_TARGET_EXECUTABLE: executable, + }, + timeoutMs, }, ); if (result.exitCode !== 0) { @@ -215,6 +236,10 @@ export async function stopInstalledApp(executable) { `failed to stop installed app at ${executable}: exit ${result.exitCode}`, ); } + await waitForProcessExitImpl(executable, { + runProcessImpl, + timeoutMs, + }); return { executable, exitCode: result.exitCode }; } diff --git a/scripts/electron/windows-squirrel-rc-smoke.mjs b/scripts/electron/windows-squirrel-rc-smoke.mjs index e2c966e43..51b52e90d 100644 --- a/scripts/electron/windows-squirrel-rc-smoke.mjs +++ b/scripts/electron/windows-squirrel-rc-smoke.mjs @@ -18,6 +18,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { buildNMinusOneLaunchEnv, + buildStopInstalledAppScript, buildWaitForWindowsProcessExitScript, classifySquirrelUninstallResult, compareVersions, @@ -39,6 +40,7 @@ import { export { buildNMinusOneLaunchEnv, + buildStopInstalledAppScript, buildWaitForWindowsProcessExitScript, classifySquirrelUninstallResult, compareVersions, @@ -48,6 +50,7 @@ export { resolveInstalledSquirrelPaths, resolveSquirrelFeed, selectNMinusOneVersion, + stopInstalledApp, uninstallInstalledSquirrel, waitForWindowsProcessExit, }; diff --git a/scripts/electron/windows-squirrel-rc-smoke.test.mjs b/scripts/electron/windows-squirrel-rc-smoke.test.mjs index ac8f817fa..6df6eb91d 100644 --- a/scripts/electron/windows-squirrel-rc-smoke.test.mjs +++ b/scripts/electron/windows-squirrel-rc-smoke.test.mjs @@ -7,6 +7,7 @@ import YAML from "yaml"; import { buildNMinusOneLaunchEnv, + buildStopInstalledAppScript, buildWaitForWindowsProcessExitScript, buildWindowsRcSummary, cleanupFromSummary, @@ -19,6 +20,7 @@ import { resolveSquirrelFeed, selectNMinusOneVersion, selectSquirrelInstaller, + stopInstalledApp, uninstallInstalledSquirrel, waitForWindowsProcessExit, } from "./windows-squirrel-rc-smoke.mjs"; @@ -160,6 +162,49 @@ describe("Windows Squirrel RC smoke", () => { ); }); + it("停止已退出的应用时忽略 PID 竞态,但保留其他 Stop-Process 错误", async () => { + const script = buildStopInstalledAppScript(); + expect(script).toContain("NoProcessFoundForGivenId"); + expect(script).toContain("FullyQualifiedErrorId"); + expect(script).toContain("throw"); + + const runProcessImpl = vi.fn().mockResolvedValue({ exitCode: 0 }); + const waitForProcessExitImpl = vi.fn().mockResolvedValue({ + executable: "C:\\runner\\Lime.exe", + exitCode: 0, + timeoutMs: 30_000, + }); + + await expect( + stopInstalledApp("C:\\runner\\Lime.exe", { + runProcessImpl, + waitForProcessExitImpl, + }), + ).resolves.toEqual({ + executable: "C:\\runner\\Lime.exe", + exitCode: 0, + }); + expect(runProcessImpl).toHaveBeenCalledWith( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ], + expect.objectContaining({ + env: expect.objectContaining({ + LIME_TARGET_EXECUTABLE: "C:\\runner\\Lime.exe", + }), + timeoutMs: 30_000, + }), + ); + expect(waitForProcessExitImpl).toHaveBeenCalledWith( + "C:\\runner\\Lime.exe", + { runProcessImpl, timeoutMs: 30_000 }, + ); + }); + it("卸载只操作候选 Update.exe,并等待安装目录与快捷方式消失", async () => { const updateExecutable = "C:\\Users\\runner\\AppData\\Local\\lime\\Update.exe";