From 537cce5e6f96f28c70192f70b98a000dfe4400b7 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:26:35 +0900 Subject: [PATCH 1/3] fix(update): preserve service repair on scan failure --- src/update/job.ts | 12 ++++++++++-- tests/update-job.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) 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..e1d99af75 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -446,6 +446,40 @@ describe("GUI update execution decisions", () => { }); }); + test("service restart is not skipped when the listener scan fails", async () => { + let serviceRuns = 0; + 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: [], + }; + writeFileSync(updateJobPath(job.id), 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: () => { + serviceRuns += 1; + return { status: 0 }; + }, + probeProxy: async () => true, + }); + expect(serviceRuns).toBe(1); + 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"); From 33558af5c2abcf8d0f0082ed273dfb949561b45e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:30:55 +0900 Subject: [PATCH 2/3] test(update): pin service repair recovery --- tests/update-job.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index e1d99af75..e4c75fe96 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -448,6 +448,7 @@ 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", @@ -460,6 +461,7 @@ describe("GUI update execution decisions", () => { restart: true, command: "", log: [], + releaseNotesUrl: "", }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 19997, hostname: "127.0.0.1" }, { @@ -468,13 +470,16 @@ describe("GUI update execution decisions", () => { waitForPort: async () => false, listListenPidsFn: () => [], scanListenPidsFn: () => ({ ok: false, error: "listener tools unavailable" }), - runService: () => { + 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); From 3ee2aa6b02e599092cb7865cca489164687872cd Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:21:43 +0900 Subject: [PATCH 3/3] test(update): use the declared job path signature --- tests/update-job.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index e4c75fe96..318a77742 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -463,7 +463,7 @@ describe("GUI update execution decisions", () => { log: [], releaseNotesUrl: "", }; - writeFileSync(updateJobPath(job.id), JSON.stringify(job)); + writeFileSync(updateJobPath(), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 19997, hostname: "127.0.0.1" }, { serviceInstalledFn: () => true, serviceViableFn: () => true,