Transcript JSONL corruption; records spliced by unsynchronized writers
⚠️ Still happening: last seen 2026-10-07 on Claude Code 2.1.292
| | |
| --- | --- |
| Last occurrence | 2026-10-07 ~03:39 UTC |
| Newest affected version | 2.1.292 (also 2.1.289, 2.1.287, 2.1.272) |
| Latest release checked | 2.1.296: no fix in the changelog |
| Recent rate | 12 corrupt lines in 12 files, Sep 15 – Oct 7 (of ~14.5k files modified in the last 30 days) |
| Status | Maintainer-confirmed real bug (see comments), not fixed |
| Suspected cause | unchecked short writes in the compaction rewriter and tombstone remover (see comments) |
Impact:
- Permanent: the damaged line stays in the transcript file and the lost bytes are never rewritten.
- Data loss: at least one record (message, tool result, ...) per incident.
- Broken tooling:
jq/json.loadsfail at that line, and line-by-line readers without error recovery lose everything after it. - Worst case: the compaction rewrite swaps a short-written temp file over the live session file with no validation.
---
What's Wrong?
Claude Code's transcript writer occasionally corrupts a session's JSONL mid-session during routine operation — no crash, no kill: the same session keeps appending valid records right after the corrupt line.
Original audit of one machine (~10,949 transcript *.jsonl under ~/.claude/projects, incl. */subagents/, ~6 months of use): 23 files contain 30 invalid lines. 24/30 show a record-splice signature:
| Signature | Count | Detail |
| --- | --- | --- |
| Truncated splice | 21x | a record is cut off mid-content and the NEXT complete record begins immediately on the same physical line — no separator, no junk bytes |
| Missing newline | 3x | two fully-valid records back-to-back on one line (}{ junction) |
(The remaining 6/30 are 0x00 runs then one valid record — the known #49876 signature, not this report.)
<details>
<summary><b>Key facts</b></summary>
- Dates: 2026-01-14 → 2026-07-19, 17 distinct days — steady recurrence, not one incident.
- Corrupt lines written by 16 distinct CC versions (2.1.7 … 2.1.215; 2.1.220 too recent to have samples).
- All 30 lines: both halves belong to the SAME
sessionId— NOT two sessions sharing a file; the session's own writers racing. - Sessions continue normally after the bad line — distinct from #52387 (SIGKILL mid-write). Related but distinct: #52387, #49876.
</details>
<details>
<summary><b>Code audit of the installed 2.1.296 bundle: the bug is still present</b></summary>
This is the third audit. It replaces the original 2.1.220 audit (four writer domains, append-path differential 2.1.217 vs 2.1.220), which was written for a build that is now old. The 2.1.234 audit in the comments below is unchanged. Method: strings extracted from the 2.1.296 binary, focused on the transcript compaction and tombstone-removal code.
performCompactTranscript(compaction rewriter): unchanged, still affected. It writes the.compact.tmp.<hex>file with three uncheckedFileHandle.writecalls (chunked flush, final flush, concurrent-tail catch-up), none of which looks atbytesWritten. It then runssync()andrename()s the temp file over the live session file. Nothing validates the bytes just written, so a short write is renamed in and the dropped tail is lost.performRemoveByUuid(tombstone remover): partly fixed. The positional write now throws"performRemoveByUuid: short write"whenbytesWritten < expected. But thetruncate()has already run, so the tail is already cut when it throws. The failure is now loud, but the data is still lost.- Concurrency guard: compaction still uses the 3x4KiB sampling comparison, not a lock.
- The correct pattern already exists in the tree:
tryAppendretries on a short write and throws"tryAppend: short write ... stalled". The two sites above do not use it. - New, not analyzed: a
performCompactTranscriptV5/removeByUuidV5path withtornTailBytes, apparently gated behind a feature flag. It may be where a fix is heading; I haven't checked whether it is enabled.
</details>
What Should Happen?
Every record append is atomic and all writers to a session file are serialized through one queue/lock — one write of json + "\n" per record — so the transcript is valid NDJSON at every instant; in-place mutations (removeByUuid, compaction) exclude appenders while they run.
Error Messages/Logs
$ jq -c .type 071f66b9-4802-47b7-9031-bd074c435770.jsonl
jq: parse error: Invalid numeric literal at line 794, column 1116
truncated splice — record cut mid-value, next record starts with no separator:
..."sessionId":"915a2{"parentUuid":"..."
pure missing-newline — two complete records on one line:
...,"gitBranch":"main"}{"type":"file-history-snapshot",...
Steps to Reproduce
Not deterministically reproducible — statistical: 30 occurrences over ~6 months, ~0.2% of session files.
Common conditions: long sessions with subagents/background tasks; Linux, ext4, local NVMe.
Scanner:
python3 - <<'PY'
import json, glob, os
for f in glob.glob(os.path.expanduser('~/.claude/projects/**/*.jsonl'), recursive=True):
for i, line in enumerate(open(f, encoding='utf-8', errors='replace'), 1):
try: json.loads(line)
except Exception as e: print(f, i, e)
PY
Splice lines: two "parentUuid" on one physical line; json.JSONDecoder().raw_decode at the failure offset recovers a complete second record.
Claude Model
_No response_
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.220 (originally reported; still reproducing on 2.1.292; code audit redone on 2.1.296)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Other
Additional Information
Full audit artifacts (scanner, per-line results, raw_decode reconstruction, bundle-audit notes) available on request. The 6 NUL-padded lines matching #49876's signature were excluded to keep this a single bug report.
Secondary observation from the same audit (not implicated in the corruption above): the drain queue's MAX_CHUNK_BYTES is 104857600, so a single batched transcript write is effectively unbounded — a memory-spike/robustness concern worth a look while in this code.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Thanks for the detailed audit. Tried to reproduce on the released 2.1.233 build (macOS): scanned a local corpus of ~5,500 transcript
.jsonlfiles (many months of use, including subagent transcripts) and found 0 invalid lines, and a fresh session with two parallel subagents produced fully valid main and subagent transcripts. So we couldn't reproduce the splice locally in the time budget, but your data (17 distinct days, 16 versions, same-session halves on one line) is convincing.Classification: real bug, not a regression. We confirmed from the current code that several independent writers still append to the same session file without a single shared lock, and that the in-place tombstone-removal path (truncate + positional rewrite of the tail) can overlap with one of the out-of-band metadata appenders. That is a plausible source of spliced/partial lines, and we agree the intended contract is "one atomic write of
json + \nper record, all writers serialized per file, in-place rewrites exclude appenders." We're tracking a fix along those lines (and will look at the unbounded batch size you flagged).If you can, one sanitized example would help pin the mechanism: for a truncated-splice line, the
typeof the cut-off record and of the record that follows it, plus whether a}fragment line appears anywhere later in the same file. Also OS/filesystem details for any non-ext4 occurrences.🤖 Generated with Claude Code
Follow-up: mechanism details for the truncated splices
@bcherny
Ran the follow-up on the same corpus, now 13,013 files (~2,064 added since the report). Zero new occurrences in the added files — nothing newer than 2.1.215 (installed is 2.1.234). Answers to your three questions first, then a new structural finding that surfaced while extracting them.
1. Record types at each truncated splice
All 21 instances (letters are anonymized per-file IDs):
| line | cut-off
type| followingtype(role / block) | version ||---|---|---|---|
| A:948 | message | assistant (text) | 2.1.113 |
| A:1029 | message | assistant (text) | 2.1.113 |
| B:2532 | user | file-history-snapshot | — |
| D:1250 | user | user (tool_result) | 2.1.81 |
| E:258 | message | progress | 2.1.81 |
| F:7138 | user | assistant (thinking) | 2.1.38 |
| G:596 | message | assistant (text) | 2.1.91 |
| I:62 | progress | user (tool_result, isSidechain) | 2.1.50 |
| K:1457 | message | file-history-snapshot | — |
| L:62 | message | assistant (thinking) | 2.1.215 |
| M:315 | user | assistant (tool_use) | 2.1.185 |
| M:319 | message | system | 2.1.185 |
| N:794 | user | assistant (thinking) | 2.1.215 |
| O:663 | user | assistant (text) | 2.1.91 |
| T:980 | message | user (tool_result) | 2.1.74 |
| U:318 † | message | file-history-snapshot | 2.1.50 |
| U:470 † | message | file-history-snapshot | 2.1.50 |
| V:365 | (cut before
type) | progress | 2.1.71 || V:376 | (cut before
type) | file-history-snapshot | — || AA:238 | user | user (tool_result) | 2.1.7 |
| AA:345 | (cut before
type) | assistant (thinking) | 2.1.7 |† the only two page-unaligned cut points (see the finding below).
Roll-up:
message×9,user×7,progress×1, cut beforetype×3assistant×9,file-history-snapshot×5,user×3,progress×2,system×12. Later
}fragment linesNo, in all 21 cases. The dropped tail bytes never reappear anywhere later in the file.
3. Filesystem
Every occurrence is on a single local ext4 NVMe volume — verified via identical
st_devacross all 13,013 files, so there are no non-ext4 occurrences to report.New finding: cut points sit on 4 KiB page boundaries
19 of the 21 splice cut points are at an exact multiple of 4096 bytes from the start of the file (only U:318 and U:470 are unaligned). Higher alignments do not hold — 8192: 12/19, 16384: 7/19, i.e. chance level — so the boundary is specifically the 4 KiB page.
Two related observations:
file-history-snapshot, 1×custom-title). 4 of the 6 NUL runs end on an exact 4096 multiple.Both damage modes match a page-granular partial write whose remainder is never retried: a short
write()/flush that isn't looped on, with the file offset advancing anyway. The splice case is the buffer's first N pages landing and the tail being dropped; the NUL case is the mirror image — the offset advancing over a region whose data never reached the page cache, leaving a materialized hole ending on a page boundary. This doesn't require interleaving between writers — a single writer mishandling a short write produces exactly this signature.Follow-up: we audited the deployed 2.1.234 binary (beautified extract of the transcript-store region) to test the short-write hypothesis, and the page-alignment evidence points at a specific function — not the append path.
The append hot path is safe
All four writer domains funnel into one primitive built on
fs/promises.appendFile, which loops on partial writes. Verified empirically (RLIMIT_FSIZE probe, Bun and Node agree):appendFile/appendFileSyncthrow rather than short-write; bareFileHandle.writesilently resolves short (e.g.bytesWritten3096 of 6000).But three
FileHandle.writecall sites on the transcript path discardbytesWrittenperformCompactTranscript): the chunked flushes to the.compact.tmp.<hex>file, the final flush, and the concurrent-tail catch-up write;performRemoveByUuid): the positional tail rewrite after the truncate.The compaction rewriter matches the corpus signature point for point
FileHandle.writeto a file seeded with a deliberately unaligned 1234-byte prefix returnedbytesWritten = 64302with no error, leaving the file at exactly 65536 bytes (size % 4096 === 0): the first partial page was filled from 1234 to 4095, then 15 full pages, then it stopped — the cut lands on a page boundary of the file offset regardless of where the write began. Identical on Bun and Node.rename()d over the live session file. Every pre-rename guard (statino/size re-samples,h.sync()) validates the source transcript; nothing validates the bytes just written —h.sync()sits exactly where abytesWrittencheck would have caught it.Triggers, and the fix shape already in the tree
On triggers: ENOSPC is the classic real-world partial-write cause, and 2.1.217's changelog already added disk-full warnings for transcript writes, so this condition is known to occur in the field on this path. Notably, the codebase already contains the correct pattern — one auxiliary transcript-append helper hand-rolls a
bytesWrittenretry loop ("tryAppend: short write … stalled") — while these three sites rely on unchecked writes. Fix shape: that same loop at the three call sites, plus a written-bytes/final-statassertion before the rename.What the original race still explains
The exit-handler race and the un-serialized out-of-band metadata re-append from the original report are still present in 2.1.234, but they can only account for the 3 unaligned missing-newline junctions — they cannot cut a record mid-string, since every append emits whole
json + "\n"strings through the loopingappendFile.---
Probe scripts: https://gist.github.com/NubeBuster/1a03409a3e95c322607f01d972048522