Skip to content

[Bug][Windows]: GUI npm update reports restart failure just before fallback becomes healthy #720

Description

@luvs01

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

  1. Install OpenCodex through npm and install the Windows Task Scheduler service.
  2. Run the proxy non-elevated.
  3. From the dashboard, update 2.7.42 to 2.7.43 with restart enabled.
  4. Let schtasks /create /tn opencodex-proxy /xml ... /f return access denied.
  5. Observe the intended direct fallback start.
  6. On a sufficiently slow cold start, observe the update job fail at the 15-second readiness deadline immediately before /healthz becomes 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:10100

Screenshots 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:

  • 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;
  • opencodex/src/update/job.ts

    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" };
  • opencodex/src/update/job.ts

    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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions