fix(cost): quarantine unreadable session logs instead of failing every poll - #224
fix(cost): quarantine unreadable session logs instead of failing every poll#224rohanpoudel2 wants to merge 5 commits into
Conversation
…y poll `readSessionUsage` treated its two failure modes asymmetrically. An oversized event marked the session `unreadable` before rethrowing, so the next poll skipped that file and the failure surfaced once. A session file that could not be opened at all — a non-ENOENT `open()` error such as EACCES — was rethrown without marking anything, so every subsequent poll reopened the same file and threw again, forever. The permanence compounds in `#readSessions`, which only defers a failure when the session's thread is already known. Because `open()` failed, the thread id was never read, so the error bypasses the `included` thread-tree filter and aborts the whole scan even when the file belongs to an unrelated prior session. A root-owned rollout left behind by a single `sudo codex` run is enough to trigger it: with a cost limit the poll's `onError` aborts the scan almost immediately, and without one `stop()` rejects and reports a scan that completed successfully as failed. Quarantine unopenable sessions through the same path as oversized ones so the failure is reported once and later polls keep tracking the sessions they can read. `isMissingFile` handling is unchanged: a file that vanished between the directory walk and the open is still skipped silently. Also guard the `refresh()` inside `stop()`. `stop()` runs after the turn is over, so it cannot abort anything and cannot under-enforce `--max-cost`; enforcement happens in the polling path, which still reports errors. Letting it reject only discarded the authoritative usage the completed turn handed to it, which is exactly the fallback the existing "falls back to the completed turn when session logs are unavailable" test documents but which was unreachable whenever the sessions directory existed and could not be scanned.
|
Note on overlap: #177 and #198 also change |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fdc1be55f4
ℹ️ 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".
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Two follow-ups to the quarantine change, both about usage the tracker stops observing. `stop()` swallows a failed final refresh so a completed turn is not reported as a failure, but it then returned `#snapshot` purely because it was non-null. When the last poll succeeded and the final refresh did not - the scan's own rollout gaining an oversized event, or its permissions changing as the turn completes - that snapshot predates the completed turn, so the caller's authoritative `fallbackUsage` was discarded, no fresh `onCost` fired, and `api.ts` recorded the stale below-limit cost for a turn that had already passed `maxCostUsd`. `stop()` now tracks whether this refresh succeeded and, when it did not, takes whichever of the two charges more, so a stale poll can no longer hide spend and the snapshot still wins whenever it counts delegated worker threads the completed turn does not. The open failure exit quarantined unconditionally, which is wrong for a process-wide shortage: an EMFILE from momentary descriptor pressure retires the session permanently, and every later refresh short-circuits at the `session.unreadable` guard, so its usage is never observed again even after descriptors free up. Quarantine is now limited to persistent file-specific codes (EACCES, EPERM, EISDIR, ELOOP, ENAMETOOLONG, ENOTDIR); anything else stays retryable. The root-owned rollout from issue openai#223 is EACCES, so it is still reported once and then skipped. Four tests in tests-ts/cost.test.ts: the stale snapshot losing to a larger completed turn and winning against a smaller one, and an open failure that recovers on a later poll for EMFILE while EACCES is never reopened.
The retryable half of the open-failure classification had no floor. Only the six listed codes were quarantined, so any other code that never clears - EBUSY from a Windows rollout held open without sharing, EIO from a failing disk - was reopened on every poll for the life of the scan, which is the exact defect this PR exists to remove. The allowlist is now the fast path rather than the only path: a session is retried up to MAX_SESSION_OPEN_ATTEMPTS consecutive failures and then retired, and the counter resets on any successful open so separate outages never add up. EMFILE still recovers within the budget; EACCES is still retired on its first failure without spending retries on a file that cannot change. The stop() fallback also gains the api.ts-level test the reasoning depends on: a scan whose recorded snapshot is below --max-cost, whose rollout gains an oversized event as the turn completes, and whose completed turn is over the limit. With the fallback in place the run rejects with ScanCostLimitExceededError for the completed turn's own $0.00625 and records fail-scan; with only the stop() guard reverted the same run resolves successfully, which is the budget bypass stated.
Fixes #223
Problem
readSessionUsagehas two failure exits and only one of them records that the session is bad:The oversized-event exit sets
session.unreadable, so theif (session.unreadable) return;guard skips that file on every later poll — one error, then the tracker moves on. Theopen()exit rethrows without recording anything, so the next poll reopens the same file and fails identically, for the life of the scan.#readSessionsamplifies it: a failure is only deferred to theincludedthread-tree filter whensession.threadId !== null. An unopenable session never got a thread id, soif (session.threadId === null) throw error;fires and the error escapes the filter that exists precisely to ignore unrelated sessions. A root-owned rollout from onesudo codexrun then fails every poll of every later scan.stop()had the mirror-image problem:The unguarded refresh rejected, which discarded the caller's
fallbackUsage— the completed turn's own authoritative usage — and reported a scan that had already succeeded as failed. Swallowing that rejection alone is not enough, because the non-null check then returns the pre-completion snapshot. Inapi.tsthat stale snapshot is whatonFinalizerecords throughcomplete-scan --cost-json, and since no newonCostfires,maxCostUsdis never compared against the completed turn:A scan whose completed turn exceeded
--max-costtherefore completes, recorded below its limit.The suite already pins the tolerant half of the first defect — "ignores oversized events from unrelated prior credential sessions". This PR makes the unopenable case behave the same way, and makes the completed turn reachable.
Change
src/cost.ts, +67/−5.quarantineSession(session), called from both failure exits, so an open failure is handled like a parse failure.PERMANENT_ACCESS_ERROR_CODES—EACCES,EPERM,EISDIR,ELOOP,ENAMETOOLONG,ENOTDIR— retires a session on its first failure. Every other code is retried, up toMAX_SESSION_OPEN_ATTEMPTS(5) consecutive failures, after which the session is retired regardless of code.session.openFailuresresets on any successful open.stop()records whether its final refresh succeeded. When it did not, the completed turn'sfallbackUsageis costed and wins whenever it charges more than the stale snapshot, which reports a freshonCost.isMissingFilehandling is untouched: a file that vanished between the directory walk and the open is still skipped silently, with no quarantine. A successful refresh still returns the snapshot unconditionally, so precedence on the healthy path is unchanged.Why not reject from
stop()when the final refresh failsThat is the behaviour being removed, and it deserves scrutiny because weakening cost handling could weaken
--max-cost. It does not:start()→refresh()→onCost→ abort).stop()runs after the turn is over; there is nothing left to abort and rejecting cannot recover spend that already happened.onError.fallbackUsageis the completed turn's own usage — better than the log-scraped snapshot, not worse.onErrorwould be self-defeating. Inapi.ts,onErrorabortscostAbortControllerandonFinalizecallsthrowIfAborted(signal, scanDir)immediately afterstop(), so reporting fromstop()would reconstruct the exact failure this fixes.api.tsalready doesawait costTracker?.stop().catch(() => null)on the failure path.Why not always prefer
fallbackUsagewhen the refresh failsBecause
#snapshotsums the scan thread and its delegated worker sessions, whilefallbackUsageis the completed turn of the main thread alone. On a scan with workers the snapshot is legitimately the larger figure, so unconditionally preferring the fallback would trade a stale under-report for a structural one. Taking whichever charges more is conservative in both directions, and it is the only comparison added — a real, current snapshot still wins outright.Why not quarantine every open failure
A process-wide shortage such as
EMFILEorENFILEclears on its own. Retiring a session on that first failure means every later refresh short-circuits at thesession.unreadableguard, so the tracker never observes that session's usage again even after descriptors free up. That matters for enforcement, not just reporting: a delegated worker whose parent chain is not yet visible sits outsideincluded, so its failure is swallowed by the thread-tree filter, and if it were retired there it would still be quarantined when the tree later grows to include it — under-counting a session the budget is supposed to cover.Why not classify by errno alone
An allowlist on its own moves the defect rather than removing it. Any retryable code that never actually clears —
EBUSYfor a rollout another process holds open without sharing on Windows,EIOon failing media — would be reopened on every 100 ms poll for the life of the scan, which is exactly what this PR exists to stop, relocated to the codes nobody enumerated. So the allowlist is the fast path and the retry budget is the floor: known-permanent codes never spend a retry, and everything else is retired after five consecutive failures. The reset on success keeps two separate outages from adding up to a retirement.Impact, stated plainly
--max-costis now enforced even when the final refresh fails. Before this change that scan completed with the pre-completion cost recorded; there is an api-level test below that resolves successfully without the fix and rejects with it.#readSessionsstill rethrows when the failing session has no thread id yet, so under--max-costthe first such error still reachesonErrorand aborts. That is issue An unreadable Codex session log fails every cost poll for the rest of the scan, and can fail a scan that succeeded #223's stated expected behaviour ("reported once"), and changing it would weaken the path that enforces the limit.Verification
Nine new tests: eight in
tests-ts/cost.test.ts, one intests-ts/api.test.ts. The POSIX permission fixtures use the repository'stestPosixpattern and restore0o600in afinallysoafterEachteardown can still delete them; the errno cases mocknode:fs/promises'sopenfor one path and count attempts, which is howEMFILE/EBUSYare reachable in a test at all.The permanence test is deliberately order-independent: whichever file
readdiryields first, the firstrefresh()rejects and the second must succeed.Full suite: 747 pass / 5 skip / 0 fail (752 tests, 34 files).
With
src/cost.tsreverted to this PR's base and all the tests kept: 740 pass / 5 skip / 7 fail.The halves are independently load-bearing. Reverting one piece at a time, against
tests-ts/cost.test.ts(23 tests):stop()fallback onlyapi.test.ts, "enforces the cost limit on a completed turn the final refresh cannot read" —Expected promise that rejects / Received promise that resolved)pnpm run typesandpnpm run formatare clean.Deliberately out of scope
sessionFileshas the same shape at directory level — a non-ENOENTreaddirerror has no per-directory state to quarantine and would also repeat every poll. It is outside this trigger and needs new state rather than reuse of the existing flag. Noted in An unreadable Codex session log fails every cost poll for the rest of the scan, and can fail a scan that succeeded #223.api.tscallsstop()twice on the failure path (onFinalize, then thecatch). The second call re-reads the now-quarantined session and can recompute a lower cost than the fallback the first call adopted, sofail-scan --cost-jsoncan record less than the limit-exceeded message states. That is pre-existing — before this PR the same path recorded the same lower figure, with a worse error attached — and makingstop()terminal is a behavioural change to a call site this PR does not otherwise touch.