Headless --resume/--fork-session reifies the entire session transcript in memory: 9-14x file size within seconds; 12.4 GB balloon OOM-killed a 16 GB host
Environment
- Claude Code 2.1.215, npm global install (
@anthropic-ai/claude-code), bundled Bun binary (bin/claude.exe) - Linux x86_64 (Ubuntu, kernel 6.8), VPS with 16 GB RAM + 4 GB swap
- Invocation: headless
claude --resume <id> [--fork-session] -p "..." --output-format stream-json --verbose
Summary
When headless mode resumes a session that has a large transcript, the process loads and reifies the whole .jsonl file into memory at once. RSS reaches 9–14x the transcript file size within 3–8 seconds, before the first byte of model output. For a 265 MB transcript the process holds more than 2.2 GB. The heap stays resident for the full life of the process, even after the API rejects the oversized request with a 400. Memory use scales with the transcript file size on disk, not with the context that is actually sent to the model.
On one production host this pattern OOM-killed a 16 GB machine three times: headless fork workers that resumed a real 223 MB / 36,889-line transcript ballooned from 0 to 10.8–12.4 GB in about 90 seconds each (kernel OOM dumps, ~140 MB/s allocation rate). Details below.
Steps to reproduce
- Save the generator below as
gen.py. Runpython3 gen.py. It writes a fully synthetic transcript (~265 MB, ~40,000 lines: text tool turns, 90 fake base64 "images", one 15 MB line, and the metadata line types a long-lived session accumulates) into~/.claude/projects/-tmp-claude-oom-repro/and prints the session id. It contains no real data. - Resume the session under a memory watch (kills the process at 6 GB as a safety):
SID=<id printed by gen.py>
cd /tmp/claude-oom-repro
claude --resume "$SID" --fork-session -p "Reply with exactly: pong" \
--output-format stream-json --verbose > /tmp/fork-test-out.jsonl 2>&1 &
P=$!
while kill -0 $P 2>/dev/null; do
R=$(awk '/VmRSS/{print $2}' /proc/$P/status 2>/dev/null)
echo "t=$SECONDS rss_kb=${R:-0}"
[ "${R:-0}" -gt 6291456 ] && kill -9 $P
sleep 1
done
- Watch RSS climb to multiple GB in the first seconds, then hold.
Observed
Measured on this machine (1 s RSS sampling of the resumed process):
| Transcript file | Lines | Peak RSS | Peak / file size | Timeline |
|---|---|---|---|---|
| 264.9 MB | 40,273 | 2.26 GB | 8.7x | plateau ~2.16 GB from t≈5 s until exit at t=46 s |
| 200.3 MB | 35,030 | 1.78 GB | 8.9x | ≥1.5 GB from t=13 s until exit at t=73 s |
| 139.6 MB | 20,141 | 1.97 GB | 14.4x | still climbing when the process exited at t=8 s |
- RSS is multi-GB before any model output.
- The API then rejects the request with a 400 ("Prompt is too long" / image limit). The process keeps the multi-GB heap resident until it exits — up to ~70 s later. Nothing is released after the rejection.
- The behavior is the same with and without
--fork-session. - A plain interactive
claude --resumeof the same real 223 MB session sat at ~450 MB, so the headless path holds several extra copies.
Production incident
Three kernel OOM kills on one 16 GB Linux host (2026-07-14 and 2026-07-19, v2.1.2xx): each time, a single freshly spawned headless worker (claude --resume <id> --fork-session -p ... --output-format stream-json) on a real 223 MB / 36,889-line transcript with 513 embedded base64 screenshots (largest single line 15.6 MB) grew from 0 to 10.8 GB, 11.8 GB, and 12.4 GB (~55x the file size) in about 90 seconds, and the kernel OOM-killed the box. PID-timeline interpolation from the OOM dumps gives a sustained allocation rate of ~140 MB/s.
A confirming data point for the mechanism: after that session was later compacted (so resume only loads the post-compact tail), the identical fork command on the identical 223 MB file completed at 0.71 GB peak. Memory tracks how much transcript the loader reifies, not what the request needs.
Expected behavior
- Parse the transcript JSONL incrementally (stream it); keep only what the request needs.
- Do not hold the raw file text, the parsed object graph, the normalized message list, and the wire payload alive at the same time.
- Bound resume memory by the assembled model context (at most a few MB of text plus capped images), not by the transcript file size.
- Release the transcript heap after the API rejects the request, instead of holding it until process exit.
Impact
- Orchestration setups spawn several headless
--resume/--fork-sessionworkers concurrently. N workers x ~10x transcript size exhausts a 16 GB host from a single hot session. - Long-lived sessions with screenshots reach 100–250 MB on disk in normal use. Resume of one of them should not need gigabytes of RAM — and in the worst observed case it took 12.4 GB, and took the host down with it.
Related
- #68167 — the
/resumepicker has the same whole-file reification pattern across the entire corpus. This issue is about resuming a single, explicitly named session id in headless mode, which should be the cheap path.
Generator script
<details>
<summary><code>gen.py</code> — writes the synthetic transcript (no real data)</summary>
#!/usr/bin/env python3
"""Generate a SYNTHETIC Claude Code session transcript (JSONL) that mimics a
long multimodal session: many tool_use -> tool_result turns, fake base64
images, metadata line types, plus one very large single line. No real data.
Usage: python3 gen.py [--images 90] [--image-kb 200] [--text-turns 11000]
[--text-kb 10] [--big-mb 15] [--cwd /tmp/claude-oom-repro]
Prints the generated session id.
"""
import argparse, base64, json, os, uuid, datetime, random
p = argparse.ArgumentParser()
p.add_argument("--images", type=int, default=90)
p.add_argument("--image-kb", type=int, default=200)
p.add_argument("--text-turns", type=int, default=11000)
p.add_argument("--text-kb", type=float, default=10)
p.add_argument("--big-mb", type=int, default=15)
p.add_argument("--cwd", default="/tmp/claude-oom-repro")
p.add_argument("--version", default="2.1.215")
a = p.parse_args()
os.makedirs(a.cwd, exist_ok=True)
proj_dir = os.path.join(os.path.expanduser("~/.claude/projects"), a.cwd.replace("/", "-"))
os.makedirs(proj_dir, exist_ok=True)
sid = str(uuid.uuid4())
path = os.path.join(proj_dir, sid + ".jsonl")
t0 = datetime.datetime(2026, 7, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
seq = [0]
def ts():
seq[0] += 1
return (t0 + datetime.timedelta(seconds=seq[0])).strftime("%Y-%m-%dT%H:%M:%S.000Z")
def base(prev, u):
return {"parentUuid": prev, "isSidechain": False, "userType": "external",
"cwd": a.cwd, "sessionId": sid, "version": a.version, "gitBranch": "",
"entrypoint": "cli", "uuid": u, "timestamp": ts()}
def b64(n_bytes):
return base64.b64encode(random.randbytes(n_bytes)).decode()
prev = None
with open(path, "w") as f:
def emit(d):
global prev
f.write(json.dumps(d) + "\n")
prev = d["uuid"]
u = str(uuid.uuid4())
d = base(None, u)
d.update({"type": "user", "message": {"role": "user",
"content": "Synthetic load-test session: take screenshots in a loop."}})
emit(d)
img_bytes = a.image_kb * 1024 * 3 // 4
words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
"hotel", "india", "juliet", "kilo", "lima", "mike", "november"]
def text_blob(i, kb):
n = int(kb * 1024 // 12)
return " ".join("%s%06d" % (words[(i + j) % len(words)], i * 7 + j) for j in range(n))
def emit_tool_pair(i, kind):
tid = "toolu_synth_%s_%06d" % (kind, i)
u = str(uuid.uuid4()); d = base(prev, u)
content_blocks = [{"type": "thinking", "thinking": text_blob(i + 7, 4),
"signature": base64.b64encode(random.randbytes(384)).decode()},
{"type": "text",
"text": text_blob(i, a.text_kb / 2) if kind == "text" else "Taking a screenshot."},
{"type": "tool_use", "id": tid, "name": "Bash",
"input": {"command": "step --kind %s --n %d" % (kind, i),
"description": "synthetic step %d" % i}}]
d.update({"type": "assistant", "requestId": "req_synth_%s_%06d" % (kind, i),
"message": {"id": "msg_synth_%s_%06d" % (kind, i), "type": "message",
"role": "assistant", "model": "claude-opus-4-6",
"content": content_blocks,
"stop_reason": "tool_use", "stop_sequence": None,
"usage": {"input_tokens": 1000 + i % 997, "output_tokens": 60,
"cache_creation_input_tokens": i % 89,
"cache_read_input_tokens": 10000 + i % 4093}}})
emit(d)
if i % 3 == 0:
f.write(json.dumps({"type": "last-prompt", "lastPrompt": "step %d" % i,
"leafUuid": prev, "sessionId": sid}) + "\n")
f.write(json.dumps({"type": "ai-title", "aiTitle": "Synthetic load test",
"sessionId": sid}) + "\n")
f.write(json.dumps({"type": "mode", "mode": "default", "sessionId": sid}) + "\n")
f.write(json.dumps({"type": "permission-mode", "permissionMode": "default",
"sessionId": sid}) + "\n")
if i % 6 == 0:
u2 = str(uuid.uuid4()); d2 = base(prev, u2)
d2.update({"type": "system", "subtype": "informational", "isMeta": True,
"durationMs": 100 + i % 900, "messageCount": i,
"content": "synthetic system note %d" % i})
emit(d2)
if i % 12 == 0:
u2 = str(uuid.uuid4()); d2 = base(prev, u2)
d2.update({"type": "attachment",
"attachment": {"type": "edited_text_file",
"filename": "/tmp/claude-oom-repro/file%03d.txt" % (i % 200),
"snippet": text_blob(i + 13, 2.0)}})
emit(d2)
if i % 20 == 0:
f.write(json.dumps({"type": "file-history-snapshot",
"messageId": "msg_synth_%s_%06d" % (kind, i),
"isSnapshotUpdate": False,
"snapshot": {"messageId": "msg_synth_%s_%06d" % (kind, i),
"trackedFileBackups": {},
"timestamp": ts()}}) + "\n")
u = str(uuid.uuid4()); d = base(prev, u)
if kind == "image":
content = [{"tool_use_id": tid, "type": "tool_result",
"content": [{"type": "image",
"source": {"type": "base64", "media_type": "image/png",
"data": b64(img_bytes)}}]}]
else:
content = [{"tool_use_id": tid, "type": "tool_result",
"content": text_blob(i + 31, a.text_kb)}]
d.update({"type": "user", "message": {"role": "user", "content": content}})
emit(d)
img_every = max(1, a.text_turns // max(1, a.images))
img_done = 0
for i in range(a.text_turns):
emit_tool_pair(i, "text")
if img_done < a.images and i % img_every == img_every - 1:
emit_tool_pair(i, "image"); img_done += 1
while img_done < a.images:
emit_tool_pair(img_done, "image"); img_done += 1
tid = "toolu_synth_big"
u = str(uuid.uuid4()); d = base(prev, u)
d.update({"type": "assistant", "requestId": "req_synth_big",
"message": {"id": "msg_synth_big", "type": "message", "role": "assistant",
"model": "claude-opus-4-6",
"content": [{"type": "tool_use", "id": tid, "name": "Bash",
"input": {"command": "cat huge-generated-log.txt"}}],
"stop_reason": "tool_use", "stop_sequence": None,
"usage": {"input_tokens": 1000, "output_tokens": 60}}})
emit(d)
big = "LOG " + " ".join("line%08d synthetic filler payload" % i
for i in range(a.big_mb * 1024 * 1024 // 40))
u = str(uuid.uuid4()); d = base(prev, u)
d.update({"type": "user", "message": {"role": "user",
"content": [{"tool_use_id": tid, "type": "tool_result", "content": big}]}})
emit(d)
u = str(uuid.uuid4()); d = base(prev, u)
d.update({"type": "assistant", "requestId": "req_synth_end",
"message": {"id": "msg_synth_end", "type": "message", "role": "assistant",
"model": "claude-opus-4-6",
"content": [{"type": "text", "text": "Loop finished."}],
"stop_reason": "end_turn", "stop_sequence": None,
"usage": {"input_tokens": 1000, "output_tokens": 20}}})
emit(d)
print(sid)
print(path, "%.1f MB" % (os.path.getsize(path) / 1048576))
</details>
Cleanup after the test: rm -rf ~/.claude/projects/-tmp-claude-oom-repro /tmp/claude-oom-repro.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