Skip to content

fix(windows): spawn engine binaries that are .cmd shims - #105

Merged
hristo2612 merged 3 commits into
hristo2612:mainfrom
e1010101:fix/windows-engine-limits-spawn
Aug 3, 2026
Merged

fix(windows): spawn engine binaries that are .cmd shims#105
hristo2612 merged 3 commits into
hristo2612:mainfrom
e1010101:fix/windows-engine-limits-spawn

Conversation

@e1010101

Copy link
Copy Markdown
Contributor

Fixes #103. Codex usage limits were unavailable on Windows against a perfectly working install, and silently — both call sites swallow the error and resolve undefined, so there is no log line and no user-visible reason. The feature simply appears never to have data.

There were two bugs, not one. The second is the one #103 describes; the first is why it was never even reached.


Resolution never found the binary. findOnPath joined dir + bare name, but an npm-installed CLI on Windows is codex.cmd, so nothing matched and resolveBin fell through to returning the bare name — which CreateProcess cannot start either, since it tries .exe and .com but never .cmd. Both halves had to know about PATHEXT.

It now walks PATHEXT, most-specific first, so a real .exe still wins over a shim when both exist (pinned by a test). isExecutableFile also stops consulting X_OK on Windows, where it succeeds for any readable file and therefore means nothing.

Spawning then refused the shim. Node has rejected .cmd/.bat without a shell since 18.20.2 (the CVE-2024-27980 fix), so both sites threw EINVAL.

They now route through cmd.exe explicitly, rather than shell: true. Both end up running cmd.exe, but doing it here keeps the quoting visible and reviewable instead of delegating it, resolves cmd.exe from System32 rather than PATH, and passes /d so a registry AutoRun cannot inject work into every engine spawn. Arguments are quoted per MSVCRT rules with the trailing backslash run doubled — the same hazard cli/skills.ts documents.

windowsVerbatimArguments is required alongside it, and this took a moment to find: without it Node applies its own quoting on top of the line built here, and cmd.exe reports the whole thing as an unrecognised command. It is what shell: true sets internally.

Interposing cmd.exe makes the engine a grandchild, so child.kill() would reap only the interpreter and leave a long-lived app-server running — one leaked process per collection, every time the read times out. killProcessTree uses taskkill /T for that, best-effort, since the caller is already on an error path.


The three codex-rollout cases now run on Windows with real coverage. You noted in #103 that fixing the stub alone would make them pass without touching the product bug, so I did the opposite: their stub is written as a .cmd — the shape npm actually installs — so they exercise this spawn path rather than asserting around it. They fail without this change and pass with it, which is the coverage the issue asked for.

Also tested: PATHEXT resolution and its ordering, the cmd.exe rewrite and its quoting, and a real .cmd executed end-to-end after asserting that a bare spawnSync of it gives EINVAL — so the regression itself is pinned, not just the fix.

Stacking: #104 skips those three cases on Windows, since it had to make the leg green without this fix. If #104 lands first this PR should drop that skip on rebase; if this lands first, #104's skip becomes a no-op to remove. Either order works, they just both touch engine-limits.test.ts.

One thing for review. resolveBin now hands every engine a resolved .cmd path where it previously handed them a bare name. I do not believe that is a regression — a .cmd-only install could not be spawned either way — but making the PTY engines actually launch a shim is separate work with different constraints (interposing cmd.exe inside a PTY changes the process tree the CLI renders into), and I have not attempted it here. Happy to file it if you agree it is worth tracking.

Verified on Windows 11 / Node 24: shared + engines + cli suites pass (738 tests), typecheck clean on both packages.

@e1010101

Copy link
Copy Markdown
Contributor Author

Worth flagging before this is reviewed: CI cannot verify the Windows half of this PR yet.

This branch does not touch the workflow, so it gets the label-gated plan matrix and runs ubuntu only. Everything Windows-specific here — PATHEXT resolution, the cmd.exe rewrite, windowsVerbatimArguments, taskkill /T, and the three engine-limits cases that now run for real — is exercised on my Windows 11 / Node 24 box and by nothing else.

