Skip to content

maxCostUsd polling queues full session-tree rescans and can delay budget enforcement #31

Description

@ShawnSiao

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

  1. Create a temporary Codex home.
  2. Under sessions/2026/07/29, create 3,000 empty .jsonl files. Empty files isolate directory enumeration and file-open overhead from JSON parsing.
  3. Construct a tracker:
const tracker = new ScanCostTracker({
  codexHome: temporaryHome,
  model: "gpt-5.6-sol",
  maxCostUsd: 1,
});
  1. Call tracker.start("target-thread").
  2. Wait 350 ms so multiple interval ticks occur.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions