Summary
When maxCostUsd is enabled, ScanCostTracker starts a 100 ms interval. Each tick calls refresh(), and refresh() chains a new full session-tree pass onto #pending even when the previous pass has not completed.
Each pass recursively enumerates $CODEX_HOME/sessions and opens every discovered .jsonl file. On a Codex home with substantial history, polling can therefore build a queue of full traversals. This delays both shutdown and the onCost callback that triggers the cost-limit abort.
Affected version and environment
- Released package:
@openai/codex-security@0.1.1
- Originally reproduced at commit
e94d6bef9797a192febfde89a26ec7f831bc09b2
- Confirmed still present on current
main at f22d4a36f26d16287bcdfd707b369116e02a08c3
- Microsoft Windows NT 10.0.19045.0
- Node.js 24.18.0
Steps to reproduce
- Create a temporary Codex home.
- Under
sessions/2026/07/29, create 3,000 empty .jsonl files. Empty files isolate directory enumeration and file-open overhead from JSON parsing.
- Construct a tracker:
const tracker = new ScanCostTracker({
codexHome: temporaryHome,
model: "gpt-5.6-sol",
maxCostUsd: 1,
});
- Call
tracker.start("target-thread").
- Wait 350 ms so multiple interval ticks occur.
- Measure how long
await tracker.stop() takes.
Observed on my machine:
{
"historicalSessionFiles": 3000,
"elapsedBeforeStopMs": 349,
"stopWaitMs": 1260,
"totalMs": 1609
}
Exact timings will vary by filesystem and hardware. The deterministic behavior is that ticks arriving while a refresh is running are appended to #pending, and every queued refresh repeats the recursive traversal.
Expected behavior
At most one session refresh should be active or pending. Timer ticks that occur during a running refresh should be coalesced rather than queued as additional full traversals.
Cost updates and the cost-limit abort should not fall progressively behind because the Codex home contains unrelated historical sessions.
Actual behavior
Every timer tick schedules another traversal. stop() waits for the queued traversals, and onCost cannot report usage discovered by a delayed pass until the earlier passes finish.
Impact
- Cost-limit enforcement can be delayed relative to the active scan.
- A completed or aborted scan can spend additional time waiting for queued polling work.
- The overhead grows with unrelated historical session files, not just the current scan and its child threads.
Relevant code
- Poll interval and refresh queue:
|
const COST_POLL_INTERVAL_MS = 100; |
|
const SESSION_READ_SIZE = 64 * 1_024; |
|
|
|
export class ScanCostTracker { |
|
readonly #options: ScanCostTrackerOptions; |
|
readonly #sessions = new Map<string, SessionUsage>(); |
|
#threadId: string | null = null; |
|
#timer: NodeJS.Timeout | null = null; |
|
#pending: Promise<void> = Promise.resolve(); |
|
#snapshot: ScanCostSnapshot = { usage: null, cost: null }; |
|
#lastCost: number | null = null; |
|
|
|
public constructor(options: ScanCostTrackerOptions) { |
|
this.#options = options; |
|
} |
|
|
|
public start(threadId: string): void { |
|
if (this.#threadId !== null) return; |
|
this.#threadId = threadId; |
|
if (this.#options.maxCostUsd === undefined) return; |
|
const poll = () => { |
|
void this.refresh().catch((error: unknown) => { |
|
this.#options.onError?.(error); |
|
}); |
|
}; |
|
this.#timer = setInterval(poll, COST_POLL_INTERVAL_MS); |
|
this.#timer.unref(); |
|
poll(); |
|
} |
|
|
|
public async refresh(): Promise<ScanCostSnapshot> { |
|
const update = this.#pending.then(async () => { |
|
await this.#readSessions(); |
|
}); |
|
this.#pending = update.catch(() => {}); |
|
await update; |
|
return this.#snapshot; |
|
} |
|
|
|
public async stop(fallbackUsage?: unknown): Promise<ScanCostSnapshot> { |
|
if (this.#timer !== null) { |
|
clearInterval(this.#timer); |
|
this.#timer = null; |
|
} |
|
await this.refresh(); |
|
if (this.#snapshot.usage !== null) return this.#snapshot; |
|
const cost = estimateScanCost(this.#options.model, fallbackUsage); |
|
this.#snapshot = { usage: fallbackUsage ?? null, cost }; |
- Full session-tree traversal:
|
async #readSessions(): Promise<void> { |
|
if (this.#threadId === null) return; |
|
for await (const path of sessionFiles( |
|
join(this.#options.codexHome, "sessions"), |
|
)) { |
|
let session = this.#sessions.get(path); |
|
if (session === undefined) { |
|
session = { |
|
offset: 0, |
|
remainder: Buffer.alloc(0), |
|
threadId: null, |
|
parentThreadId: null, |
|
usage: null, |
|
}; |
|
this.#sessions.set(path, session); |
|
} |
|
await readSessionUsage(path, session); |
|
} |
|
|
|
const included = new Set([this.#threadId]); |
|
let changed = true; |
|
while (changed) { |
|
changed = false; |
|
for (const session of this.#sessions.values()) { |
|
if ( |
|
session.threadId !== null && |
|
session.parentThreadId !== null && |
|
included.has(session.parentThreadId) && |
|
!included.has(session.threadId) |
|
) { |
|
included.add(session.threadId); |
|
changed = true; |
|
} |
|
} |
|
} |
|
|
|
let usage: ScanTokenUsage | null = null; |
|
for (const session of this.#sessions.values()) { |
|
if ( |
|
session.threadId !== null && |
|
included.has(session.threadId) && |
|
session.usage !== null |
|
) { |
|
usage = addTokenUsage(usage, session.usage); |
|
} |
|
} |
|
if (usage === null) return; |
|
const cost = estimateScanCost(this.#options.model, usage); |
|
this.#snapshot = { usage, cost }; |
|
this.#reportCost(cost); |
|
} |
|
|
|
#reportCost(cost: ScanCost | null): void { |
|
if (cost === null || cost.estimatedUsd === this.#lastCost) return; |
|
this.#lastCost = cost.estimatedUsd; |
|
this.#options.onCost?.(cost); |
|
} |
|
} |
|
|
|
async function* sessionFiles(directory: string): AsyncGenerator<string> { |
|
let entries; |
|
try { |
|
entries = await readdir(directory, { withFileTypes: true }); |
|
} catch (error) { |
|
if (isMissingFile(error)) return; |
|
throw error; |
|
} |
|
for (const entry of entries) { |
|
const path = join(directory, entry.name); |
|
if (entry.isDirectory()) { |
|
yield* sessionFiles(path); |
|
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) { |
|
yield path; |
|
} |
|
} |
|
} |
|
|
|
async function readSessionUsage( |
|
path: string, |
|
session: SessionUsage, |
|
): Promise<void> { |
|
let file; |
|
try { |
|
file = await open(path, "r"); |
|
} catch (error) { |
|
if (isMissingFile(error)) return; |
|
throw error; |
|
} |
|
try { |
|
const buffer = Buffer.alloc(SESSION_READ_SIZE); |
- Cost-limit abort callback:
|
const tracker = new ScanCostTracker({ |
|
codexHome: runtime.codexHome, |
|
model, |
|
maxCostUsd: options.maxCostUsd, |
|
onCost: (cost) => { |
|
notifyObserver( |
|
"onCost", |
|
options.onCost, |
|
options.onObserverError, |
|
cost, |
|
); |
|
if ( |
|
options.maxCostUsd !== undefined && |
|
cost.estimatedUsd > options.maxCostUsd |
|
) { |
|
costAbortController.abort( |
|
new ScanCostLimitExceededError(options.maxCostUsd, cost, scanDir), |
|
); |
|
} |
|
}, |
|
onError: (error) => costAbortController.abort(error), |
Suggested direction
A minimal change could ensure only one refresh is in flight and coalesce subsequent timer ticks into at most one follow-up refresh.
A more complete optimization could also:
- cache directory/file discovery instead of walking the full tree every 100 ms;
- avoid reopening unchanged unrelated sessions;
- narrow candidates to the target thread and discovered child threads where possible.
A regression test could inject a slow session reader or traversal and assert that repeated timer ticks do not cause multiple queued full passes.
Related issue
This is related to cost control, but it is not a duplicate of #25. That issue asks how to estimate cost before a run; this report concerns runtime polling backlog and delayed enforcement of an already configured limit.
Summary
When
maxCostUsdis enabled,ScanCostTrackerstarts a 100 ms interval. Each tick callsrefresh(), andrefresh()chains a new full session-tree pass onto#pendingeven when the previous pass has not completed.Each pass recursively enumerates
$CODEX_HOME/sessionsand opens every discovered.jsonlfile. On a Codex home with substantial history, polling can therefore build a queue of full traversals. This delays both shutdown and theonCostcallback that triggers the cost-limit abort.Affected version and environment
@openai/codex-security@0.1.1e94d6bef9797a192febfde89a26ec7f831bc09b2mainatf22d4a36f26d16287bcdfd707b369116e02a08c3Steps to reproduce
sessions/2026/07/29, create 3,000 empty.jsonlfiles. Empty files isolate directory enumeration and file-open overhead from JSON parsing.tracker.start("target-thread").await tracker.stop()takes.Observed on my machine:
{ "historicalSessionFiles": 3000, "elapsedBeforeStopMs": 349, "stopWaitMs": 1260, "totalMs": 1609 }Exact timings will vary by filesystem and hardware. The deterministic behavior is that ticks arriving while a refresh is running are appended to
#pending, and every queued refresh repeats the recursive traversal.Expected behavior
At most one session refresh should be active or pending. Timer ticks that occur during a running refresh should be coalesced rather than queued as additional full traversals.
Cost updates and the cost-limit abort should not fall progressively behind because the Codex home contains unrelated historical sessions.
Actual behavior
Every timer tick schedules another traversal.
stop()waits for the queued traversals, andonCostcannot report usage discovered by a delayed pass until the earlier passes finish.Impact
Relevant code
codex-security/sdk/typescript/src/cost.ts
Lines 57 to 104 in f22d4a3
codex-security/sdk/typescript/src/cost.ts
Lines 109 to 198 in f22d4a3
codex-security/sdk/typescript/src/api.ts
Lines 503 to 523 in f22d4a3
Suggested direction
A minimal change could ensure only one refresh is in flight and coalesce subsequent timer ticks into at most one follow-up refresh.
A more complete optimization could also:
A regression test could inject a slow session reader or traversal and assert that repeated timer ticks do not cause multiple queued full passes.
Related issue
This is related to cost control, but it is not a duplicate of #25. That issue asks how to estimate cost before a run; this report concerns runtime polling backlog and delayed enforcement of an already configured limit.