Conversation
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
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
📝 WalkthroughSummaryThis 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. Changeslib/codex-manager/login-flow.ts
lib/codex-manager/login-menu-data.ts
test/codex-manager-login-menu-refresh.test.ts (new)
test/codex-manager-cli.test.ts
Validation
WalkthroughFixes 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. ChangesQuota Refresh Concurrent Safety
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested labels
review notescache rebasing risks: concurrency window: the drain helper at test isolation: missing edge case: the new test suite in windows path consideration: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
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
test/codex-manager-cli.test.tsOops! Something went wrong! :( ESLint: 10.0.0 Error: The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it. test/codex-manager-login-menu-refresh.test.tsOops! Something went wrong! :( ESLint: 10.0.0 Error: The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it. 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. Comment |
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
| } catch { | ||
| // Fall back to the snapshot clone; saving slightly stale data beats | ||
| // dropping this run's probe results. | ||
| } |
There was a problem hiding this 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.
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.| 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); | ||
| }); |
There was a problem hiding this 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.
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.| async function drainPendingMenuQuotaRefresh( | ||
| state: MenuQuotaRefreshState, | ||
| ): Promise<void> { | ||
| if (state.pendingMenuQuotaRefresh) { | ||
| await state.pendingMenuQuotaRefresh; | ||
| } | ||
| } |
There was a problem hiding this 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.
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!
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
lib/codex-manager/login-flow.tslib/codex-manager/login-menu-data.tstest/codex-manager-cli.test.tstest/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 throughlib/index.tsor documented package subpaths
Never import fromdist/in source tests or library code
Never suppress type errors
Files:
lib/codex-manager/login-flow.tslib/codex-manager/login-menu-data.ts
**/*.ts
📄 CodeRabbit inference engine (AGENTS.md)
Do not use
as any,@ts-ignore, or@ts-expect-errortype assertions
Files:
lib/codex-manager/login-flow.tstest/codex-manager-login-menu-refresh.test.tslib/codex-manager/login-menu-data.tstest/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.tstest/codex-manager-login-menu-refresh.test.tslib/codex-manager/login-menu-data.tstest/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.tslib/codex-manager/login-menu-data.ts
**
⚙️ CodeRabbit configuration file
**: # PROJECT KNOWLEDGE BASEGenerated: 2026-04-25
Commit: a87e005
Branch: main
Package version: 2.2.0OVERVIEW
codex-multi-authis a Codex CLI-first OAuth account manager and optional forwarding wrapper for the official Codex CLI. The installedcodex-multi-authentrypoint handles account-management commands locally,codex-multi-auth-codexforwards 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.tstest/codex-manager-login-menu-refresh.test.tslib/codex-manager/login-menu-data.tstest/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
UseremoveWithRetryfor Windows filesystem cleanup instead of barefs.rmto handle EBUSY/EPERM/ENOTEMPTY backoff
Use source files in tests, not compileddist/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 ineslint.config.js
Files:
test/codex-manager-login-menu-refresh.test.tstest/codex-manager-cli.test.ts
test/**/*.ts
📄 CodeRabbit inference engine (AGENTS.md)
Windows filesystem operations must include retry handling for transient
EBUSY,EPERM, andENOTEMPTYerrors where tests cover Windows locks
Files:
test/codex-manager-login-menu-refresh.test.tstest/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.tstest/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.tstest/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.tstest/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
| 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); | ||
| }); |
There was a problem hiding this comment.
🧹 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.
| 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.
| 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); | ||
| }); |
There was a problem hiding this comment.
🧹 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.
| 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); | ||
| }); |
There was a problem hiding this comment.
🧹 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.
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
…ertions test: strengthen the quota-refresh regression suite per #549 review
…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
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.
Fix 1 — last-write-wins clobber (flagged on #540)
refreshQuotaCacheForMenuprobed 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 sameupdateQuotaCacheForAccountlogic, 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
persistAccountPoolstorage write (WindowsEBUSY/EPERMon 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 inrefreshQuotaCacheForMenu(tracks applied(account, snapshot)pairs, re-applies onto a freshloadQuotaCache()result).lib/codex-manager/login-flow.ts:drainPendingMenuQuotaRefreshhelper called at the three dashboard exit points.test/codex-manager-login-menu-refresh.test.ts(new): pins the rebase (a concurrentacc_concurrententry 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'sloadQuotaCachemock 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 thestatusMessage()observable (cleared in the same.finallythat frees the pending slot) instead of relying on exact microtask counts, which the added rebaseawaitshifted.Validation
npm run typecheck; eslint on all 4 touched files--max-warnings=0Risk / 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 becauseloadQuotaCachenever 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:drainPendingMenuQuotaRefreshcalled at empty-storage, cancel, and add-account exits — prevents windows EBUSY/EPERM on the subsequentpersistAccountPoolwrite.test/codex-manager-login-menu-refresh.test.ts(new): four rebase scenarios; the reload-failure case mocksloadQuotaCacheto 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 tovi.waitForonstatusMessage()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
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 racePrompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(codex-manager): close the menu quota..." | Re-trigger Greptile