Two ways to close that, whichever you prefer:

I would rather not have this merged on my machine's word alone, given that is exactly how the LA alias bug in #101 survived — my box said green, the runner disagreed, and the runner was right.

@hristo2612

Copy link
Copy Markdown
Owner

#104 is merged, so the Windows leg is now unconditional on main. Please rebase this onto it and the run on this PR will cover the Windows paths for real.

Taking your ordering point as decided: I'd rather this waited for the runner than merged on one machine's word, for exactly the reason you gave. The LA alias bug is the precedent, and it cost nothing to wait then either.

On rebase, #104's skip on the three codex-rollout cases comes off, since this PR replaces it with the .cmd stub that gives them real coverage.

Two things I'll flag now rather than after the run, both from reading the diff:

The resolveBin change is broader than the fix. You called this out yourself and I agree with your reading, but it's worth stating in the merge record: every engine now receives a resolved .cmd path where it previously got a bare name. For the two call sites here that is the point. For the PTY engines it changes what they are handed without changing what they do with it, and a .cmd-only install could not be spawned either way, so this is not a regression. Please do file the PTY follow-up you offered. Interposing cmd.exe inside a PTY changes the process tree the CLI renders into, and that deserves its own review rather than riding along here.

windowsVerbatimArguments and the explicit cmd.exe route are the right call over shell: true. Resolving cmd.exe from System32 and passing /d so a registry AutoRun cannot inject into every engine spawn is a real hardening improvement over what shell: true would have given us, and it matches the reasoning already written down in cli/skills.ts. Worth keeping that comment where the next reader will find it.

killProcessTree is a good catch too. One leaked app-server per timed-out collection would have been invisible until a machine had a lot of them.

e1010101 added 2 commits July 30, 2026 01:13
Fixes hristo2612#103. Codex usage limits were unavailable on Windows against a working
install, silently: both call sites swallow the error and resolve undefined, so
there was no log line and no user-visible reason.

There were two bugs, not one.

Resolution never found the binary. findOnPath joined dir + bare name, but an
npm-installed CLI on Windows is `codex.cmd`, so nothing matched and resolveBin
fell through to the bare name -- which CreateProcess cannot start either,
because it tries .exe and .com but never .cmd. It now walks PATHEXT, most
specific first, so a real .exe still wins over a shim. isExecutableFile also
stopped consulting X_OK there, where it succeeds for any readable file and
means nothing.

Spawning then refused the shim. Node has rejected .cmd and .bat without a shell
since 18.20.2 (CVE-2024-27980), so both engine-limits call sites threw EINVAL.
They now route through cmd.exe explicitly rather than shell: true -- same
interpreter, but the quoting stays visible and reviewable, cmd.exe is resolved
from System32 rather than PATH, and /d keeps a registry AutoRun out of every
engine spawn. Arguments are quoted per MSVCRT rules with the trailing backslash
run doubled, the hazard cli/skills.ts documents. windowsVerbatimArguments is
required with it: without that Node re-quotes the line and cmd.exe reports the
whole thing as an unrecognised command.

Interposing cmd.exe makes the engine a grandchild, so killing the handle would
reap only the interpreter and leave a long-lived app-server running on every
timeout. killProcessTree uses taskkill /T for that, best-effort, since the
caller is already on an error path.

The three codex-rollout cases now run on Windows with real coverage rather than
skipped: their stub is written as a .cmd, which is the shape npm actually
installs, so they exercise this path instead of asserting around it.

Note for review: resolveBin now hands every other engine a resolved .cmd path
where it previously handed them a bare name. That is not a regression -- a
.cmd-only install could not be spawned either way -- but making the PTY engines
launch a shim is separate work, and I have not attempted it here.
hristo2612#104 skipped these on Windows because it had to make the leg green without
this fix. The .cmd stub replaces that skip with real coverage: they exercise
the shim spawn path and fail without the change in this PR.
@e1010101

