Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(codex-manager): close the menu quota-refresh write races - #549

Closed
ndycode wants to merge 1 commit into
claude/audit-29-login-machineryfrom
claude/audit-32-quota-cache-races
Closed

ndycode wants to merge 1 commit into
claude/audit-29-login-machineryfrom
claude/audit-32-quota-cache-races

Conversation

@ndycode

@ndycode ndycode commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

The focused follow-up promised on the #540 and #547 review threads: both quota-cache write races are pre-existing (they predate the login-machinery extraction; #547 moved them verbatim), fixed together here so the extraction stack stays zero-behavior-change.

Stacked on #547 (claude/audit-29-login-machinery), i.e. five deep: #525#535#540#547 → this. Merge in order; this PR then shows only the one fix commit.

Fix 1 — last-write-wins clobber (flagged on #540)

refreshQuotaCacheForMenu probed against a snapshot clone of the cache the menu had loaded, then saved that clone whole-file. Any entries a concurrent writer persisted while the probes ran — a user-triggered deep check, a second session — were silently discarded. The save now reloads the freshest persisted cache and re-applies this run's successful probe results onto it (using the same updateQuotaCacheForAccount logic, so unsafe-email pruning behaves identically), falling back to the clone if the reload fails. No blocking, no UX change — concurrent writers' entries simply survive.

Fix 2 — orphaned in-flight refresh (flagged on #547)

Leaving the dashboard (add-account, cancel, and the empty-storage onboarding path) abandoned a running background refresh whose cache save could land mid-flight against the subsequent persistAccountPool storage write (Windows EBUSY/EPERM on sibling files). The three exit paths now drain the pending refresh first. The wait is bounded by the per-probe HTTP timeouts, the chain never rejects, and menu actions are deliberately not drained — they stay instant; fix 1 makes their concurrent saves safe instead.

Changes

  • lib/codex-manager/login-menu-data.ts: save-time rebase in refreshQuotaCacheForMenu (tracks applied (account, snapshot) pairs, re-applies onto a fresh loadQuotaCache() result).
  • lib/codex-manager/login-flow.ts: drainPendingMenuQuotaRefresh helper called at the three dashboard exit points.
  • test/codex-manager-login-menu-refresh.test.ts (new): pins the rebase (a concurrent acc_concurrent entry survives the save), the reload-failure fallback, no-save-when-unchanged, and the empty-storage no-op.
  • test/codex-manager-cli.test.ts: the suite's loadQuotaCache mock now returns a fresh object per call, matching the real implementation's fresh disk read (the old shared singleton would leak the rebase mutation across loads); the two refresh-orchestration tests settle pass 1 via the statusMessage() observable (cleared in the same .finally that frees the pending slot) instead of relying on exact microtask counts, which the added rebase await shifted.

Validation

  • npm run typecheck; eslint on all 4 touched files --max-warnings=0
  • All 29 codex-manager suites: 484/484 passed (480 pre-existing + 4 new)

Risk / Rollback

The rebase only changes which base the whole-file save starts from; same-account results still take latest-wins (correct). The drain adds a bounded wait only on dashboard exit. Revert the single commit.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB


Generated by Claude Code

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

fixes two pre-existing quota-cache write races introduced during the login-machinery extraction: a last-write-wins clobber in refreshQuotaCacheForMenu (now rebases probe results onto a fresh disk load before saving) and an orphaned in-flight refresh on dashboard exit (now drained at all three loop-exit paths before returning to the oauth flow).

  • login-menu-data.ts: tracks successful (account, snapshot) pairs, reloads the freshest persisted cache after all probes complete, re-applies the pairs onto it, and saves — concurrent writers' entries survive. the surrounding catch block is dead code because loadQuotaCache never rejects; on a real EBUSY reload it resolves to {}, silently losing non-probed entries instead of falling back to the snapshot clone as the comment promises.
  • login-flow.ts: drainPendingMenuQuotaRefresh called at empty-storage, cancel, and add-account exits — prevents windows EBUSY/EPERM on the subsequent persistAccountPool write.
  • test/codex-manager-login-menu-refresh.test.ts (new): four rebase scenarios; the reload-failure case mocks loadQuotaCache to reject, which the real implementation never does, so the fallback path tested here is unreachable in production.
  • test/codex-manager-cli.test.ts: mock changed to fresh-object-per-call to prevent rebase mutation leak; refresh-settle guard switched to vi.waitFor on statusMessage() clearing for determinism.

Confidence Score: 3/5

the drain fix in login-flow.ts is safe and correct; the rebase logic in login-menu-data.ts has a silent failure mode when the disk reload encounters windows filesystem errors

the catch block surrounding loadQuotaCache() in the rebase path is unreachable — the real implementation swallows all errors and returns empty. on windows EBUSY during reload, cacheToSave becomes an empty base with only the fresh probes applied, silently discarding every pre-existing non-probed entry rather than falling back to the snapshot clone as documented. the test exercising this fallback passes only because it mocks a rejection that never fires in production.

lib/codex-manager/login-menu-data.ts (rebase catch block) and test/codex-manager-login-menu-refresh.test.ts (reload-failure test case)

Important Files Changed

Filename Overview
lib/codex-manager/login-menu-data.ts adds save-time rebase via loadQuotaCache + re-apply logic; the surrounding catch block is dead code because loadQuotaCache never rejects, causing silent empty-object fallback instead of the documented snapshot-clone fallback on EBUSY reload
lib/codex-manager/login-flow.ts adds drainPendingMenuQuotaRefresh helper and calls it at all three loop-exit paths; logic is correct and bounded; no issues found
test/codex-manager-login-menu-refresh.test.ts new suite covering 4 rebase scenarios; the reload-failure test mocks a rejection that the real loadQuotaCache never emits, so the fallback path it claims to cover is unreachable in production
test/codex-manager-cli.test.ts mock switched to fresh-object-per-call to prevent mutation leak; refresh-settle guard changed to vi.waitFor on statusMessage clearing; both changes are correct and necessary

Sequence Diagram

sequenceDiagram
    participant Dashboard as DashboardLoop
    participant Refresh as refreshQuotaCache
    participant Disk as quota-cache.json
    participant Pool as persistAccountPool

    Dashboard->>Disk: loadQuotaCache initial
    Dashboard->>Refresh: fire-and-forget
    activate Refresh
    Refresh->>Refresh: probe accounts via HTTP
    Refresh->>Disk: loadQuotaCache rebase reload
    Disk-->>Refresh: fresh persisted state
    Refresh->>Refresh: re-apply snapshots onto persisted
    Refresh->>Disk: saveQuotaCache cacheToSave
    deactivate Refresh
    Dashboard->>Refresh: drain await pendingMenuQuotaRefresh
    Refresh-->>Dashboard: settled
    Dashboard-->>Pool: return add-account
    Pool->>Disk: persistAccountPool write no EBUSY race
Loading

Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
lib/codex-manager/login-menu-data.ts:235-238
**dead catch — `loadQuotaCache` never rejects**

`loadQuotaCache` wraps every code path in a try/catch and returns `{ byAccountId: {}, byEmail: {} }` on any failure — it is documented never to throw. so this catch block is unreachable in production.

the real-world EBUSY-during-reload path is: `readCacheFileWithRetry` exhausts 5 attempts and throws → `loadQuotaCache`'s own catch swallows it and resolves to an empty object → `persisted = {}` → the rebase applies only this run's probes onto an empty base → `cacheToSave` silently drops every pre-existing entry in `nextCache` that wasn't re-probed this pass. that is the opposite of what the fallback comment intends ("saving slightly stale data beats dropping this run's probe results").

the corresponding test (`"falls back to saving its own snapshot clone when the reload fails"`) confirms this gap: it uses `mockRejectedValue(new Error("EBUSY"))`, which exercises the dead catch, but the production `loadQuotaCache` would resolve instead of reject. the passing test gives false safety on a code path that is never reached.

### Issue 2 of 3
test/codex-manager-login-menu-refresh.test.ts:107-121
**test exercises dead catch branch**

`loadQuotaCacheMock.mockRejectedValue(new Error("EBUSY"))` is the only way to reach the catch in the rebase block, but the real `loadQuotaCache` in `lib/quota-cache.ts` never rejects — it catches internally and returns `{}`. this test passes but verifies a code path that never fires in production, so the actual EBUSY behavior (silent empty-object reload → partial save) has no coverage.

### Issue 3 of 3
lib/codex-manager/login-flow.ts:95-101
**no test pins the drain-before-exit contract**

the three `drainPendingMenuQuotaRefresh` call-sites cover the stated exit paths, but no vitest case asserts that a drain actually happens before `persistAccountPool` is reached. the `codex-manager-cli.test.ts` changes verify refresh settling within a menu pass, which is a different assertion. a test that starts a slow in-flight refresh and verifies `persistAccountPool` is only called after the drain resolves would lock in the windows EBUSY fix and prevent regression on this path.

Reviews (1): Last reviewed commit: "fix(codex-manager): close the menu quota..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Two related races flagged by review on #540/#547, both pre-existing
before the login-machinery extraction:

- Last-write-wins clobber: refreshQuotaCacheForMenu probed against a
  snapshot clone and then saved it whole-file, silently discarding any
  entries a concurrent writer (deep check, second session) persisted
  while the probes ran. The save now reloads the freshest persisted
  cache and re-applies this run's successful probe results onto it,
  falling back to the clone if the reload fails.

- Orphaned in-flight refresh: leaving the dashboard (add-account,
  cancel, empty-storage onboarding) abandoned a running refresh whose
  background cache save could race the subsequent account-pool write
  (Windows EBUSY/EPERM on sibling files). The three exit paths now
  drain the pending refresh first; the wait is bounded by the per-probe
  HTTP timeouts and never rejects.

New regression suite pins the rebase-on-save behavior (concurrent entry
preserved, reload-failure fallback, no save when nothing changed). The
cli suite's loadQuotaCache mock now returns a fresh object per call
like the real fresh-disk-read implementation, and the two
refresh-orchestration tests settle pass 1 deterministically via the
statusMessage observable instead of relying on microtask counts.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Summary

This PR fixes two critical race conditions in the quota-cache refresh mechanism used during login/menu operations that could cause cache data loss due to concurrent writes. Fix 1 prevents a last-write-wins clobber where the in-memory quota snapshot could overwrite concurrent-writer entries by reloading the freshest persisted cache and rebasing results onto it before save; Fix 2 prevents orphaned in-flight refreshes on dashboard exit by draining pending refreshes before proceeding with account pool writes. Both fixes include comprehensive test coverage (4 new tests covering rebase, reload-fallback, no-save, and empty-storage scenarios), and all 484 tests pass with no test infrastructure changes required.

Changes

lib/codex-manager/login-flow.ts

  • Added drainPendingMenuQuotaRefresh() helper that awaits any pending menu quota refresh before proceeding
  • Integrated drain calls at three dashboard exit paths: no-existing-accounts → "add-account", menu cancel → "exit", and "add" mode → "add-account"
  • Wait is bounded by per-probe HTTP timeouts; menu actions remain instant

lib/codex-manager/login-menu-data.ts

  • Refactored refreshQuotaCacheForMenu() to rebase successful quota results onto the freshest persisted cache before saving, rather than saving the in-memory clone directly
  • Falls back to original clone if cache reload fails
  • Conditionally saves only when changes are detected

test/codex-manager-login-menu-refresh.test.ts (new)

  • Added comprehensive test suite covering: rebase onto persisted cache, reload-fallback behavior, no-save-when-unchanged, and empty-storage no-op scenarios

test/codex-manager-cli.test.ts

  • Updated quota-cache mocking to return fresh objects per invocation instead of shared instances, preventing cross-call mutation leakage
  • Made two interactive menu auto-fetch tests deterministic by clarifying prompt handler argument flow and adding status message synchronization waits

Validation

  • Typecheck and ESLint: passed on all touched files
  • Test results: 484/484 passed (480 existing + 4 new)
  • Rebase changes are isolated to save-time behavior; same-account last-write-wins semantics preserved
  • Single revertable commit

Walkthrough

Fixes concurrent quota cache writer races by rebasing fresh probes onto the latest persisted cache before saving, rather than overwriting with an in-memory clone. Adds login-loop drain coordination to await pending refreshes before exiting. Includes comprehensive refresh tests and test mock isolation fixes.

Changes

Quota Refresh Concurrent Safety

Layer / File(s) Summary
Quota cache rebasing on refresh
lib/codex-manager/login-menu-data.ts
refreshQuotaCacheForMenu records per-account snapshots during probe phase. when changes occur, it loads the freshest persisted cache, re-applies recorded snapshots onto it (falling back to local clone if load fails), and saves the rebased result instead of the original clone. this prevents the old overwrite race.
Login loop drain pending refresh
lib/codex-manager/login-flow.ts
New drainPendingMenuQuotaRefresh helper awaits pendingMenuQuotaRefresh when present. control loop calls it on three exit paths (no accounts → "add-account", cancel → "exit", add mode → "add-account") to ensure pending saves complete before proceeding.
Comprehensive refresh test suite
test/codex-manager-login-menu-refresh.test.ts
New Vitest suite fully exercises the rebasing logic: concurrent writer entries preserved, reload-failure fallback, all-probe-failure returns input unchanged with no I/O, no-account path skips refresh entirely.
CLI test mock isolation and async coordination
test/codex-manager-cli.test.ts
loadQuotaCacheMock now returns fresh object per call (not shared value) to prevent mutation leakage. two "ready-first" menu tests updated to accept options in handlers and explicitly await options.statusMessage?.() clearing before releasing control, ensuring deterministic status/refresh sequencing.

Sequence Diagram(s)

sequenceDiagram
  participant ProbeLoop as Quota probe loop
  participant SnapStore as Applied snapshots
  participant PersistLoad as Persisted cache load
  participant MergeLogic as Re-apply logic
  participant SaveOp as Cache save
  
  ProbeLoop->>SnapStore: record snapshot per account
  ProbeLoop-->>MergeLogic: detected changes
  MergeLogic->>PersistLoad: fetch freshest persisted
  PersistLoad-->>MergeLogic: latest cache or fail
  MergeLogic->>MergeLogic: re-apply recorded snapshots
  MergeLogic->>SaveOp: save rebased cache
  SaveOp-->>MergeLogic: confirm save
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • ndycode/codex-multi-auth#122: Both PRs modify the core quota refresh/cache update logic in refreshQuotaCacheForMenu—this PR fixes concurrent writer overwrites via rebasing, while retrieved PR alters the same refresh path to thread multi-workspace email fallback state and persist from working quota cache.

Suggested labels

bug


review notes

cache rebasing risks: lib/codex-manager/login-menu-data.ts:202-240 performs a load-then-rebase when changes are detected. if the persisted cache load fails, fallback to the original cloned cache is reasonable, but confirm that cacheToSave is always defined before the save call—there's a logical path where a successful probe with no changes returns early without ever setting it, which looks correct, but verify the return statement at lib/codex-manager/login-menu-data.ts:250 doesn't leak undefined.

concurrency window: the drain helper at lib/codex-manager/login-flow.ts:89-102 closes races between refresh completion and control-flow exit, but there's still a narrow window between the helper returning and the actual state transition. confirm that MenuQuotaRefreshState.pendingMenuQuotaRefresh is cleared after drainPendingMenuQuotaRefresh awaits, otherwise a rapid re-entry could observe stale promises.

test isolation: test/codex-manager-cli.test.ts:753-760 fixing the mock to return fresh objects per call is correct, but verify that the fixture's beforeEach also resets all pending state, especially MenuQuotaRefreshState.pendingMenuQuotaRefresh, to prevent cross-test leakage of reference-held promises.

missing edge case: the new test suite in test/codex-manager-login-menu-refresh.test.ts does not cover the case where cache save itself fails—the code re-applies snapshots but doesn't validate that the save rejection is handled or propagated correctly. this could mask failures in the persistence layer.

windows path consideration: test/codex-manager-login-menu-refresh.test.ts uses dynamic import and vitest mocking—confirm these patterns work cleanly on windows, especially if quota-probe.js or cache-loading use file:// URIs or platform-specific paths.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title follows conventional commits format with type 'fix', clear scope, and lowercase imperative summary under 72 chars, directly addressing the core fix.
Description check ✅ Passed Description covers both fixes, changes made, and validation status; however, missing explicit Risk level value and rollback command in the Risk/Rollback section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/audit-32-quota-cache-races
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/audit-32-quota-cache-races

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

test/codex-manager-cli.test.ts

Oops! Something went wrong! :(

ESLint: 10.0.0

Error: The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it.
at /node_modules/eslint/lib/config/config-loader.js:145:10
at async loadTypeScriptConfigFileWithJiti (/node_modules/eslint/lib/config/config-loader.js:144:3)
at async loadConfigFile (/node_modules/eslint/lib/config/config-loader.js:265:11)
at async ConfigLoader.calculateConfigArray (/node_modules/eslint/lib/config/config-loader.js:588:23)
at async #calculateConfigArray (/node_modules/eslint/lib/config/config-loader.js:369:19)
at async Promise.all (index 0)
at async findFiles (/node_modules/eslint/lib/eslint/eslint-helpers.js:635:25)
at async ESLint.lintFiles (/node_modules/eslint/lib/eslint/eslint.js:1014:21)
at async Object.execute (/node_modules/eslint/lib/cli.js:386:14)
at async main (/node_modules/eslint/bin/eslint.js:175:19)

test/codex-manager-login-menu-refresh.test.ts

Oops! Something went wrong! :(

ESLint: 10.0.0

Error: The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it.
at /node_modules/eslint/lib/config/config-loader.js:145:10
at async loadTypeScriptConfigFileWithJiti (/node_modules/eslint/lib/config/config-loader.js:144:3)
at async loadConfigFile (/node_modules/eslint/lib/config/config-loader.js:265:11)
at async ConfigLoader.calculateConfigArray (/node_modules/eslint/lib/config/config-loader.js:588:23)
at async #calculateConfigArray (/node_modules/eslint/lib/config/config-loader.js:369:19)
at async Promise.all (index 0)
at async findFiles (/node_modules/eslint/lib/eslint/eslint-helpers.js:635:25)
at async ESLint.lintFiles (/node_modules/eslint/lib/eslint/eslint.js:1014:21)
at async Object.execute (/node_modules/eslint/lib/cli.js:386:14)
at async main (/node_modules/eslint/bin/eslint.js:175:19)


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

ndycode pushed a commit that referenced this pull request Jun 10, 2026
The quota-refresh write races and the small-suite mock-factory
migration are delivered; remaining deferred work narrows to the
giant-suite migrations only.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
Comment on lines +235 to +238
} catch {
// Fall back to the snapshot clone; saving slightly stale data beats
// dropping this run's probe results.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 dead catch — loadQuotaCache never rejects

loadQuotaCache wraps every code path in a try/catch and returns { byAccountId: {}, byEmail: {} } on any failure — it is documented never to throw. so this catch block is unreachable in production.

the real-world EBUSY-during-reload path is: readCacheFileWithRetry exhausts 5 attempts and throws → loadQuotaCache's own catch swallows it and resolves to an empty object → persisted = {} → the rebase applies only this run's probes onto an empty base → cacheToSave silently drops every pre-existing entry in nextCache that wasn't re-probed this pass. that is the opposite of what the fallback comment intends ("saving slightly stale data beats dropping this run's probe results").

the corresponding test ("falls back to saving its own snapshot clone when the reload fails") confirms this gap: it uses mockRejectedValue(new Error("EBUSY")), which exercises the dead catch, but the production loadQuotaCache would resolve instead of reject. the passing test gives false safety on a code path that is never reached.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager/login-menu-data.ts
Line: 235-238

Comment:
**dead catch — `loadQuotaCache` never rejects**

`loadQuotaCache` wraps every code path in a try/catch and returns `{ byAccountId: {}, byEmail: {} }` on any failure — it is documented never to throw. so this catch block is unreachable in production.

the real-world EBUSY-during-reload path is: `readCacheFileWithRetry` exhausts 5 attempts and throws → `loadQuotaCache`'s own catch swallows it and resolves to an empty object → `persisted = {}` → the rebase applies only this run's probes onto an empty base → `cacheToSave` silently drops every pre-existing entry in `nextCache` that wasn't re-probed this pass. that is the opposite of what the fallback comment intends ("saving slightly stale data beats dropping this run's probe results").

the corresponding test (`"falls back to saving its own snapshot clone when the reload fails"`) confirms this gap: it uses `mockRejectedValue(new Error("EBUSY"))`, which exercises the dead catch, but the production `loadQuotaCache` would resolve instead of reject. the passing test gives false safety on a code path that is never reached.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment on lines +107 to +121
it("falls back to saving its own snapshot clone when the reload fails", async () => {
loadQuotaCacheMock.mockRejectedValue(new Error("EBUSY"));

const result = await refreshQuotaCacheForMenu(
createStorage(Date.now()),
emptyCache(),
60_000,
);

expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
expect(saved.byAccountId.acc_a).toMatchObject({ status: 200 });
expect(saved.byAccountId.acc_b).toMatchObject({ status: 200 });
expect(result).toBe(saved);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 test exercises dead catch branch

loadQuotaCacheMock.mockRejectedValue(new Error("EBUSY")) is the only way to reach the catch in the rebase block, but the real loadQuotaCache in lib/quota-cache.ts never rejects — it catches internally and returns {}. this test passes but verifies a code path that never fires in production, so the actual EBUSY behavior (silent empty-object reload → partial save) has no coverage.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/codex-manager-login-menu-refresh.test.ts
Line: 107-121

Comment:
**test exercises dead catch branch**

`loadQuotaCacheMock.mockRejectedValue(new Error("EBUSY"))` is the only way to reach the catch in the rebase block, but the real `loadQuotaCache` in `lib/quota-cache.ts` never rejects — it catches internally and returns `{}`. this test passes but verifies a code path that never fires in production, so the actual EBUSY behavior (silent empty-object reload → partial save) has no coverage.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment on lines +95 to +101
async function drainPendingMenuQuotaRefresh(
state: MenuQuotaRefreshState,
): Promise<void> {
if (state.pendingMenuQuotaRefresh) {
await state.pendingMenuQuotaRefresh;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 no test pins the drain-before-exit contract

the three drainPendingMenuQuotaRefresh call-sites cover the stated exit paths, but no vitest case asserts that a drain actually happens before persistAccountPool is reached. the codex-manager-cli.test.ts changes verify refresh settling within a menu pass, which is a different assertion. a test that starts a slow in-flight refresh and verifies persistAccountPool is only called after the drain resolves would lock in the windows EBUSY fix and prevent regression on this path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager/login-flow.ts
Line: 95-101

Comment:
**no test pins the drain-before-exit contract**

the three `drainPendingMenuQuotaRefresh` call-sites cover the stated exit paths, but no vitest case asserts that a drain actually happens before `persistAccountPool` is reached. the `codex-manager-cli.test.ts` changes verify refresh settling within a menu pass, which is a different assertion. a test that starts a slow in-flight refresh and verifies `persistAccountPool` is only called after the drain resolves would lock in the windows EBUSY fix and prevent regression on this path.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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 `@test/codex-manager-login-menu-refresh.test.ts`:
- Around line 107-121: Add an explicit assertion that the reload was attempted
by asserting loadQuotaCacheMock was called once in the test for
refreshQuotaCacheForMenu fallback; i.e., in the "falls back to saving its own
snapshot clone when the reload fails" test add
expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1) so the test verifies that
refreshQuotaCacheForMenu invoked loadQuotaCacheMock before falling back and
saving via saveQuotaCacheMock.
- Around line 123-136: Add a non-mutation assertion to the "does not reload or
save when every probe fails" test: after calling refreshQuotaCacheForMenu (with
createStorage, emptyCache, and mocked fetchCodexQuotaSnapshot failure), assert
that the returned result is not the same object instance as the input cache
(e.g., expect(result).not.toBe(cache)) in addition to the existing equality
check; this ensures refreshQuotaCacheForMenu does not mutate the passed-in cache
in place while keeping the existing checks for loadQuotaCacheMock and
saveQuotaCacheMock.
- Around line 83-105: Add an explicit assertion that loadQuotaCacheMock was
called once to this test ("rebases its results onto the freshest persisted cache
before saving"); specifically, after invoking refreshQuotaCacheForMenu and
before or after the save assertions, add
expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1) to ensure the test verifies
the load path (the call to loadQuotaCacheMock) in addition to the existing
saveQuotaCacheMock and saved result checks.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d2a8b159-f749-4c7c-afaf-67a310020a20

📥 Commits

Reviewing files that changed from the base of the PR and between edd6562 and 37548ef.

📒 Files selected for processing (4)
  • lib/codex-manager/login-flow.ts
  • lib/codex-manager/login-menu-data.ts
  • test/codex-manager-cli.test.ts
  • test/codex-manager-login-menu-refresh.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (9)
lib/**/*.ts

📄 CodeRabbit inference engine (lib/AGENTS.md)

lib/**/*.ts: All public exports should flow through lib/index.ts or documented package subpaths
Never import from dist/ in source tests or library code
Never suppress type errors

Files:

  • lib/codex-manager/login-flow.ts
  • lib/codex-manager/login-menu-data.ts
**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Do not use as any, @ts-ignore, or @ts-expect-error type assertions

Files:

  • lib/codex-manager/login-flow.ts
  • test/codex-manager-login-menu-refresh.test.ts
  • lib/codex-manager/login-menu-data.ts
  • test/codex-manager-cli.test.ts
**/*.{ts,js}

📄 CodeRabbit inference engine (AGENTS.md)

Use ESM module syntax exclusively; the project is ESM-only with "type": "module"

Files:

  • lib/codex-manager/login-flow.ts
  • test/codex-manager-login-menu-refresh.test.ts
  • lib/codex-manager/login-menu-data.ts
  • test/codex-manager-cli.test.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/codex-manager/login-flow.ts
  • lib/codex-manager/login-menu-data.ts
**

⚙️ CodeRabbit configuration file

**: # PROJECT KNOWLEDGE BASE

Generated: 2026-04-25
Commit: a87e005
Branch: main
Package version: 2.2.0

OVERVIEW

codex-multi-auth is a Codex CLI-first OAuth account manager and optional forwarding wrapper for the official Codex CLI. The installed codex-multi-auth entrypoint handles account-management commands locally, codex-multi-auth-codex forwards official Codex commands through this package's wrapper when explicitly used, and runtime rotation can route live Responses traffic through a localhost account-rotation proxy by default. The plugin-host entrypoint remains exported for compatibility, but the primary product surface is the account manager, optional wrapper, storage, runtime proxy, and repair tooling.

STRUCTURE

./
├── scripts/
│   ├── codex.js              # codex-multi-auth-codex wrapper, official CLI forwarder, shadow CODEX_HOME/runtime proxy setup
│   ├── codex-multi-auth.js   # standalone package CLI entrypoint
│   ├── codex-routing.js      # auth command and compatibility alias routing
│   ├── codex-bin-resolver.js # official Codex binary discovery
│   ├── codex-app-router.js   # persistent localhost router for packaged Codex app bind
│   └── codex-app-launcher.js # reversible user-level app launcher routing helper
├── index.ts                  # optional plugin-host runtime entry
├── lib/                      # core runtime logic (see lib/AGENTS.md)
│   ├── auth/                 # OAuth flow, PKCE, callback server
│   ├── runtime/              # Codex CLI/app integration helpers, app bind, live sync, runtime observability
│   ├── request/              # request transform, SSE, failover, backoff
│   ├── storage/              # path resolution, migrations, backups, restore, import/export
│   ├── codex-cli/            # Codex CLI state sync and writer helpers
│   ├── codex-manager/        # command modules and settings panels
│   ├── prompts/              # model-family prompts, GitHub ETag cache
│   ├── recovery/             # conve...

Files:

  • lib/codex-manager/login-flow.ts
  • test/codex-manager-login-menu-refresh.test.ts
  • lib/codex-manager/login-menu-data.ts
  • test/codex-manager-cli.test.ts
test/**/*.test.ts

📄 CodeRabbit inference engine (test/AGENTS.md)

test/**/*.test.ts: Vitest globals (describe, it, expect) are enabled and should be used without explicit imports
Maintain 80% coverage threshold across statements, branches, functions, and lines
Use removeWithRetry for Windows filesystem cleanup instead of bare fs.rm to handle EBUSY/EPERM/ENOTEMPTY backoff
Use source files in tests, not compiled dist/ files; test the source directly
Do not skip tests without justification; include rationale if a test must be skipped
Relax ESLint rules for test files as specified in eslint.config.js

Files:

  • test/codex-manager-login-menu-refresh.test.ts
  • test/codex-manager-cli.test.ts
test/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Windows filesystem operations must include retry handling for transient EBUSY, EPERM, and ENOTEMPTY errors where tests cover Windows locks

Files:

  • test/codex-manager-login-menu-refresh.test.ts
  • test/codex-manager-cli.test.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-login-menu-refresh.test.ts
  • test/codex-manager-cli.test.ts
test/**/codex-manager-cli.test.ts

📄 CodeRabbit inference engine (test/AGENTS.md)

Test CLI settings with question cancellation across all 5 panels and EBUSY/concurrent race conditions

Files:

  • test/codex-manager-cli.test.ts
🧠 Learnings (2)
📚 Learning: 2026-06-04T06:14:18.093Z
Learnt from: ndycode
Repo: ndycode/codex-multi-auth PR: 510
File: test/scheduling-strategy-config.test.ts:1-1
Timestamp: 2026-06-04T06:14:18.093Z
Learning: In ndycode/codex-multi-auth, do not flag explicit imports from "vitest" (e.g., describe, it, expect, beforeEach/afterEach, etc.) in test files as issues—even if the Vitest config sets `globals: true`. The repo’s established convention is to keep these imports for consistency with neighboring tests; removing them would make files outliers.

Applied to files:

  • test/codex-manager-login-menu-refresh.test.ts
  • test/codex-manager-cli.test.ts
📚 Learning: 2026-06-04T06:14:24.975Z
Learnt from: ndycode
Repo: ndycode/codex-multi-auth PR: 510
File: test/runtime-rotation-proxy.test.ts:2478-2491
Timestamp: 2026-06-04T06:14:24.975Z
Learning: In ndycode/codex-multi-auth test files (e.g. `test/*.test.ts`), when creating V3 storage fixtures for accounts, it’s an intentional convention to use `as never` for deliberately minimal stored-account objects that only include `refreshToken`, `addedAt`, and `lastUsed`. Do not treat `as never` here as a type-safety problem: optional/other fields are expected to be populated by the runtime during execution, and the cast is used solely to keep the fixture minimal and consistent across existing tests.

Applied to files:

  • test/codex-manager-login-menu-refresh.test.ts
  • test/codex-manager-cli.test.ts
🔇 Additional comments (11)
lib/codex-manager/login-menu-data.ts (4)

11-19: LGTM!


188-191: LGTM!


202-210: LGTM!


216-250: LGTM!

lib/codex-manager/login-flow.ts (4)

89-101: LGTM!


142-144: LGTM!


219-222: LGTM!


322-324: LGTM!

test/codex-manager-login-menu-refresh.test.ts (2)

1-80: LGTM!


138-148: LGTM!

test/codex-manager-cli.test.ts (1)

753-760: LGTM!

Also applies to: 9080-9100, 9273-9284

Comment on lines +83 to +105
it("rebases its results onto the freshest persisted cache before saving", async () => {
// Regression for the last-write-wins clobber: a concurrent writer (deep
// check, second session) saved acc_concurrent while the menu refresh was
// probing against its stale snapshot clone. The whole-file save must keep
// that entry, not silently discard it.
loadQuotaCacheMock.mockResolvedValue({
byAccountId: { acc_concurrent: { ...CONCURRENT_ENTRY } },
byEmail: {},
});

const result = await refreshQuotaCacheForMenu(
createStorage(Date.now()),
emptyCache(),
60_000,
);

expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
expect(saved.byAccountId.acc_concurrent).toMatchObject({ status: 429 });
expect(saved.byAccountId.acc_a).toMatchObject({ status: 200 });
expect(saved.byAccountId.acc_b).toMatchObject({ status: 200 });
expect(result).toBe(saved);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

add explicit load call assertion to strengthen the regression test.

the test correctly verifies the rebase behavior (concurrent entry preserved), but doesn't assert that loadQuotaCacheMock was called exactly once. this is indirectly verified (the concurrent entry could only come from the load), but an explicit expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1) would make the test more robust against refactoring and clearer about the code path.

suggested assertion
 		expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
+		expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1);
 		const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("rebases its results onto the freshest persisted cache before saving", async () => {
// Regression for the last-write-wins clobber: a concurrent writer (deep
// check, second session) saved acc_concurrent while the menu refresh was
// probing against its stale snapshot clone. The whole-file save must keep
// that entry, not silently discard it.
loadQuotaCacheMock.mockResolvedValue({
byAccountId: { acc_concurrent: { ...CONCURRENT_ENTRY } },
byEmail: {},
});
const result = await refreshQuotaCacheForMenu(
createStorage(Date.now()),
emptyCache(),
60_000,
);
expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
expect(saved.byAccountId.acc_concurrent).toMatchObject({ status: 429 });
expect(saved.byAccountId.acc_a).toMatchObject({ status: 200 });
expect(saved.byAccountId.acc_b).toMatchObject({ status: 200 });
expect(result).toBe(saved);
});
it("rebases its results onto the freshest persisted cache before saving", async () => {
// Regression for the last-write-wins clobber: a concurrent writer (deep
// check, second session) saved acc_concurrent while the menu refresh was
// probing against its stale snapshot clone. The whole-file save must keep
// that entry, not silently discard it.
loadQuotaCacheMock.mockResolvedValue({
byAccountId: { acc_concurrent: { ...CONCURRENT_ENTRY } },
byEmail: {},
});
const result = await refreshQuotaCacheForMenu(
createStorage(Date.now()),
emptyCache(),
60_000,
);
expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1);
const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
expect(saved.byAccountId.acc_concurrent).toMatchObject({ status: 429 });
expect(saved.byAccountId.acc_a).toMatchObject({ status: 200 });
expect(saved.byAccountId.acc_b).toMatchObject({ status: 200 });
expect(result).toBe(saved);
});
🤖 Prompt for 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.

In `@test/codex-manager-login-menu-refresh.test.ts` around lines 83 - 105, Add an
explicit assertion that loadQuotaCacheMock was called once to this test
("rebases its results onto the freshest persisted cache before saving");
specifically, after invoking refreshQuotaCacheForMenu and before or after the
save assertions, add expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1) to
ensure the test verifies the load path (the call to loadQuotaCacheMock) in
addition to the existing saveQuotaCacheMock and saved result checks.

Comment on lines +107 to +121
it("falls back to saving its own snapshot clone when the reload fails", async () => {
loadQuotaCacheMock.mockRejectedValue(new Error("EBUSY"));

const result = await refreshQuotaCacheForMenu(
createStorage(Date.now()),
emptyCache(),
60_000,
);

expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
expect(saved.byAccountId.acc_a).toMatchObject({ status: 200 });
expect(saved.byAccountId.acc_b).toMatchObject({ status: 200 });
expect(result).toBe(saved);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

add explicit load call assertion for the fallback path.

the test correctly verifies the fallback behavior (saves clone when reload fails), but should assert expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1) to confirm the code path attempted the reload. this makes the test's intent clearer and guards against future changes that might skip the reload entirely.

suggested assertion
 		expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
+		expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1);
 		const saved = saveQuotaCacheMock.mock.calls[0][0] as QuotaCacheData;
🤖 Prompt for 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.

In `@test/codex-manager-login-menu-refresh.test.ts` around lines 107 - 121, Add an
explicit assertion that the reload was attempted by asserting loadQuotaCacheMock
was called once in the test for refreshQuotaCacheForMenu fallback; i.e., in the
"falls back to saving its own snapshot clone when the reload fails" test add
expect(loadQuotaCacheMock).toHaveBeenCalledTimes(1) so the test verifies that
refreshQuotaCacheForMenu invoked loadQuotaCacheMock before falling back and
saving via saveQuotaCacheMock.

Comment on lines +123 to +136
it("does not reload or save when every probe fails", async () => {
fetchCodexQuotaSnapshotMock.mockRejectedValue(new Error("network"));

const cache = emptyCache();
const result = await refreshQuotaCacheForMenu(
createStorage(Date.now()),
cache,
60_000,
);

expect(loadQuotaCacheMock).not.toHaveBeenCalled();
expect(saveQuotaCacheMock).not.toHaveBeenCalled();
expect(result).toEqual(cache);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

consider adding non-mutation verification for completeness.

test 3 correctly verifies the no-change path skips reload/save and returns a cache equal to the input. however, it doesn't verify the result is a distinct clone (expect(result).not.toBe(cache)), which would catch a hypothetical bug where the function mutates the input in place. this is a minor gap; the test already validates the functional contract, but explicit non-mutation checks strengthen regression coverage.

optional assertion
 		expect(loadQuotaCacheMock).not.toHaveBeenCalled();
 		expect(saveQuotaCacheMock).not.toHaveBeenCalled();
 		expect(result).toEqual(cache);
+		expect(result).not.toBe(cache);
🤖 Prompt for 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.

In `@test/codex-manager-login-menu-refresh.test.ts` around lines 123 - 136, Add a
non-mutation assertion to the "does not reload or save when every probe fails"
test: after calling refreshQuotaCacheForMenu (with createStorage, emptyCache,
and mocked fetchCodexQuotaSnapshot failure), assert that the returned result is
not the same object instance as the input cache (e.g.,
expect(result).not.toBe(cache)) in addition to the existing equality check; this
ensures refreshQuotaCacheForMenu does not mutate the passed-in cache in place
while keeping the existing checks for loadQuotaCacheMock and saveQuotaCacheMock.

ndycode added a commit that referenced this pull request Jun 10, 2026
fix(codex-manager): close the menu quota-refresh write races

Conflict resolution: kept HEAD mock-factory version of codex-manager-cli.test.ts;
quota-cache race tests covered by codex-manager-login-menu-refresh.test.ts
@ndycode ndycode closed this Jun 10, 2026
ndycode added a commit that referenced this pull request Jun 11, 2026
…ertions

test: strengthen the quota-refresh regression suite per #549 review
luo178 pushed a commit to luo178/codex-multi-auth that referenced this pull request Jun 23, 2026
…eview

The three CodeRabbit suggestions landed after ndycode#549 merged, applied here
as a follow-up: the rebase and reload-fallback tests now assert the
loadQuotaCache call explicitly, and the no-change test pins that the
caller's snapshot is returned as a clone, never mutated in place. Also
covers the previously untested save-failure path: a rejecting
saveQuotaCache must resolve the refresh and surface console.warn rather
than vanish into the caller's background .catch.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
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.

2 participants