Repository navigation
Windows scheduler backend: ocx service stop reports success without stopping the proxy, and the --native switch breaks the existing backend #764
Description
Client or integration
OpenCodex dashboard
Area
Service lifecycle
Summary
On Windows with the scheduler backend, ocx service stop prints a success message without stopping the proxy. There are two independent causes, plus three related problems in the --native switch that I hit while investigating.
1a. stopWindows() never reaches the wrapper. The scheduled task runs wscript.exe -> opencodex-service-launcher.vbs -> opencodex-service.cmd. schtasks /end does not terminate that wrapper here, and the wrapper's :loop respawns the proxy ~5s after anything stops the child, so an explicit stop is silently undone. stopServiceIfInstalled() also returns true as soon as the task exists, without verifying the proxy died.
1b. stopTrackedProxyIfRunning() has no orphan recovery. It gives up when the pid file is missing or stale:
const pid = readPid();
if (!pid) return "none";ocx stop handles this via findLiveProxy(); the service path does not. So a lost pid file turns ocx service stop into a no-op that reports success.
1c. findLiveProxy()'s identity probe has a fixed 750ms budget. proxyIdentityAt() uses AbortSignal.timeout(io.timeoutMs ?? 750). Against a proxy that has only just begun listening, this misses a proxy that is plainly alive — I measured the same call succeeding 5/5 in isolation and failing intermittently when invoked from the stop path. Every caller treats null as "no proxy", so the failure is silent.
2. uninstallWindows() deletes files before verifying the task was removed.
try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());Without elevation schtasks /delete fails silently, the three artifacts are deleted anyway, and installWindowsNative() then correctly aborts with Task Scheduler backend still present after removal. The abort guard is right, but the surviving task now points at a launcher that no longer exists, so the scheduler backend is broken and fails at next logon.
3. install --native cannot work for Microsoft-account users, and fails at the very end. The generated WinSW XML uses <domain>HOSTNAME</domain><user>username</user> with allowservicelogon. For an account whose PrincipalSource is MicrosoftAccount, sc create cannot authenticate it. The user is prompted for a password that can never work, and by then the scheduler backend has already been removed per problem 2.
4. install /p hangs indefinitely without an interactive console. runWinswInteractive() uses stdio: "inherit" so WinSW can prompt for the password. Invoked from a hidden or non-interactive parent, the prompt is invisible and the process waits forever — I had one sit at 0% CPU for over five minutes with no output and no timeout.
5. A Windows service cannot see user-level environment variables. Related, because setting OPENCODEX_ADMIN_AUTH_TOKEN is the documented-in-code escape hatch for a separate problem: the scheduler backend inherits the logon-time environment block (a freshly set user variable is invisible until the next logon), and the WinSW backend declares a fixed <env> list that does not include it. Passing OPENCODEX_ADMIN_AUTH_TOKEN and OPENCODEX_ACL_TIMEOUT_MS through to the generated XML alongside CODEX_HOME / OPENCODEX_HOME would close this.
Reproduction
Problem 1 (stop is a no-op):
- Install the scheduler backend on Windows and start it:
ocx service start. Confirm the proxy answers on/healthz. - Delete
%USERPROFILE%\.opencodex\ocx.pidand write a staleruntime-port.jsonpointing at a dead pid and an unused port. (This state also arises naturally whenever the pid file is lost while the proxy is alive.) - Run
ocx service stop. It printsservice stopped + native Codex restored. - The port is still listening and the proxy process is still alive.
Even with a coherent pid file, stopping only the child lets the wrapper respawn it about five seconds later.
Problems 2-4 (--native switch):
- From a non-elevated shell run
ocx service install --native. It aborts withTask Scheduler backend still present after removal— butopencodex-service.cmd,opencodex-service-launcher.vbsandopencodex-service-task.xmlare already gone. - From an elevated shell with a Microsoft-account user, the same command removes the task successfully, prompts for the account password, then fails with
FATAL - Failed to create service. The account name is invalid or does not exist, or the password is invalid for the account name specified. - Launch that elevated command with a hidden window (for example
Start-Process -Verb RunAs -WindowStyle Hidden) and it hangs at the invisible password prompt indefinitely.
Version
2.7.43
Operating system
Windows 11 (build 10.0.26200), @bitkyc08/opencodex installed via npm -g, scheduler service backend.
Provider and model
Not provider-specific — this is service lifecycle only.
Logs or error output
# wrapper log after the child is stopped, showing the respawn loop
[dd/mm/yyyy hh:mm:ss] child exited with code 1; restarting in 5s
[dd/mm/yyyy hh:mm:ss] opencodex service wrapper start
# non-elevated --native switch
Removing the Task Scheduler backend before installing the native (WinSW) service...
error: Task Scheduler backend still present after removal - aborting switch.
at installWindowsNative (src/service.ts:1251:19)
# elevated --native switch, Microsoft-account user
INFO - Installing service 'OpenCodex Proxy (native) (opencodex-proxy-native)'...
Username: <redacted>
Password: ************
Set Account rights to allow log on as a service (y/n)?: y
FATAL - Failed to create service. The account name is invalid or does not exist,
or the password is invalid for the account name specified
error: Command failed: opencodex-proxy-native.exe install /p
status: 33
at runWinswInteractive (src/lib/winsw.ts:178:3)
Redacted configuration
service-state.json:
{ "version": 2, "backend": "scheduler" }Scheduled task principal (this is why the scheduler backend works for Microsoft-account users and WinSW does not):
LogonType : InteractiveToken
Trigger : LogonTrigger
Suggested fixes
- Have the service stop path fall back to
findLiveProxy()the wayocx stopalready does, terminate the wrapper when the scheduler backend is in use (guarded by matching the full wrapper script path so an unrelatedcmd.execannot match), and verify the listen port is actually free before reporting success. - Retry the identity probe once or twice before concluding no proxy exists, and/or make the 750ms budget configurable. A stop path can afford a few hundred extra milliseconds far more easily than it can afford not stopping.
- Verify the task is gone before deleting the wrapper, launcher and task XML — or restore them when the switch aborts.
- Detect a Microsoft-account principal during
--nativepreflight and refuse with an explanation, before touching the existing backend. - Fail fast when stdin is not interactive instead of blocking on the invisible
install /pprompt.
I have working implementations of 1, 2 and 5 and am happy to send PRs.
Additional: two pre-existing test failures on a clean checkout
Both reproduce on a clean v2.7.43 with no local changes, on Windows:
tests/service.test.ts->systemd service unit > uses unquoted append targets for service logstests/winsw.test.ts->winsw xml > registers the user service account (v2 schema), never LocalSystem
For the second the generated XML looks correct to me (<domain>HOSTNAME</domain><user>username</user><allowservicelogon>true</allowservicelogon> matching whoami), so it may be a fixture assumption rather than a real defect.
Separately, tests/anthropic-image-normalize.test.ts requires Bun.Image: it fails on bun 1.3.12 and passes on the 1.3.14 the repo pins. An engine check or skip guard would make that self-explanatory.
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
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
github-actions commentedon Jul 30, 2026
Automated translation bookkeeping — detected language: unknown.
[-]Windows: issues that force downstream patching (service stop, management token fail-closed, native backend, startup probes)[/-][+]Windows scheduler backend: ocx service stop reports success without stopping the proxy, and the --native switch breaks the existing backend[/+]Wibias commentedon Jul 30, 2026
[shipping-github] Research review
Claim: Windows scheduler
ocx service stopreports success without stopping the proxy;--nativeswitch can brick the scheduler backend.Checked against: dev@be177ea5
stopWindowsonlyschtasks /end;stopTrackedProxyIfRunningreturns early if pid file missing;uninstallWindowsdeletes launcher files even if task delete failed; WinSW/pinteractive path unchanged--nativecan leave a task pointing at deleted launchersEvidence:
src/service.tsstopWindows~1401;stopTrackedProxyIfRunning~1564–1566 (if (!pid) return "none");uninstallWindowsdeletes scripts after best-effort/delete. Matches reporter's multi-part write-up.Gaps: Did not reproduce on a live Windows scheduler install in this session.
Wibias commentedon Jul 30, 2026
[shipping-github] Opened PR #780 to address this.
fix(service): verify the proxy is really gone before reporting a stop (…
fix(service): only pay the restart-window wait where a supervisor can…
test(service): pin the platform default, not just the explicit flag
lidge-jun commentedon Jul 31, 2026
The first half is fixed on
codex/260731-pr-merge-round(2d0c6a99c+5530b8c63); the--nativeswitch half is untouched.Why stop reported success.
ops.stop()reports the outcome of the stop command, not of the process. A scheduler wrapper that survivesschtasks /endrespawns its child a few seconds later, so a stop that returned success can still leave a live proxy. The tracked-pid cleanup does not catch it either — the respawned child writes a pid this process never recorded, and the cleanup returns "none" the moment the pid file is absent.What changed. Stop is now verified rather than inferred. After the manager and the tracked proxy are stopped, the command polls for a live proxy across the observed restart window; if one answers it reports the port, leaves native Codex alone, and exits non-zero. Restoring the config on top of a running proxy is what turns a failed stop into a broken install, so that is the step that must not run on an unverified stop.
Deliberately different from PR #780. That PR waits only when
schtasks /endreturns an error. Your report is an/endthat succeeds while the wrapper lives — on that path there is nothing for the command to report, so waiting on its exit status cannot help. #780's other work (orphan discovery, PID verification, Microsoft-account preflight) is untouched and still worth reviewing separately.One correction I had to make. The first version ran the full wait on every stop, on every platform. Review caught it, and it was right to: launchd and systemd do not bring a child back after unload/stop, so making every macOS and Linux user wait out a Windows-specific window would have been a worse everyday bug than the one being fixed. The wait is now scoped to supervisors that can actually respawn, with tests pinning both the fast path and the platform derivation itself.
Staying open for the
--nativeswitch, which is a separate defect in the same command.docs(plan): record the round outcome, including what was wrong
fix(service): drop wait keyed on schtasks /end failure
merge upstream/dev and keep PID verification with stop polling
24 remaining items
fix(service): drop wait keyed on schtasks /end failure
fix(service): verify the proxy is really gone before reporting a stop (…
fix(service): only pay the restart-window wait where a supervisor can…
test(service): pin the platform default, not just the explicit flag
docs(plan): record the round outcome, including what was wrong
merge upstream/dev and keep PID verification with stop polling
fix(service): bake WinSW admin env and retry stop-path probes
docs(plan): disposition all 41 open issues against dev head
fix(stop): typed stop outcome so a history-only failure does not abor…
fix(cli): stop start shadowing a live configured-port proxy, and retr…
fix(cli): retry liveness probes before ocx claude spawns a proxy (lid…
fix(claude): fall back to native launch when routing is off (lidge-ju…
fix(oauth): rebase startup reconciliation on the persisted config (re…
fix(combos): classify failover failures by blast radius (bounded carr…
fix(server): raises-only probe override, 32-bit ceiling, test hygiene…