Copy link
Copy Markdown
Contributor Author

Rebased onto main (4ef3661a), no conflicts, and #104's skip is off — the three codex-rollout cases now run on Windows with the .cmd stub. They pass locally and fail without this PR's spawn fix, which is the coverage you wanted rather than a re-enabled assertion.

The Windows leg should now run on this PR for real, since #104 made it unconditional. That is the run I have been waiting for.

PTY follow-up filed as #107. It ended up worth more than a one-liner, because a PTY is not just a spawn with a wrapper:

  • the engine becomes a grandchild of cmd.exe, and PtyLifecycleManager plus the exit identity-guard in claude-interactive.ts reason about the handle they spawned — killProcessTree covers the reap, but the identity checks would be comparing against the interpreter;
  • conpty resize and signal passthrough with an extra layer is worth measuring, not assuming;
  • and node-pty does its own launching, so whether it can start a .cmd as-is needs checking first — the wrapper may be unnecessary.

One thing I flagged there that is worth your attention: the engine case is more severe than the limits gap #103 covered. That one degraded a display; this one means an npm-installed engine cannot be launched at all on Windows. Anyone with codex.exe on PATH is unaffected, so I have not been able to judge how many real installs hit it.

Agreed on keeping the cli/skills.ts reasoning where the next reader finds it — windows-spawn.ts points back at it in the module comment for exactly that.

@hristo2612

Copy link
Copy Markdown
Owner

Gated this on top of main (4ef3661a): typecheck clean both packages, 290 files / 3581 passing. The three codex-rollout cases run for real on Windows now, which is the coverage #103 asked for, and the .cmd stub is the right way to have got it.

One blocker, and it is one character.

windows-spawn.ts:85, in killProcessTree:

const root = process.env.SystemRoot || process.env.windir || "C:\Windows";

\W is not an escape sequence, so JavaScript drops the backslash and that literal is "C:Windows". cmdExePath() forty lines above gets it right with "C:\\Windows", which is what makes it easy to miss.

$ node -e 'console.log(JSON.stringify("C:\Windows"))'
"C:Windows"

It only bites when both SystemRoot and windir are unset, which is rare but reachable in a stripped service environment. When it does, taskkill.exe resolves under a directory that does not exist, execFileSync throws, and the catch falls back to child.kill("SIGTERM") — so it degrades silently into exactly the grandchild leak this function exists to prevent, with no error anywhere.

Rather than fixing the literal, I'd pull the root resolution into one helper that both cmdExePath() and killProcessTree call. Two copies of the same fallback in one 90-line file is what let them drift in the first place, and a third caller is likely once #107 lands.

On the CI red you may have seen: one of my four gate runs failed on delegation-tools.test.ts, unrelated to this PR. I chased it rather than retrying until green, and it is not yours. Every darwin-side path in this diff is byte-identical to main: findOnPath takes [name] off win32, isExecutableFile's early return is win32-gated, spawnableCommand passes through, and killProcessTree reduces to the same child.kill("SIGTERM") it replaced. Clean main went 3 for 3 and this branch 3 of 4, which is a load-dependent flake landing by chance rather than a regression. I'll track it separately.

Fix the literal and I'll merge. Everything else here is done.

Separately: #107 is a good split, and I've read it. Your severity read is right and worth restating plainly, because it is worse than #103 was: that one degraded a display, this one means an npm-installed engine cannot be launched at all on Windows. I verified the surface — 11 sites across claude-interactive (1439, 1494), codex-interactive (426), grok-interactive, hermes-interactive, antigravity (310, 428), plus the batch engines in codex.ts, grok.ts, hermes-acp.ts, pi.ts.

Take your step 1 first and report before building anything: whether node-pty can launch a .cmd directly. If it can, most of this disappears and the answer is a resolution change rather than an interposition. If it cannot, I'd want to see the case for resolving the shim to its target executable argued against interposing cmd.exe, because keeping the engine as a direct child leaves PtyLifecycleManager's identity guard meaning what it says today.

killProcessTree wrote its fallback as "C:\Windows". \W is not an escape
sequence, so JavaScript dropped the backslash and the literal was the
drive-relative "C:Windows" -- a directory that does not exist. taskkill.exe
beneath it could not be found, execFileSync threw, and the catch fell back to
child.kill("SIGTERM"), degrading silently into precisely the grandchild leak
the function exists to prevent. Reachable whenever neither SystemRoot nor
windir is set, as in a stripped service environment.

Fixed as review suggested, by removing the duplication rather than the
character: one windowsRoot() and one system32(), used by both cmdExePath and
killProcessTree. Two copies of the same fallback in one 90-line file is what
let them drift, and hristo2612#107 will add a third caller.

windowsRoot is exported so a test pins the behaviour instead of proofreading
catching it next time: precedence between the two variables, and that the
fallback is ROOTED -- asserted against path.win32.isAbsolute, with the
drive-relative form the bug produced asserted false alongside it so the
expectation cannot later be adjusted to match whatever the code emits.

Mutation-checked: reintroducing the single backslash fails two of these tests.
@e1010101

Copy link
Copy Markdown
Contributor Author

Fixed at 19ad7a6a, and you were right on both the bug and the remedy.

Verified the mechanism before touching it — "C:\Windows" really does produce the drive-relative "C:Windows", and it was the only one of four copies of that fallback in the tree written that way (owner-only.ts:42, pairing-challenge.test.ts:19 and cmdExePath all had the doubled form). Which is exactly your point about why it was easy to miss.

Taken your approach rather than the one-character fix: one windowsRoot() and one system32(), used by both cmdExePath and killProcessTree.

windowsRoot is exported so a test pins it rather than proofreading:

  • precedence between SystemRoot and windir;
  • that the fallback is rooted, asserted with path.win32.isAbsolute — and the drive-relative form the bug produced asserted false alongside it, so the expectation cannot later be "fixed" by matching whatever the code emits.

Mutation-checked: reintroducing the single backslash fails two of those tests. It would not have been caught otherwise, since the fallback only runs when both variables are unset.

One process note, since it is the second time on this branch that my own escaping has bitten me: I generated that literal through a shell heredoc, which silently ate a backslash. The test above now covers the class rather than the instance.

On the delegation-tools.test.ts red — thank you for chasing it rather than retrying to green, and for laying out the darwin-side reasoning. Agreed on the read: every path in this diff is win32-gated or a pass-through, so a load-dependent flake landing by chance is the only explanation that fits 3-of-3 on clean main against 3-of-4 here.

Separately, and it changes #107 materially: I have answered your step 1. node-pty launches a .cmd directlypty.spawn(<absolute .cmd>) exits 0 with arguments passed through, while child_process.spawnSync of the same file gives EINVAL. What node-pty does not do is PATHEXT resolution: pty.spawn("fake-engine") with its directory on PATH throws File not found.

So the resolveBin change in this PR is load-bearing for the interactive engines, not merely broader than the fix. Before it they got a bare name and node-pty threw File not found; after it they get the resolved .cmd and it starts — no interposition, and the identity guard keeps meaning what it says. Full detail and the remaining (non-PTY) surface are in #107.

@e1010101

Copy link
Copy Markdown
Contributor Author

The Windows red on this PR is not this PR. It is callback-concurrent-init failing with attempt to write a readonly database — the registry init race, in a file this branch does not touch.

I have not re-run it to green. Raised as #110 instead, with the investigation:

  • the retry was engaging correctly — code is SQLITE_READONLY (verified against better-sqlite3 directly), predicate matches, runSqliteBusyRetry is in the stack;
  • it simply ran out: the ladder spends 1.76s and the worker that died had been contending for 3.5s;
  • so fix(registry): give the SQLite contention retry a time budget #110 makes it a time budget rather than an attempt count, jittered, matched to the busy_timeout already on the connection.

Two things I want to be straight about rather than let the PR description imply otherwise:

I cannot claim #110 eliminates it. I instrumented the give-up path at six times CI's concurrency and got RETRY-GAVEUP elapsed=15015ms — the loop exhausts the whole budget. That confirms the mechanism and simultaneously proves no bounded wait is sufficient for unbounded contention. It raises a ceiling; the fix without a ceiling is serializing init across processes, which I have flagged rather than smuggled in.

My first instinct was wrong and the measurement caught it. I assumed a longer budget would show up as a lower failure rate under stress, and it did not — 1/12 against main's 2/12, which is noise. Only instrumenting the give-up path distinguished "the retry never engages" from "the retry engages and runs out". Worth saying because the second reading is the one that justifies the change, and the failure-rate comparison alone would have justified nothing.

Happy to rebase this onto #110 once it lands so the leg here is green for the right reason. It should not merge on a re-run.

Everything else on this PR is unchanged and green: 19ad7a6a fixes the windowsRoot escape you caught, ubuntu, typecheck and build all pass, and the three codex-rollout cases run for real on Windows.

@e1010101

e1010101 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Field evidence, from a real Windows install rather than a fixture. I upgraded my own instance to the released 0.29.0 today and the boot log carries this unprompted:

[WARN] codex debug models failed: spawn C:\Users\…\Volta\bin\codex ENOENT

Volta installs both shims side by side:

codex       43 bytes   (extensionless shell script)
codex.cmd   28 bytes   (the Windows shim)

And Node's spawn against each, on that machine:

path result
…\Volta\bin\codex ENOENT
…\Volta\bin\codex.cmd EINVAL

That is this PR's argument reproduced end to end, and it shows both halves are load-bearing:

  1. Today (0.29.0): findOnPath joins dir + bare name, matches the extensionless script — isExecutableFile's X_OK check succeeds for any readable file on Windows, so it looks executable — and returns it. CreateProcess cannot run a 43-byte shell script with no extension, so ENOENT. That is the log line above, on a released build, against a working codex install.
  2. With PATHEXT resolution alone: it would find codex.cmd instead — and get EINVAL, the CVE-2024-27980 refusal.
  3. Only both together — resolve to the .cmd, then route it through cmd.exe — actually launch it.

So the X_OK change is not incidental tidying either: it is what stops the extensionless script from being mistaken for an executable in the first place.

Worth noting the failure mode matches what #103 described: the warning is one line in a boot log nobody reads, the collector resolves undefined, and the Limits page simply never has codex data. I would not have found this by looking at the UI.

Same evidence applies to #107 — the engines resolve through the identical path, so an npm- or Volta-installed engine on Windows hits ENOENT before it ever reaches the spawn question. I will cross-reference it there.

@hristo2612

Copy link
Copy Markdown
Owner

Merging. The blocker is resolved the way I asked, and I chased the red Windows leg to a named test rather than re-running until green.

The C:\Windows literal: fixed by extraction, not by patching. windowsRoot() is now the single source and both cmdExePath() and killProcessTree route through system32(). One occurrence of the literal survives in the tree and it carries the doubled backslash.

The test you pinned it with is better than the fix. Asserting the fallback is rooted via path.win32.isAbsolute, with expect(path.win32.isAbsolute("C:Windows")).toBe(false) sitting next to it, means the expectation cannot later be "fixed" by matching whatever the code happens to emit. The drive-relative-versus-rooted distinction is the exact thing the bug turned on, and it is now the thing under test. Auditing the other three copies of the fallback and reporting that they were already correct is the part most people skip.

The Windows failure is not yours. Run 30541919096: 1 failed / 3578 passed / 12 skipped. The single failure is sessions/__tests__/callback-concurrent-init.test.ts › "serializes concurrent opens and one transactional migration", dying on attempt to write a readonly database inside runSqliteBusyRetryinitDb.

Four lines of evidence, and I want to be honest about which ones actually carry weight:

  1. Your six files are all under packages/jinn/src/shared/. Grepping the failing test and its callback-open-worker.mjs fixture for resolveBin, engine-limits, windows-spawn and spawnableCommand returns zero matches. The failing path never reaches your code.
  2. This branch already went green on the Windows leg at run 30474558296, and the delta since is only the windowsRoot() extraction and the new assertions.
  3. Both the failing test and runSqliteBusyRetry predate this PR by weeks (3c4e4aa3, 0296d67f).
  4. main is 4-for-4 green on Windows since ci(windows): fix the six remaining failures, then make the Windows leg permanent #104.

Point 4 is the weakest and I am not leaning on it: four green runs is what a 20%-rate flake produces about 41% of the time, so "main is green, the PR is red" would not settle ownership on its own. Point 2 is what settles it.

Your diagnosis in #110 matches: SQLITE_READONLY is classed transient and the wrapper does engage, but the ladder spends 1.76 s while the dead worker had contended for 3.5 s. Instrumenting the give-up path to get RETRY-GAVEUP elapsed=15015ms at 6x concurrency is how that should be established. Saying plainly that #110 raises a ceiling rather than removing one, and declining to claim a failure-rate improvement off 1/12 versus 2/12, is the right call twice over.

On sequencing. The tidy order is #110 first, then rebase this and take a green leg. I am not doing that, because #110 by your own measurement makes the leg probably green rather than reliably green, and holding a finished PR behind a probabilistic gate trades a real merge for a cosmetic one. The ownership evidence above is stronger than a re-run would be, so it goes in the record instead. The cross-process init lock you flagged is the actual fix and is worth its own issue.

macOS gate on top of main: merges clean, typecheck clean, 3854 passing. One workflow-vertical failure that cleared in isolation, and notably a different flake than the one my last gate on this branch tripped, which is itself consistent with load-dependence rather than regression.

Closes #103. Thank you — that is six merged now.

@hristo2612
hristo2612 merged commit 5bb51e0 into hristo2612:main Aug 3, 2026
3 of 4 checks passed
hristo2612 pushed a commit to e1010101/jinn that referenced this pull request Aug 3, 2026
The Windows leg failed on hristo2612#105 with "attempt to write a readonly database" from
one of 16 processes initializing the registry -- the same class hristo2612#104 addressed
at journal_mode, now at the schema-init transaction one frame along.

The retry was engaging correctly: the error code is SQLITE_READONLY, which the
predicate matches, and runSqliteBusyRetry is in the stack. It simply ran out.
The ladder [10, 50, 200, 500, 1000] spends 1.76s, and the worker that died had
been contending for 3.5s.

An attempt count is the wrong unit for this. What is being waited out is a
window of contention whose length has nothing to do with how many times we have
asked, so this is now a time budget: 15s on Windows, 5s elsewhere, matching the
busy_timeout already set on the connection. Backoff is exponential and jittered
-- without jitter, peers that collide once back off by the same amount and
collide again, which is how a ladder that looks generous still exhausts itself.

Measured rather than assumed. Instrumenting the give-up path at six times CI's
concurrency produced `RETRY-GAVEUP elapsed=15015ms code=SQLITE_READONLY`: the
loop engages, backs off, and exhausts the whole budget. So this raises the
ceiling from 1.76s to 15s against observed contention of 3.5s, and it is not a
guarantee -- no bounded wait can be one. The change that would remove the
ceiling is serializing initialization across processes, which is larger and
deserves its own review.

At CI-equivalent load (16 processes) this is 0 failures in 10 local runs. A
comparison against main at 96 processes is within noise, because at that
concurrency both exhaust whatever budget they are given.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Windows] Engine usage limits are unavailable: bare spawn/execFile of a .cmd shim

2 participants