Skip to content

fix: bound daemon memory so a long session cannot exhaust it - #23

Merged
Stephen Belanger (Qard) merged 1 commit into
mainfrom
fix/daemon-unbounded-memory
Aug 18, 2026
Merged

fix: bound daemon memory so a long session cannot exhaust it#23
Stephen Belanger (Qard) merged 1 commit into
mainfrom
fix/daemon-unbounded-memory

Conversation

@Qard

@Qard Stephen Belanger (Qard) commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

A user reported the daemon reaching 20 GB before crashing. Nothing in the long-lived daemon bounded its in-memory footprint — not payloads, not replay, not the conversation history, and not the per-session maps.

Root cause

Every Claude lifecycle event (UserPromptSubmit/Stop/StopFailure/SubagentStop/SessionEnd) read the entire transcript into memory and journaled it verbatim, so a session re-recorded its whole growing transcript on every turn.

Measured on my own daemon state dir:

Source transcript e8c6ac32-….jsonl 18 MB
Resulting journal e8c6ac32-….ndjson 1.6 GB (89× amplification)
142 snapshot-carrying lines 1.54 GB (98.9%)
All 5,852 other lines 19 MB

Replay then loaded that file with read_to_string, parsed every line into a Vec held at once, and accumulated every resulting span op — each LLM row carrying a full conversation-history clone — into a single Vec before emitting. That is the crash.

The waste was total rather than incidental: the translator only ever consumes bytes past its cursor offset, so the entire prefix of every snapshot was dead weight in all cases.

Scope: in-memory only

Only in-memory caches are bounded here. Nothing on disk — journal, mirror, or conversation content — is capped or truncated, because each is a durability record whose loss is silent. They are kept small by storing every byte once rather than by dropping data, and every in-memory structure is re-derivable from them.

Changes

  • Mirror transcripts instead of snapshotting them. New transcript_mirror module streams only new bytes into a daemon-owned file; the journal carries a reference plus the high-water offset. Each byte is stored once, and the offset bounds replay to exactly what the live run saw. This keeps the durability property that motivated snapshots — the mirror is immune to the agent rewriting the path — while making journal growth linear in transcript size. The copy goes through a small fixed buffer, so mirroring an arbitrarily large transcript costs arbitrarily little memory.
  • Stream journal replay. JournalReader reads entry by entry, bounded to the journal length recorded when the session was created, and each entry's spans are emitted as produced. Peak memory is one entry, not a whole session.
  • Retire idle sessions. Nothing ever removed a session from the six daemon maps, so translator state, sink handles, credential leases, and journal file handles accumulated until the process exited — which for a continuously busy user never happened, since the idle watchdog needs every session quiet. Sessions now retire after --session-idle-timeout and rebuild from the journal on a later event. This is also what bounds the retained conversation history and the sink's open span handles.
  • Bound the session queue. Unbounded mpsc replaced with a bounded channel, so a stalled sink applies backpressure rather than accumulating events. The event is journaled before enqueue, so this costs latency, never data.
  • Drop two redundant full clones of the conversation history per turn.
  • Run journal/mirror GC periodically, not only at daemon startup.

Compatibility

Journals written before mirroring inline the transcript as _bt_transcript_snapshot, and translators still accept that form. A test asserts both forms translate identically across all three Claude fixtures, so upgrading neither changes live output nor breaks recovery from an existing journal.

Tests

  • claude_journal_does_not_grow_with_the_transcript_on_every_turn — 40 turns on a growing transcript; asserts the journal stays flat and the mirror holds the transcript exactly once. This would have caught the original bug.
  • idle_sessions_are_retired_and_can_resume_from_their_journal — retirement happens, and replay afterwards reuses the original span ids.
  • mirror_and_inline_snapshot_transcripts_translate_identically — the compatibility guarantee above.
  • claude_boundary_journal_references_a_self_contained_transcript_mirror — replaces the old snapshot assertion.

122 tests pass; clippy and rustfmt clean.

Note

I confirmed the mechanism and the 1.6 GB artifact locally, but did not reproduce the user's 20 GB crash directly — their session size may differ.

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

A user reported the daemon reaching 20 GB before crashing. Nothing in the
long-lived daemon bounded its in-memory footprint: not payloads, not replay,
not the conversation history, and not the per-session maps.

The dominant cost was transcript snapshotting. Every Claude lifecycle event
(UserPromptSubmit/Stop/StopFailure/SubagentStop/SessionEnd) read the entire
transcript into memory and journaled it verbatim, so a session re-recorded its
whole growing transcript on every turn. On my own machine an 18 MB transcript
had produced a 1.6 GB journal: 142 snapshot lines accounted for 1.54 GB while
all 5,852 other lines totalled 19 MB. Replay then loaded that file with
read_to_string and parsed every line into a Vec held at once, and accumulated
every resulting span op -- each LLM row carrying a full history clone -- into a
single Vec before emitting. That is the crash.

