From 4e34ad2adaead822bf41e7a161714a745d50fa91 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 7 Aug 2026 22:36:45 +0900 Subject: [PATCH] fix(update): scope Windows restart cleanup --- src/update/job.ts | 39 ++++++++++++++++++++++++--------------- tests/update-job.test.ts | 20 +++++++++++++++++--- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 3b9557f953..f07380c1fa 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -768,11 +768,6 @@ async function restartAfterUpdate( intervalMs: 100, scanIntervalMs: 500, killOcxHolders: true, - // Windows scheduler wrappers can mint a *new* bun PID during the wait; keep - // killing every ocx listener on this port, not only the pre-wait snapshot. - // npm rename trees under `@bitkyc08/.opencodex-*` are classified as ocx by - // isOcxStartCommandLine — never kill unknown foreign claimants on this port. - killAllOcxOnPort: true, onlyKillPids, }); @@ -885,7 +880,6 @@ async function restartAfterUpdate( if (serviceInstalled) stopWindowsServiceWrappersBestEffort(); // Reclaim the captured port before the pinned start. Spawning `--port` while the old // socket is still busy is how Windows updates used to fail health checks (or hop). - // killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait. const directAllow = reclaimKillAllowlist(); const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow)); if (!freed) { @@ -1040,15 +1034,7 @@ function stopWindowsServiceWrappersBestEffort(): void { function killWindowsServiceWrapperProcesses(): void { if (process.platform !== "win32") return; try { - const ps = [ - "$pats = @('opencodex-service.cmd','opencodex-service-launcher.vbs');", - "Get-CimInstance Win32_Process | Where-Object {", - " if ($_.ProcessId -eq $PID) { return $false };", - " $c = $_.CommandLine; if (-not $c) { return $false };", - " foreach ($p in $pats) { if ($c -like ('*' + $p + '*')) { return $true } };", - " $false", - "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }", - ].join(" "); + const ps = buildWindowsServiceWrapperCleanupScript(getConfigDir()); spawnSync(resolveTrustedWindowsPowerShellExe(), [ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps, @@ -1058,6 +1044,29 @@ function killWindowsServiceWrapperProcesses(): void { } } +function buildWindowsServiceWrapperCleanupScript(configDir: string): string { + const psQuote = (value: string): string => `'${value.replace(/'/g, "''")}'`; + const wrappers = [ + { name: "cmd.exe", path: join(configDir, "opencodex-service.cmd") }, + { name: "wscript.exe", path: join(configDir, "opencodex-service-launcher.vbs") }, + ]; + return [ + `$targets = @(${wrappers.map(wrapper => ( + `@{ Name = ${psQuote(wrapper.name)}; Path = ${psQuote(wrapper.path)} }` + )).join(",")});`, + "Get-CimInstance Win32_Process | Where-Object {", + " if ($_.ProcessId -eq $PID) { return $false };", + " $c = $_.CommandLine; if (-not $c) { return $false };", + " foreach ($t in $targets) {", + " if ($_.Name -ieq $t.Name -and $c.IndexOf($t.Path, [StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }", + " };", + " $false", + "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }", + ].join(" "); +} + +export const buildWindowsServiceWrapperCleanupScriptForTests = buildWindowsServiceWrapperCleanupScript; + /** Exposed for tests: drives the non-service restart path with injected io. */ export function restartAfterUpdateForTests( job: UpdateJobState, diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 7adcc513fb..a575c46edf 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + buildWindowsServiceWrapperCleanupScriptForTests, checkForUpdate, confirmRestartAfterUpdateForTests, finishGuiUpdateRestart, @@ -40,6 +41,19 @@ afterEach(() => { }); describe("GUI update check", () => { + test("Windows wrapper cleanup is scoped to wrapper hosts in the current home", () => { + const home = String.raw`C:\Users\Alice's Work\ocx`; + const script = buildWindowsServiceWrapperCleanupScriptForTests(home); + + expect(script).toContain("Name = 'cmd.exe'"); + expect(script).toContain("Name = 'wscript.exe'"); + expect(script).toContain(join(home, "opencodex-service.cmd").replace("'", "''")); + expect(script).toContain(join(home, "opencodex-service-launcher.vbs").replace("'", "''")); + expect(script).toContain("OrdinalIgnoreCase"); + expect(script).not.toContain("$pats"); + expect(script).not.toContain("-like"); + }); + test("surfaces an npm update with the launcher-safe command", () => { const result = checkForUpdate("latest", { currentVersion: () => "2.6.17", @@ -175,12 +189,12 @@ describe("GUI update execution decisions", () => { expect(waited).toEqual([{ port: 12345, hostname: "127.0.0.1", - opts: { killOcxHolders: true, onlyKillPids: [], killAllOcxOnPort: true }, + opts: { killOcxHolders: true, onlyKillPids: [], killAllOcxOnPort: undefined }, }]); expect(spawned).toEqual([{ port: 12345 }]); }); - test("restart reclaim allowlists the trusted oldPid and kills any ocx on the port", async () => { + test("restart reclaim only enables kills for the trusted oldPid snapshot", async () => { const optsSeen: Array<{ killOcxHolders?: boolean; onlyKillPids?: number[]; killAllOcxOnPort?: boolean }> = []; const job: UpdateJobState = { id: "restart-oldpid", @@ -209,7 +223,7 @@ describe("GUI update execution decisions", () => { }, spawnStart: () => {}, }); - expect(optsSeen).toEqual([{ killOcxHolders: true, onlyKillPids: [4242], killAllOcxOnPort: true }]); + expect(optsSeen).toEqual([{ killOcxHolders: true, onlyKillPids: [4242], killAllOcxOnPort: undefined }]); }); test("restart reclaim also allowlists leftover ocx listeners on the captured port", async () => {