diff --git a/src/update/job.ts b/src/update/job.ts index abea4ee12..d507061b8 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -1134,8 +1134,16 @@ async function restartAfterUpdate( `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — reinstall may fail until the port is free.` + ` ${formatPortHolders(port, listPids, verifyOcx, preServiceAllow)}`, ); - const liveAfter = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid)); - if (liveAfter.length === 0) { + const liveScan: ListenPidScan = io.scanListenPidsFn + ? io.scanListenPidsFn(port) + : io.listListenPidsFn + // Test seam: an injected list represents a successful scan. + ? { ok: true, pids: io.listListenPidsFn(port) } + : scanListenPids(port); + const liveAfter = liveScan.ok + ? liveScan.pids.filter(pid => pid !== process.pid && aliveFn(pid)) + : null; + if (liveAfter !== null && liveAfter.length === 0) { // Non-elevated `service install` will UAC-fail anyway; skip straight to // the direct-start fallthrough instead of burning another minute on it. updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start."); diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index c8ae64de4..318a77742 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -446,6 +446,45 @@ describe("GUI update execution decisions", () => { }); }); + test("service restart is not skipped when the listener scan fails", async () => { + let serviceRuns = 0; + const serviceArgs: string[][] = []; + const job: UpdateJobState = { + id: "svc-scan-failure", + status: "restarting", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + currentVersion: "2.10.2", + latestVersion: "2.10.3", + channel: "latest", + installer: "npm", + restart: true, + command: "", + log: [], + releaseNotesUrl: "", + }; + writeFileSync(updateJobPath(), JSON.stringify(job)); + await restartAfterUpdateForTests(job, { port: 19997, hostname: "127.0.0.1" }, { + serviceInstalledFn: () => true, + serviceViableFn: () => true, + waitForPort: async () => false, + listListenPidsFn: () => [], + scanListenPidsFn: () => ({ ok: false, error: "listener tools unavailable" }), + runService: (_job, _bin, args) => { + serviceRuns += 1; + serviceArgs.push(args); + return { status: 0 }; + }, + spawnStart: () => {}, + probeProxy: async () => true, + }); + expect(serviceRuns).toBe(1); + expect(serviceArgs[0]).toContain("repair"); + expect(readUpdateJob(job.id)?.log.some(line => + line.includes("Skipping service reinstall after reclaim timeout"), + )).toBe(false); + }); + test("proxy restart pins --port so post-update start does not hop to an ephemeral port", () => { const proxy = restartCommand(false, "npm", "/pkg/bin/ocx.mjs", 10100); expect(proxy.mode).toBe("proxy");