The waste was total rather than incidental: the translator only ever consumes
bytes past its cursor offset, so the entire prefix of every snapshot was dead
weight in all cases.

Only in-memory caches are bounded here. Nothing on disk -- journal, mirror, or
conversation content -- is capped or truncated, because each is a durability
record whose loss is silent; they are kept small by storing every byte once
rather than by dropping data, and every in-memory structure is re-derivable
from them.

Changes:

- Mirror transcripts instead of snapshotting them. New transcript_mirror module
  streams only new bytes into a daemon-owned file; the journal carries a
  reference plus the high-water offset. Each byte is stored once, and the
  offset bounds replay to exactly what the live run saw. Keeps the durability
  property that motivated snapshots -- the mirror is immune to the agent
  rewriting the path -- and makes journal growth linear in transcript size.
- Stream journal replay. JournalReader reads entry by entry, bounded to the
  journal length recorded when the session was created, and each entry's spans
  are emitted as produced. Peak memory is one entry, not a whole session.
- Retire idle sessions. Nothing ever removed a session from the six daemon maps,
  so translator state, sink handles, credential leases, and journal file handles
  accumulated until the process exited -- which for a continuously busy user
  never happened, since the idle watchdog needs every session quiet. Sessions
  now retire after --session-idle-timeout and rebuild from the journal on a
  later event.
- Bound the session queue. Unbounded mpsc replaced with a bounded channel, so a
  stalled sink applies backpressure rather than accumulating events. The event
  is journaled before enqueue, so this costs latency, never data.
- Drop two redundant full clones of the conversation history per turn.
- Run journal/mirror GC periodically, not only at daemon startup.

Backward compatible: journals written before mirroring inline the transcript as
_bt_transcript_snapshot, and translators still accept that form. A test asserts
both forms translate identically across all three Claude fixtures.

Tests: journal growth stays flat across 40 turns on a growing transcript
(would have caught the original bug), idle sessions retire and resume from
their journal with stable span ids, and the mirror reference is self-contained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Qard
Stephen Belanger (Qard) merged commit abd6756 into main Aug 18, 2026
20 checks passed
@Qard
Stephen Belanger (Qard) deleted the fix/daemon-unbounded-memory branch August 18, 2026 00:25
Stephen Belanger (Qard) added a commit to braintrustdata/bt that referenced this pull request Aug 20, 2026
Moves the `bt-daemon` pin from `1ca65d4` to
[`abd6756`](braintrustdata/braintrust-coding-agent-plugins@abd6756),
which is braintrustdata/braintrust-coding-agent-plugins#23 — *"bound
daemon memory so a long session cannot exhaust it"*.

## Why

A user reported the daemon reaching **20 GB** before crashing. Every
Claude lifecycle event had been reading the whole transcript into memory
and journaling it verbatim, so a session re-recorded its growing
transcript on every turn — an 18 MB transcript produced a **1.6 GB**
journal (142 snapshot lines accounted for 1.54 GB; all 5,852 other lines
totalled 19 MB). Replay then loaded that file with `read_to_string` and
parsed every line into a `Vec` held at once.

The daemon now:
- mirrors transcript bytes once into daemon-owned storage and journals
only a reference plus a high-water offset,
- streams journal replay entry by entry instead of materializing it,
- retires idle sessions so translator state, sink handles, and
credential leases are released rather than held for the process
lifetime,
- applies backpressure on session queues instead of using an unbounded
channel.

Nothing on disk is capped or truncated — only in-memory caches are
bounded, and each is re-derivable from disk.

## Scope

No source changes are needed in `bt`. It only constructs bt-daemon's
clap-parsed command schema (`TraceArgs`), and the one new field
(`ServeArgs::session_idle_timeout_secs`) carries a `default_value_t`, so
this is not a breaking API change. It surfaces as a new hidden flag:

```
bt trace daemon --session-idle-timeout-secs <SECS>   [default: 300]
    Retire a session's in-memory state after this many seconds without traffic.
    A later event rebuilds it from the journal. 0 disables retirement.
```

The `Cargo.lock` change is deliberately limited to the one `source`
line. `cargo update -p bt-daemon` also re-resolved unrelated transitive
picks (`windows-sys`, `socket2`, `getrandom`), which I reverted — the
bump adds no dependencies, and pristine `main` passes `--locked`
cleanly, so that churn was noise.

## Verification

- `cargo check --locked --all-targets` — clean
- `cargo test auth::tests` — 77 passed (what CI runs)
- `cargo test trace` — 40 passed
- `cargo clippy --locked --all-targets --all-features -- -D warnings` —
14 findings, identical before and after the bump. They are pre-existing
`collapsible_if` lints in `src/traces.rs`, `src/sql.rs`, and
`src/datasets/pipeline.rs` from a newer local clippy than CI's pinned
toolchain; untouched here.
- Confirmed the new flag and its default surface through the real `bt`
binary.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants