Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions src/update/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
20 changes: 17 additions & 3 deletions tests/update-job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading