Skip to content

fix(sdk): prevent transcript memory leaks in scan tracking - #830

Open
msapelov wants to merge 1 commit into
openai:mainfrom
msapelov:fix/scan-tracker-memory
Open

fix(sdk): prevent transcript memory leaks in scan tracking#830
msapelov wants to merge 1 commit into
openai:mainfrom
msapelov:fix/scan-tracker-memory

Conversation

@msapelov

@msapelov msapelov commented Sep 8, 2026

Copy link
Copy Markdown

Summary

During long-running concurrent audits sharing a Codex home, several codex-security CLI processes reached roughly 2 GiB of resident memory per process. These measurements were for the local CLI scan processes. Investigating that growth revealed transcript-derived activity retained by ScanCostTracker even when it would never be delivered.

The tracker currently parses unrelated session transcripts, keeps unused parent activity, and stores complete message text for prose deduplication. This patch indexes ownership before reading transcripts, collects only consumed activity, and retains compact deduplication fingerprints.

An isolated reproduction on 0.1.26 adds 18.66 MiB of retained heap from 6.04 MiB of unrelated messages; this patch reduces that growth to 0.09 MiB. It uses synthetic logs and makes no model calls. The reproduction confirms this retention bug; the observed process RSS includes other allocations and is not attributed entirely to this cause.

Changes

  • Read metadata once for unrelated sessions; begin incremental transcript tracking only when the session belongs to the scan.
  • Replay newly associated sessions from the beginning, preserving early events, activity, progress and token usage even when parent metadata arrives later or raw-event reporting is disabled.
  • Collect derived worker activity and progress only when their observers exist; keep parent raw-event delivery intact.
  • Store SHA-256 fingerprints for prose deduplication instead of full messages and expanding reasoning prefixes. Hash UTF-16 code units to preserve distinct JavaScript strings without truncating delivered output.
  • Cover skipped unrelated reads, partial metadata, late worker association with and without raw-event reporting, token totals, and distinct long messages.

Related work: #177 changes refresh scheduling; #465 changes spending-limit verification in the same tracker; #278 restructures an older rollout reader. None provides this fix for the current tracker. Changes to this file in #465 may require integration if it merges first.

Testing

Validated on the patch rebased onto 2536d104deef9bca8ced84c6f6263b915418253b (0.1.26).

  • Focused cost/log/activity/progress tests: 131 passed, 0 failures.
  • pnpm run test --seed 12345: 2,420 passed, 43 platform-specific skips, 0 failures.
  • pnpm run test (seed 100061061): 2,420 passed, 43 platform-specific skips, 0 failures.
  • pnpm run types, pnpm run format, tsc -p tsconfig.build.json and git diff --check: passed.
  • The unrelated-transcript I/O regression fails on current main and passes with the patch.
  • A separate comparison of 64 synthetic observer scenarios preserved activity, progress, raw events and token totals.

Tests use CI-pinned Bun 1.3.14 and pnpm 11.19.0. A temporary test PATH supplies the python command used by one CI fixture.

The Node 26.8.1 reproduction compares current main and patched tracker code compiled with the same TypeScript compiler and dependencies. Each sample runs garbage collection before measuring retained heap; figures are growth from an initialized tracker, not total process memory.

Synthetic scenario Before After
unrelated 18.66 MiB 0.09 MiB
parent 18.63 MiB 0.14 MiB
worker 6.16 MiB 0.57 MiB
usage-only 6.16 MiB 0.18 MiB
reasoning 4.56 MiB 0.32 MiB

The worker scenario delivers all 3,000 activities both before and after the change. The reasoning scenario preserves all 750 updates. The unrelated scenario delivers zero unrelated activities in both versions, demonstrating that excluded output was still retained internally.

Self-contained memory reproduction

Save the script as reproduce-memory.mjs. After building the SDK, run it against each version's dist/cost.js:

node --expose-gc --max-old-space-size=128 reproduce-memory.mjs /path/to/sdk/dist/cost.js unrelated

The script creates and removes its own temporary session files. It uses no existing Codex home or credentials. Optional modes are parent, worker, usage-only, and reasoning.

