Codex Desktop 26.810: repeated compacted image snapshots grow rollout to 16.9 GB and app-server to 19.5 GB

Open 💬 2 comments Opened Aug 17, 2026 by zoeytabmedia

What version of the Codex App are you using (From “About Codex” dialog)?

ChatGPT for macOS 26.810.52044 (build 6662); bundled codex-cli 0.148.0-alpha.9

What subscription do you have?

ChatGPT subscription (exact tier omitted for privacy)

What platform is your computer?

Darwin 27.0.0 arm64 arm

What issue are you seeing?

Summary

A long-running Codex Desktop thread produced a 16.94 GB rollout JSONL and caused the bundled codex app-server to peak at approximately 19.5 GB physical footprint. System swap reached 28.6 GB used on a Mac with 32 GB RAM, and the app crashed under memory pressure.

This is still reproducible on ChatGPT for macOS 26.810.52044 with bundled codex-cli 0.148.0-alpha.9.

Content-safe local evidence

The affected rollout is a user thread fork using multi-agent v2. No raw prompts, paths, screenshots, session identifiers, or private project content are included here.

  • Rollout size: 16,942,114,729 bytes
  • Compaction windows recorded: 798
  • Recent compacted.payload.replacement_history: approximately 20–21 MB per record
  • Images retained per recent compacted snapshot: 75–83 input_image items
  • Inline image data per snapshot: approximately 20.5 MB
  • More than 98% of the sampled replacement-history bytes were inline image data
  • Context remained near the limit: approximately 233k input tokens out of a 258.4k context window
  • codex app-server peak footprint: approximately 19.5 GB
  • System swap: 28.6 / 29.7 GB used
  • Sustained app-server CPU during history processing: approximately 111%

Two file descriptors for the same oversized rollout were open in app-server: one read descriptor and one write descriptor.

A symbolized macOS process sample showed the active path passing through:

tokio::fs::file::File::poll_read
serde_json::read::StrRead::parse_str
serde::private::de::content::content_clone
codex_app_server::thread_state::ThreadState::track_current_turn_event
codex_app_server_protocol::protocol::thread_history::ThreadHistoryBuilder::handle_event

Allocator inspection showed a large transient allocation peak followed by substantial empty/fragmented small-allocation arenas. This looks like unbounded history materialization and cloning plus allocator fragmentation, rather than a small conventional object-retention leak.

Why this appears pathological

Each compaction persists another full replacement-history snapshot containing dozens of historical inline images. Because the post-compaction context remains close to the context limit, the thread compacts repeatedly without converging to a small checkpoint. Opening or continuing the thread then makes app-server parse and clone a multi-gigabyte append-only history.

What steps can reproduce the bug?

  1. Create or continue a Codex Desktop thread that uses repeated image/browser tooling.
  2. Allow the thread to run long enough to compact many times.
  3. Fork or continue the thread with full history.
  4. Observe that compacted replacement_history retains the historical inline input_image data.
  5. Continue until the post-compaction context remains close to the model context limit.
  6. Quit and reopen Codex Desktop, or reopen the affected thread.
  7. Observe app-server reading the oversized rollout, high CPU, a large transient heap, heavy swap growth, and eventual memory-pressure failure.

The private session identifier and local file path are intentionally omitted. I can provide additional sanitized aggregate measurements if useful.

What is the expected behavior?

  • Compaction should remove, externalize, summarize, or deduplicate historical inline image bytes.
  • Superseded compacted checkpoints should not remain on the active hydration path.
  • Resume and thread-history pagination should stream or use a persisted index instead of fully materializing and cloning the rollout.
  • Codex should enforce per-record, per-thread, and retained-media limits.
  • One oversized thread should fail safely with an actionable warning instead of destabilizing the app and operating system.

Additional information

This appears closely related to:

  • #34863 — compacted records retain repeated inline PNG data and drive app-server to 27 GB
  • #28866 — resume can OOM on very large local session JSONL
  • #34915 — thread-history pages repeatedly rebuild from large rollout JSONL files
  • #34268 — full-history forks duplicate historical compaction snapshots and inline images
  • #33493 — retained input images keep post-compaction context near the threshold

