Repository navigation
[Bug][Windows]: GUI npm update reports restart failure just before fallback becomes healthy #720
Description
Client or integration
OpenCodex dashboard
Area
Service lifecycle
Summary
During a GUI npm update with restart enabled, a non-elevated Windows Task Scheduler service refresh can fail with the expected WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED marker and correctly fall back to a direct proxy start.
In the reproduced run, the fallback needed slightly more than the fixed 15-second readiness deadline. The update job ended at 09:55:28.828 with proxy restart never became healthy, while the target-version proxy became healthy around 09:55:29.
The package update had succeeded and the replacement proxy was running, so this was a false terminal failure. The first 15-second miss also entered the redundant explicit-restart path, including two 30-second busy-port waits and another service-install attempt.
Expected: accept a target-version/new-PID replacement that becomes healthy within a realistic bounded Windows cold-start window, while still warning that the scheduler registration was not refreshed.
Reproduction
- Install OpenCodex through npm and install the Windows Task Scheduler service.
- Run the proxy non-elevated.
- From the dashboard, update
2.7.42to2.7.43with restart enabled. - Let
schtasks /create /tn opencodex-proxy /xml ... /freturn access denied. - Observe the intended direct fallback start.
- On a sufficiently slow cold start, observe the update job fail at the 15-second readiness deadline immediately before
/healthzbecomes healthy.
This is timing-dependent. In the reproduced run, the fallback process was created around 09:55:14, the job failed at 09:55:28.828, and the listener became healthy around 09:55:29.
The current arrival deadline is 15 seconds followed by a 15-second stability window. A bounded regression could keep the replacement unhealthy until virtual time 15.25s, then expose a changed PID and the target version, and assert success without entering the explicit-restart path. A final identity probe at the deadline would also close the narrow boundary race.
Version
2.7.42 → 2.7.43 (v2.7.43, commit d1f544bbc22d25b9b2bd3c8e776fb8e3242b4ed5)
Operating system
Windows 11 Pro 25H2, build 26200.8894, x64
Provider and model
Not provider-specific
Logs or error output
Reinstalling the background service with the updated files...
WindowsSchtasksError: Windows access denied while running Task Scheduler.
OCX_ERROR_CODE=WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED
opencodex: service refresh failed — starting the proxy directly instead.
Proxy starting on port 10100.
Update installed. Restarting proxy...
npm self-update did not leave a healthy proxy; performing explicit restart...
Port 10100 still busy after 30s; refusing to hop — reinstall may fail until the port is free.
Service reinstall failed (exit 1); falling back to a direct proxy start.
Port 10100 still busy after 30s (reclaim could not free the socket); not starting on another port.
error: proxy restart never became healthy on 127.0.0.1:10100Screenshots and supporting files
This appears to be a narrow follow-up to #515. It is distinct from #591/#698, whose locale-independent access-denied marker worked correctly in this reproduction.
Relevant readiness/restart code:
Lines 25 to 34 in d1f544b
const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; const UPDATE_TIMEOUT_MS = 180_000; const RESTART_TIMEOUT_MS = 60_000; const RESTART_HEALTH_TIMEOUT_MS = 15_000; const RESTART_STABILITY_WINDOW_MS = 15_000; /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */ export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000; /** How long update restart waits for the captured port to become bindable after stop. */ export const RESTART_PORT_RECLAIM_MS = 30_000; Lines 520 to 553 in d1f544b
async function awaitRestartedProxyHealthy( job: UpdateJobState, captured: { port: number; hostname: string }, io: RestartIo = {}, ): Promise<AwaitHealthyResult> { const probe = io.probeProxy ?? (async (port: number, hostname?: string) => ( !!(await proxyIdentityAt(port, { hostname })) )); const sleep = io.sleepMs ?? (async (ms: number) => { await new Promise(resolve => setTimeout(resolve, ms)); }); const now = io.now ?? (() => Date.now()); const port = captured.port; const hostname = captured.hostname; const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS; while (now() < startDeadline) { if (await probe(port, hostname)) { updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`); const stableUntil = now() + RESTART_STABILITY_WINDOW_MS; while (now() < stableUntil) { if (!(await probe(port, hostname))) { updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`); return { ok: false, reason: "flapped" }; } await sleep(500); } updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`); return { ok: true }; } await sleep(250); } return { ok: false, reason: "timeout" }; Lines 679 to 767 in d1f544b
export async function finishGuiUpdateRestart( job: UpdateJobState, captured: { port: number; hostname: string; oldPid?: number }, installer: Installer, io: RestartIo = {}, ): Promise<boolean> { if (installer === "npm") { const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)(); if (serviceInstalled) { const already = await awaitRestartedProxyHealthy(job, captured, io); if (already.ok) { const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)( captured.port, captured.hostname, ); const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); if (evidence.ok) { updateJob( job, {}, `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`, ); return true; } updateJob( job, {}, `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`, ); } else { updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart..."); } } } const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate; await restartFn(job, captured, io); if (installer !== "npm") { // Bun/source: health alone remains enough unless a richer identity probe is supplied. if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io); } return confirmNpmExplicitRestart(job, captured, io); } /** * After an explicit npm (or identity-aware) restart, require update-correlated * evidence — not merely a healthy OpenCodex listener. A no-op restart or a * failed port reclaim can leave the pre-update process on the captured port; * `confirmRestartedProxy` alone would treat that as success. */ async function confirmNpmExplicitRestart( job: UpdateJobState, captured: { port: number; hostname: string; oldPid?: number }, io: RestartIo = {}, ): Promise<boolean> { const healthy = await awaitRestartedProxyHealthy(job, captured, io); if (!healthy.ok) { const port = captured.port; const hostname = captured.hostname; const error = healthy.reason === "flapped" ? `proxy restart became unhealthy on ${hostname}:${port}` : `proxy restart never became healthy on ${hostname}:${port}`; updateJob(job, { status: "failed", restarted: false, error, }, restartFailureHint(port)); return false; } const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)( captured.port, captured.hostname, ); const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); if (!evidence.ok) { updateJob(job, { status: "failed", restarted: false, error: `proxy restart did not show update-correlated identity (${evidence.reason})`, }, restartFailureHint(captured.port)); return false; } updateJob( job, {}, `Proxy restart confirmed on ${captured.hostname}:${captured.port} (${evidence.detail}).`, ); return true;
Redacted configuration
Not configuration-dependent. The reproduction uses an npm installation and the Windows Task Scheduler service backend.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
github-actions commentedon Jul 30, 2026
Automated translation bookkeeping — detected language: English.
merge: PR #737 — tolerate late proxy readiness (#720)
lidge-jun commentedon Jul 30, 2026
Fixed on
devby62e937614(PR #737).The restart confirmation in
src/update/job.tswas exiting its wait loop on thenow() < startDeadlinetest, so a replacement proxy that became healthy right at the boundary was never probed again and got reported as a restart failure. It now always makes one final identity-aware probe at the deadline, and the sleep is clamped to the remaining time so it cannot step past it. The arrival budget also moves from 15s to 30s.tests/update-job.test.tsreproduces your case directly: a replacement arriving at 15.25s is accepted, with no redundant restart issued. A separate case pins the 30s boundary probe.Falsification check: reverting only
src/update/job.tsturns two of those into failures, so the coverage is real. Full suite on the merged tree: 6080 pass / 0 fail.Honest limit: a replacement that takes longer than 30s to come up is still reported as a restart failure. This narrows the window rather than removing it. If you still see the message after this lands, that is a different and slower failure and worth a fresh report with the update-job log.
Closing.
merge: PR lidge-jun#737 — tolerate late proxy readiness (lidge-jun#720)