Skip to content

perf(provider-switch): compaction wait pins full conversation copies and re-decodes the rollout every 250 ms #720

Closed

Description

@Juliusolsson05

Symptom

A Codex→Claude provider switch that needs compaction or a portable handoff pins ~354 MiB (80%) of the main-process heap for the whole wait (up to 5 minutes) and stalls the main event loop. Captured on 2026-09-03 with the heap watchdog: main heap climbed 143 MB → 1.94 GB in ~9 minutes, renderer heartbeat froze repeatedly (freeze-diagnostics eventLoopLagMs up to 6.2 s, main eventLoop.meanMs 1591 / maxMs 3934), then the watchdog tripped at 1.97 GB and wrote heap-snapshots/main-…-75074-2f6565.heapsnapshot.

Evidence (dominator tree, scripts/analyze-heapsnapshot.mjs --owners)

354.1 MiB  79.8%  object:Timeout            [node 2098111]   (node:timers/promises setTimeout)
354.1 MiB  79.8%  object:Generator          waitForPortableCodexSummary
236.0 MiB  53.2%  object:Generator          compactSourceBeforeSwitch
118.0 MiB  26.6%  object:Object {schemaVersion, sourceProvider:'codex', sourceSessionIds, entries: Array(18362)}  ×3

Edge walk of the generators' parameters_and_registers:

  • compactSourceBeforeSwitch registers hold before and compacted (same object, 118 MiB) plus a second full ConversationDocument.
  • waitForPortableCodexSummary registers hold current (another 118 MiB copy) across await delay(COMPACTION_POLL_MS).

Source session 795127e7-… (Codex rollout, cwd …/bringdown, 18,362 decoded entries).

Root cause

src/main/providerSwitch/compactBeforeSwitch.ts:

  1. before = await source.read(...) decodes the entire rollout into a ConversationDocument and the local stays reachable for the whole function, even though only describeLatestCompaction(before)?.fingerprint and latestSourceLine(compacted) are ever used from it.
  2. waitForNewCompaction and waitForPortableCodexSummary poll every 250 ms for up to 300 s, and every poll calls source.read() again — a full readFile + decodeJsonl + decodeCodexConversation + analysis of a 60–150 MB rollout. That is ~4 full decodes per second (hundreds of MB/s of allocation) on the main thread, and each iteration's current is held in the generator across the await.
  3. waitForNewCompaction's return value (compacted) stays alive while the Codex portable-handoff wait runs another ≤300 s.

Net: 2–3 simultaneous full copies retained + continuous re-decode → heap burst, GC pressure, and multi-second event-loop stalls for every open pane while the switch waits.

Expected

The wait should retain only the scalars it needs (compaction fingerprint, baseline line number), re-decode the transcript only when the file has actually grown, and not hold previous snapshots across the poll await.

Impact

Environment

Agent Code main @ 6012427, Electron 43, macOS 15 (Darwin 25.2), 8-core M3 / 16 GB. Dev run via npm start.

Refs #327, #365 (this is the "name the retained owner with dominator evidence" acceptance item), #103.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions