fix(catalog): stop respawning the codex --version probe on every catalog read - #610
fix(catalog): stop respawning the codex --version probe on every catalog read#610mihneaptu wants to merge 2 commits into
Conversation
…log read Loading the bundled Codex catalog spawned `codex --version` on every call, even on a warm 60s cache hit. On Windows that pushed /api/claude-code and /v1/models to 5-8s, past the 3s budget cmdClaude allows, so `ocx claude` printed both the context-window and gateway-cache warnings on almost every launch: [1m] marking was skipped and ~/.claude/cache/gateway-models.json was never refreshed, leaving a stale model picker (mine was 6 days stale). Two independent causes: 1. loadBundledCodexCatalog forwarded its own already-defaulted execFileSync into resolveAndPersistCodexRuntime. resolveCacheKey() deliberately refuses to memoize injected-dependency resolves, so the real-runtime path never hit the memo. Forward an injected execFileSync only. 2. resolveAndPersistCodexRuntime persisted unconditionally on any non-fallback selection. persistCodexRuntime() clears the in-process memo and persistedRuntimeCacheStamp() folds updatedAt into the cache key, so each write both cleared the memo and rekeyed it -- even when the resolved runtime was byte-identical. Only write when the selection actually changed. Also default the catalog path to discoverAlternatives: false. Catalog loading consumes only resolved.runtime.command, never newerAvailable, but full PATH discovery probed 102 candidate launchers (~1.2s) per cold read on this machine. Priority selection is identical either way; callers that want discovery diagnostics still opt in explicitly. Measured on Windows 11, /api/claude-code over HTTP: cold (fresh proxy) 5000-8300ms -> 165ms warm 6300ms -> ~25ms after 65s idle (cache expired) 6325ms -> 1440ms Tests: two regression tests in tests/codex-runtime.test.ts. The first counts real --version invocations across repeated catalog reads and asserts the count stops growing; the second asserts an unchanged selection keeps its persisted updatedAt. Both fail on dev and pass with this change. Verified: bun run typecheck clean, bun run privacy:scan passed, and 144 tests across tests/codex-runtime.test.ts plus the four codex-catalog suites pass. The full bun run test sweep could not complete locally -- Bun 1.3.14 on Windows panics with an internal assertion failure at ~2.9GB RSS, the same runtime memory issue ocx doctor warns about, and it reproduces independently of this change. Please treat CI as authoritative for the full suite. Refs lidge-jun#606
📝 WalkthroughWalkthroughCodex catalog loading now preserves injected probing behavior and defaults alternative discovery to disabled. Runtime persistence skips rewrites when the resolved selection is unchanged, with tests covering warm-read probe reuse and timestamp preservation. ChangesCodex runtime caching
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/codex/runtime.ts`:
- Around line 509-514: Update the selection comparison in the persisted-runtime
flow around persistedRuntime and selectionUnchanged to normalize
result.runtime.version the same way as persistedRuntime.selectedVersion,
treating an absent resolved version as null. Preserve the existing command,
source, and rewrite conditions so unversioned runtimes remain unchanged across
reloads.
In `@tests/codex-runtime.test.ts`:
- Around line 385-405: Update the test around resolveAndPersistCodexRuntime to
inject a monotonic now function through deps, returning different timestamps on
successive calls. Ensure the repeated identical selection exercises a potential
second persistence attempt and assert the persisted updatedAt remains the first
timestamp, making the no-rewrite check deterministic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c812c313-8e60-48a1-a694-fb4c518d6666
📒 Files selected for processing (3)
src/codex/catalog/bundled.tssrc/codex/runtime.tstests/codex-runtime.test.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1310dd20b9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The P1 on the POSIX launcher does not reproduce — measuredReviewing this from the maintainer side. You already addressed the bot's P1 in Worth recording, though: the failure the P1 predicts does not actually occur, so you were not blocked by a real defect. I measured it three ways against 1. The catalog does load. I temporarily instrumented Not 2. A So the assertion pair is a real oracle for catalog loading; it is not silently green. 3. A is the path this code actually takes. B and C are what you get when One real observation this did surface, unrelated to your PR: because Nothing needed from you on this. Your builtin launcher is a fine improvement regardless. |
Correction to my previous comment — two things I got wrongA follow-up review caught two errors in what I wrote above, and one of them was bad advice about this codebase. Correcting both, with measurements. 1. The env behavior is Bun-specific, not general. I wrote that "the child inherits the environment captured at process start" as though that were ordinary Bun snapshots the environment at startup for child spawns and does not observe later That also means the test's green state rests on a Bun implementation detail. If Bun ever aligns with Node here, There is a related asymmetry I should have spotted: 2.
So that line does exactly what it was written to do: it keeps a machine-installed My NOOP conclusion stands — nothing here needs fixing, and nothing is needed from you. But the reasoning I published was wrong in both places, so it seemed worth correcting rather than leaving on the record. |
* docs(devlog): live state sync unit for 2026-07-28 (branches, PRs, issues) Measured snapshot of origin/dev=7710185c0, 15 open PRs, 27 open issues, and the delta against the 260727 owner-decision ledger and 260728 bug-bundle plan. The audit round demoted two items the first draft got wrong: #570 is only partially fixed (items 1a/2 of its six-item hardening plan; the myhost.lan alias case still 403s), and #612 is credential-handling work under MAINTAINERS.md's security-review rule rather than a decision-free patch. * docs(devlog): rebuild-unit roadmap — screen 16 open PRs, lock 4 work-phases Two audit rounds moved this a long way from the draft. The conflict set for #576 was inverted (logic files, not i18n), the proposed usage-debug size gate would have elided 0.9% of reads, and #610's P1 turned out to be author-resolved while the same defect survived on dev's own test. * docs(devlog): record the WP2 measurement — the #610 P1 does not reproduce PATH="" never reaches the child because runCodexDebugModels calls execFile without an env option, so the launcher keeps working and the catalog loads. The isolation that line intends is decorative; the test stays safe through CODEX_CLI_PATH instead. Closing WP2 as NOOP with the observation sent to #610. * test(usage): give the rolling-file bound test room on Windows The 325 appends this test needs cost ~1,954 synchronous fs calls (measured: mkdir 325, chmod 652, append 325, exists 325, read 325, write 2). On windows-latest under full-suite load that took 13.6s and tripped the 5s default, while ubuntu and macos stayed well under it. The cost is per-open rather than per-byte, so removing one of the six calls would not close a 2.7x overshoot. Reducing the append count would trade away coverage -- 325 is already the minimum that crosses the rotate threshold twice. Both assertions and the rotation contract are unchanged. * docs(devlog): record the #576 rebase outcome and the regression it surfaced The three conflicts were the predicted logic files. What the audit caught was separate: the pre-rebase commit already deleted dev's grokSyncFailureMessage and its three handlers, and no test in the repo asserts that string, so a full green suite would not have stopped it.
|
Ping on CI approval — the two Since I flagged in the description that my local Current state, for convenience:
Whenever one of you has a moment to approve the run, thanks. |
Fixes the root cause behind #606.
Problem
loadBundledCodexCatalog()spawnscodex --versionon every call, including warm60s cache hits. On Windows that pushes
/api/claude-codeand/v1/modelsto 5-8s,past the 3s budget
cmdClaudeallows each, soocx claudeaborts both and prints:The consequences are quiet:
[1m]marking is skipped for the run, and~/.claude/cache/gateway-models.jsonis never rewritten. Since Claude Code onlyrefreshes that cache itself when it holds a credential — and the
subscription-preserving launch deliberately injects none — the picker keeps serving
whatever is on disk. Mine was 6 days stale (80 entries vs 98 current).
Root causes
1. The bundled-catalog cache can never be hit.
candidatesis computed in anIIFE that runs before the
bundledCatalogCachelookup, becausecacheKeyisassigned inside it — so the runtime resolve happens on every call. That would still
be cheap if it were memoized, but the call forwarded its own already-defaulted
execFileSync:and
resolveCacheKey()deliberately refuses to memoize injected-dependencyresolves. The guard is right in intent (injected deps imply a test double), but this
caller defeated it for the real-runtime path. Now only a genuinely injected
execFileSyncis forwarded.2. The persist write cleared its own memo.
resolveAndPersistCodexRuntime()wrote on any non-fallback selection.
persistCodexRuntime()ends withresolveCache = null, andpersistedRuntimeCacheStamp()folds the on-diskupdatedAtinto the cache key — so each write both cleared the memo and changed thekey it would have been stored under, even when the resolved runtime was
byte-identical to what was already persisted. Now it only writes when the selection
actually changed.
3. Cold reads probed every
codexon PATH. The catalog path passeddiscoverAlternatives: deps.discoverAlternatives(i.e.undefined), whichresolveCodexRuntimeUncached()treats as "discover". It then probed every candidateto populate
newerAvailable, each probe spawningcodex --versionwith a freshmkdtempSyncsandbox:Both select the identical runtime, and catalog loading never reads
newerAvailableor
failures. Defaulted tofalsehere; callers that want discovery diagnosticsstill opt in explicitly.
Measurements
/api/claude-codeover HTTP, Windows 11:loadBundledCodexCatalog()three times in a row, nothing changing on disk:After the fix
ocx clauderuns clean andgateway-models.jsonrefreshed from 80stale entries to 98 current ones, 25 carrying
[1m].Tests
Three regression tests in
tests/codex-runtime.test.ts, all failing ondevandpassing here:
repeated catalog reads reuse the runtime probe instead of respawning it—uses a real launcher script that appends to a log on each
--version, so it countsactual subprocess spawns rather than mocking them. Asserts the count stops growing
across repeated reads. Note it deliberately allows the second read to probe once
more: the first read has no persisted selection yet, so it legitimately writes one
and that write clears the memo. Observed progression is
[1,2,2,2,2,2]— theinvariant is convergence, not a single probe. The POSIX launcher uses shell built-ins
only, so the test remains isolated with
PATH=""on Linux and macOS.repeat resolveAndPersistCodexRuntime keeps an unchanged selection's persisted stamp— injects a monotonic clock and asserts an identical selection does notrewrite
updatedAt.treats missing persisted and resolved versions as the same selection—covers JavaScript callers that surface
undefineddespite the TypeScriptnullcontract, preventing the same rewrite/memo invalidation loop for unversioned runtimes.
Verification
bun run typecheck— cleanbun run privacy:scan— passedbun test tests/codex-runtime.test.ts tests/codex-catalog*.test.ts— 145 pass,0 fail across 5 files (655 assertions)
PATH=""One gap I should be upfront about: the full
bun run testsweep did not complete onmy machine. Bun 1.3.14 on Windows panics with
Internal assertion failureat~2.9GB peak RSS partway through the 415-file suite — the same runtime memory issue
ocx doctorwarns about on this platform. It is not triggered by this change, soplease treat CI's cross-platform run as authoritative for the full suite.
Suggestions beyond this fix (not implemented here)
bundledCatalogCachebefore resolving the runtime. Deriving the cachekey from the very probe the cache exists to avoid means a warm hit can only ever
save the
codex debug modelscall (~87ms), never the probe (~1.2s) thatdominates. A cheaper key would let warm hits skip both. I left the ordering alone
to keep this change minimal.
network faults; something like
timed out after 3000mswould point straight atthe cause. I went looking at OAuth state and provider connectivity first.
~1.4s here, so a slower machine or longer
PATHcould still trip it silently.Tradeoff worth reviewing
Defaulting
discoverAlternativestofalseon the catalog path means catalog-pathcallers no longer receive
newerAvailablediagnostics. Nothing in that path consumesthem today, but if you would rather keep discovery observable, moving it to a slow
background interval instead of every catalog read would preserve the diagnostics
while keeping the request path fast. Happy to rework it that way.
Summary by CodeRabbit