The reason for filing this separately is that the failure persists on the newer 26.810 / codex-cli 0.148 build, and this report includes a symbolized deserialization/history-builder stack plus a measured 798-compaction case.

Privacy note: the original rollout contains private prompts, local paths, tool output, and screenshots. It will not be uploaded publicly. All measurements above were collected using bounded, content-safe sampling.

View original on GitHub ↗

2 Comments

MilkyWay008 · 10 days ago

The per-record payload math (~20–21 MB inline across 75–83 input_image items per compaction) is what jumps out...... that's not unbounded growth, that's the compactor preserving full base64 history in replacement_history instead of externalizing or summarizing it. Combined with ThreadHistoryBuilder materializing the whole rollout instead of streaming from the JSONL offset, every compaction re-approaches the same ceiling. Until the server-side fix lands, the only user-side mitigation is avoid resuming threads that have crossed several hundred compactions; closing and starting fresh is the safe path. Worth +1'ing #34863 / #33493 since it's the same retention pattern.

jdcodes1 · 10 days ago

Your "does not converge" observation has a precise mechanism on main @ 1f41cc5d92: inline images cost zero against the compaction retention budget, but full price against the model's context — that asymmetry produces both the 16.9 GB rollout and the never-converging compaction loop.

1. Retention keeps every user message, and the budget only counts their text. Remote compaction v2 retains all user/developer/system messages verbatim (is_retained_for_remote_compaction_v2, compact_remote_v2.rs#L512-L527 — only agent messages get a token cap). The retained set is then trimmed to RETAINED_MESSAGE_TOKEN_BUDGET = 64_000 tokens — but look at how a regular user message is priced:

https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/core/src/compact_remote_v2.rs#L557-L565

source_tokens = message_text_token_count(...)text only. Only client-authored developer messages use the full estimate_item_token_count. A user message carrying 20 MB of inline input_image data costs its text length against the 64k budget, so all 75-83 images sail through retention on every compaction. (truncate_message_text_to_token_budget likewise truncates text and passes images through untouched.)

2. But the model-side accounting counts those images at full price. The context manager estimates each image at ~7,373 bytes ≈ 1,844 tokens (RESIZED_IMAGE_BYTES_ESTIMATE, context_manager/history.rs#L618-L628). Your ~80 retained images ≈ ~150k tokens of permanent context floor in a 258.4k window — matching your measured "233k of 258.4k immediately after compaction". So each compaction frees almost nothing, the threshold re-trips quickly, and the loop repeats: your 798 compaction windows.

3. Each iteration persists the whole snapshot. Every compaction writes a complete replacement_history (with all retained inline images) as a new append-only rollout record (compact_remote_v2.rs#L317-L323). 798 windows × ~21 MB ≈ your 16.9 GB — the growth is O(compactions × retained-image bytes), i.e. quadratic-ish in session length once the loop starts. The 19.5 GB app-server footprint then follows from the read path in your symbolized stack (ThreadHistoryBuilder::handle_event + serde content_clone materializing and cloning the whole history on open/continue).

Fix outline, ordered by leverage:

  1. Price images in the retention budget with the same estimator the context manager already uses (estimate_item_token_count instead of message_text_token_count for user messages, or at minimum add the per-image constant). This alone breaks the non-convergence: old images fall out of the retained set, the post-compaction context actually shrinks, and the loop stops.
  2. Cap retained images structurally — keep the most recent N input_image items and replace older ones with a text placeholder ("[image from earlier in the conversation, dropped during compaction]"), mirroring what already happens to oversized agent messages.
  3. Stop re-embedding bytes per snapshot — content-address inline images (hash → stored once) so replacement_history records reference rather than duplicate them; this turns rollout growth from O(compactions × images) back to O(unique content).
  4. Reader side: the open-thread path should stream rather than materialize+clone — but with (1)-(3) the multi-GB input stops existing, which is the cheaper win.

Test shape: a thread with a handful of large synthetic images + forced repeated compaction; assert retained-set token cost respects the budget with images priced in, post-compaction context drops below the trigger threshold, and rollout growth per compaction is bounded by new content, not history size.