// node --expose-gc --max-old-space-size=128 reproduce-memory.mjs /path/to/dist/cost.js [unrelated|parent|worker|usage-only|reasoning]
// Synthetic session files only: no credentials, network, model calls, or live scans.
import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

if (!process.argv[2] || !global.gc) throw new Error("Pass a cost.js path and run Node with --expose-gc.");
const mode = process.argv[3] ?? "unrelated";
if (!["unrelated", "parent", "worker", "usage-only", "reasoning"].includes(mode)) throw new Error("Unknown fixture mode.");
const { ScanCostTracker } = await import(pathToFileURL(resolve(process.argv[2])).href);
const home = await mkdtemp(join(tmpdir(), "scan-cost-memory-"));
const sessions = join(home, "sessions");
await mkdir(sessions);
const repository = join(home, "repository");
const metadata = (id, parent) => JSON.stringify({ type: "session_meta", payload: {
  id, cwd: repository, timestamp: "2026-09-08T00:00:00Z",
  ...(parent ? { parent_thread_id: parent } : {}),
}}) + "\n";
const root = join(sessions, "root.jsonl");
await writeFile(root, metadata("root-thread"));
const transcript = mode === "parent" ? root : join(sessions, "other.jsonl");
if (mode !== "parent") await writeFile(transcript, metadata("other-thread", mode === "unrelated" ? undefined : "root-thread"));
let emitted = 0;
let tracker = new ScanCostTracker({
  codexHome: home, model: "gpt-5.6-sol", repository,
  ...(mode === "usage-only" ? {} : { onActivity: () => { emitted++; } }),
});
const samples = [];
async function measure(label) {
  await new Promise(resolve => setImmediate(resolve));
  global.gc();
  global.gc();
  samples.push({ label,
    heapUsedMiB: +(process.memoryUsage().heapUsed / 1024 ** 2).toFixed(2),
    transcriptMiB: +((await stat(transcript)).size / 1024 ** 2).toFixed(2),
    activitiesEmitted: emitted,
  });
}
try {
  tracker.start("root-thread");
  await tracker.refresh();
  await measure("baseline");
  const perBatch = mode === "reasoning" ? 250 : 1000;
  for (let batch = 0; batch < 3; batch++) {
    let lines = "";
    for (let i = 0; i < perBatch; i++) {
      const number = batch * perBatch + i;
      lines += JSON.stringify({ type: "event_msg", timestamp: `synthetic-${number}`,
        payload: mode === "reasoning"
          ? { type: "agent_reasoning_delta", delta: `word${number} `.padEnd(16, "x") }
          : { type: "agent_message", message: `Message ${number}: ${"x".repeat(2000)}` },
      }) + "\n";
    }
    await appendFile(transcript, lines);
    lines = null;
    await tracker.refresh();
    await measure(`after ${(batch + 1) * perBatch} events`);
  }
  await rm(transcript);
  await tracker.refresh();
  await writeFile(transcript, "");
  await measure("after transcript removed and tracker refreshed");
  await tracker.stop();
  tracker = null;
  await measure("after tracker released");
  console.log(JSON.stringify({ runtime: process.version, mode, samples }, null, 2));
} finally {
  await tracker?.stop();
  await rm(home, { recursive: true, force: true });
}

Risk and rollout

Session ownership and replay order are the main compatibility risks. Descendant, independent Deep worker, inherited-history, receipt, incremental-read and large-event tests exercise those paths. There are no public CLI, authentication, pricing, artifact-schema or polling-interval changes.

The patch retains compact ownership metadata and deduplication identities; it does not impose transcript limits or promise constant memory usage. Observers may still retain the output they receive. Running scans do not load the fix until a new process starts.

Public disclosure review

  • No customer, partner, prospect, or user identities, data, or identifying details are included.
  • No credentials, personal data, private source, scan findings, or nonpublic links or tickets are included.
  • I reviewed the branch name, title, description, commits, changes, comments, logs, screenshots, attachments, and links for public disclosure.

@github-actions github-actions Bot added the bug Something isn't working label Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant