Silent U+FFFD corruption in CJK model output due to TextDecoder missing `{ stream: true }` in SSE line decoder
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Japanese (CJK) characters in model output are silently replaced with U+FFFD (replacement character) when written to files via Write/Edit tools. No API error occurs and no warning is shown — the corruption is completely silent.
This is not the same issue as #16294 (unpaired surrogates from tool output causing API 400 errors). This bug originates inside the SSE streaming decoder in the Anthropic SDK, not from external tool output.
Example: 銀行送金 becomes U+FFFD U+FFFD U+FFFD 行送金 — the character U+9280 (e9 8a 80, 3-byte UTF-8) is replaced with 3 consecutive U+FFFD.
What Should Happen?
All CJK characters should be correctly decoded from the SSE streaming response and written to files without corruption.
Error Messages/Logs
Steps to Reproduce
- Start a Claude Code session with locale
C.UTF-8 - Ask Claude to write a file containing substantial Japanese text via the Write tool (longer outputs increase the probability of hitting the bug)
- Check the written file for U+FFFD:
``bash``
LC_ALL=C grep -Pc '\xef\xbf\xbd' <file>
- The bug is intermittent — it depends on whether an SSE chunk boundary falls in the middle of a multi-byte UTF-8 character
Note: the corruption is also visible in terminal output as garbled characters, and is recorded in the session JSONL transcript.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.92
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
Evidence: input is clean, output is corrupted
I analyzed the session JSONL transcript byte-by-byte:
- Lines 1-143 contain zero U+FFFD — all input context (CLAUDE.md, rules, settings, every file read via Read tool) is completely clean.
- Line 144 (assistant role, Write tool call) is the first U+FFFD occurrence — corruption originates in model output processing, not in input.
- All 1,982 text files on disk are clean — no external contamination source.
Corruption pattern
| Pattern | Occurrences | Intended char | UTF-8 bytes |
|---------|------------|--------------|-------------|
| 3 consecutive U+FFFD | 26x | U+9280 | e9 8a 80 |
| 3 consecutive U+FFFD | 19x | U+5206 | e5 88 86 |
| 3 consecutive U+FFFD | 12x | U+30B8 | e3 82 b8 |
| 3 consecutive U+FFFD | 9x | U+5024 | e5 80 a4 |
| 2 consecutive U+FFFD | 3x | U+52D5 | e5 8b 95 |
| 2 consecutive U+FFFD | 2x | U+975E | e9 9d 9e |
Key observation: 3-byte CJK characters produce either 3 or 2 U+FFFD, consistent with byte-level splitting of a multi-byte UTF-8 sequence.
- 3 U+FFFD: all 3 bytes of the character fell at a chunk boundary and were each decoded as individual invalid bytes
- 2 U+FFFD: the first byte landed in the previous chunk (1 U+FFFD), the remaining 2 bytes in the next chunk (2 U+FFFD)
Root cause: TextDecoder.decode() without { stream: true }
Reproduced with Node.js TextDecoder:
// Character: U+9280 = e9 8a 80 (3-byte UTF-8)
const chunk1 = Buffer.from([0xe9]);
const chunk2 = Buffer.from([0x8a, 0x80]);
// WITHOUT stream option - each chunk decoded independently
new TextDecoder().decode(chunk1) + new TextDecoder().decode(chunk2)
// Result: "\ufffd\ufffd\ufffd" (3x U+FFFD) <-- REPRODUCES THE BUG
// WITH stream option - incomplete bytes buffered correctly
const d = new TextDecoder();
d.decode(chunk1, { stream: true }) + d.decode(chunk2)
// Result: correct character <-- FIXED
Exact code location in the binary
Claude Code (v2.1.92) is a Bun-compiled single executable (~220 MB). The bug is in the q88 function (Anthropic SDK's SSE LineDecoder), found at binary offsets 107357658 and 216449490:
// q88: converts Uint8Array line to string (minified, 90 bytes)
function q88(H) {
let $;
return (JD6 ?? ($ = new globalThis.TextDecoder, JD6 = $.decode.bind($)))(H);
}
This creates a shared TextDecoder instance and caches a bound .decode() without { stream: true }.
Full SSE streaming pipeline trace
fetch().body (ReadableStream<Uint8Array>)
-> FxH() wraps with getReader(), yields raw Uint8Array chunks
-> Zq_() accumulates bytes, splits by \n\n (SSE record boundary)
yields Uint8Array per SSE record
-> Me.decode() (LineDecoder) splits by \n at byte level
calls q88() per line to convert bytes -> string
-> GD6.decode() parses "event:", "data:" SSE fields
-> JSON.parse() extracts content_block_delta.delta.partial_json
-> string concat accumulates partial_json fragments
-> JSON.parse() final tool_use input (e.g., Write tool content)
Zq_ and Me correctly accumulate at the byte level before splitting by newline bytes. However, q88 decodes each split segment with a TextDecoder that has no streaming state. If the byte sequence passed to q88 contains incomplete UTF-8, the incomplete bytes silently become U+FFFD.
Proposed fix (verified, same-length binary patch)
The original q88 is exactly 90 bytes. This same-length replacement adds { stream: true }:
// Before (90 bytes):
function q88(H){let $;return(JD6??($=new globalThis.TextDecoder,JD6=$.decode.bind($)))(H)}
// After (90 bytes):
function q88(H){var D=new TextDecoder;JD6=JD6||(x=>D.decode(x,{stream:!0}));return JD6(H)}
The proper fix should be in the Anthropic SDK source (anthropic-sdk-typescript), in the LineDecoder / SSE decoder module.
Runtime monkey-patching is not possible — BUN_INSPECT_PRELOAD, --preload, and bunfig.toml preload all have no effect on compiled Bun binaries.
Impact on CJK users
This bug disproportionately affects CJK languages (Chinese, Japanese, Korean). ASCII text uses 1-byte characters that virtually never split at a problematic boundary. Japanese text is almost entirely 3-byte UTF-8 sequences, making the probability of hitting a chunk boundary mid-character much higher.
Workaround
PostToolUse hook that detects U+FFFD after every Write/Edit and blocks with an error message:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0; fi
FFFD_COUNT=$(LC_ALL=C grep -Pc '\xef\xbf\xbd' "$FILE_PATH" 2>/dev/null)
FFFD_COUNT=${FFFD_COUNT:-0}
if [ "$FFFD_COUNT" -gt 0 ]; then
CONTEXT=$(LC_ALL=C grep -nP '\xef\xbf\xbd' "$FILE_PATH" 2>/dev/null | head -5)
jq -n --arg file "$FILE_PATH" --arg count "$FFFD_COUNT" --arg context "$CONTEXT" \
'{ "decision": "block", "reason": "U+FFFD detected in \($file) (\($count) lines). Re-read and fix.\n\($context)" }'
exit 0
fi
exit 0
Configure in .claude/settings.json:
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-mojibake.sh" }]
}]
}
}
This detects corruption after the fact and prompts Claude to re-read and fix, but cannot prevent the corruption itself.
6 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
This issue is related to #42867, #40396, and #40574 but is not a duplicate. Those issues correctly describe the symptom (CJK characters corrupted to U+FFFD at chunk boundaries), but none of them identify the root cause at the code level.
This issue provides:
q88in the Anthropic SDK's SSELineDecoder, which callsTextDecoder.decode()without{ stream: true }TextDecoderwithout{ stream: true }produces the exact same 3x U+FFFD pattern{ stream: true }fetch().bodythroughFxH->Zq_->Me.decode->q88->JSON.parseThe fix should be applied in the Anthropic SDK source (
anthropic-sdk-typescript), in theLineDecodermodule whereTextDecoderis instantiated. Specifically, the bound.decode()needs{ stream: true }to buffer incomplete multi-byte UTF-8 sequences across chunk boundaries.I believe this issue should remain open as the authoritative root cause analysis, and the other issues can reference it.
Further analysis: SDK source looks safe in theory, but corruption still occurs
I traced the minified
q88function back to its source inanthropic-sdk-typescript:src/internal/utils/bytes.ts—decodeUTF8:src/internal/decoders/line.ts—LineDecoder.decode()concatenates incoming bytes into aUint8Arraybuffer, splits by newline bytes (0x0a/0x0d), and callsdecodeUTF8per complete line.src/core/streaming.ts—iterSSEChunksaccumulates bytes and splits by\n\n(SSE record boundary).Why the SDK source appears safe
The
LineDecodersplits on newline bytes (0x0a), which cannot appear inside a multi-byte UTF-8 sequence (continuation bytes are0x80-0xBF, lead bytes are0xC0+). So each segment passed todecodeUTF8should always be a complete UTF-8 sequence, and{ stream: true }should be unnecessary.But corruption still occurs
I have byte-level proof that clean input produces corrupted output in this exact pipeline. Something between the HTTP response body and the JSONL transcript introduces U+FFFD. Possible explanations:
ReadableStreamimplementation forfetch().bodymay chunk bytes differently, or itsUint8Arrayoperations may have edge cases not present in Node.js. The SDK is tested against Node.js, not Bun.LineDecoder, it could produce U+FFFD.iterSSEChunks— The double-newline detection (Zq_/iterSSEChunks) scans for\n\nin the accumulated buffer. If the split offset calculation has an off-by-one, it could yield a chunk that ends mid-character. This would only manifest with very specific byte alignments.Recommendation
Adding
{ stream: true }todecodeUTF8in the SDK would be a low-risk defensive fix — if the input is always complete UTF-8 (as the design intends),{ stream: true }is a no-op. But if any upstream layer has a subtle chunking bug, it would prevent silent data corruption.However, the actual root cause may be in the Bun runtime or in Claude Code's application layer rather than in the SDK. I don't have access to Claude Code's source to investigate further.
Bun runtime findings
Claude Code v2.1.92 is a Bun-compiled binary, not Node.js. Searching the oven-sh/bun repo reveals relevant history of UTF-8 streaming bugs:
TextDecoderwith{ stream: true }did not work — multi-byte sequences split across chunks produced incorrect output.TextDecoderis still broken when BOM is split across chunks.Per the spec, Bun's
fetch().bodyReadableStream can split chunks at arbitrary byte boundaries, including in the middle of multi-byte UTF-8 sequences. This is correct behavior — it is the caller's responsibility to handle this.SDK source analysis
I also traced the minified
q88back toanthropic-sdk-typescript:src/internal/utils/bytes.ts—decodeUTF8()usesnew TextDecoder()without{ stream: true }src/internal/decoders/line.ts—LineDecodersplits by newline bytes (0x0a) at the byte level before callingdecodeUTF8()src/core/streaming.ts—iterSSEChunkssplits by\n\nat the byte levelSince newline bytes cannot appear inside multi-byte UTF-8 sequences, the SDK design is theoretically safe — each segment passed to
decodeUTF8should be complete UTF-8. However, the corruption still occurs in practice.Possible explanations:
ReadableStreamchunking orUint8Arrayoperations that cause byte misalignment before theLineDecodersees the data.iterSSEChunks— The\n\nscan and split offset calculation could yield a chunk ending mid-character under specific byte alignments.Adding
{ stream: true }to the SDK'sdecodeUTF8would be a low-risk defensive fix — it's a no-op when input is already complete UTF-8, but prevents silent corruption if any upstream layer has a subtle bug.Additional data point: corruption also in
toolUseResult.stdouton macOS (not only model output)I hit the same U+FFFD corruption pattern but in a different code path than the model-output/Write-Edit case described above. Sharing byte-level evidence in case it helps narrow down whether this is (a) a single TextDecoder bug shared across paths or (b) multiple sites that each need the same
{ stream: true }fix.Environment
TERM=xterm-ghostty)ja_JP.UTF-8CLAUDE_CODE_NO_FLICKER=1enabled (ruled out as cause — see below)Symptom
Single U+FFFD bytes (
ef bf bd) persisted in session.jsonlfiles, primarily insidetoolUseResult.stdout— i.e. corruption of child-process stdout captured by the Bash tool, not model streaming output written via Write/Edit.Example from a session transcript (JSON path
.toolUseResult.stdout):And from a rendered table the model generated in a previous session:
Note the single FFFD mid-character in
状��態— consistent with one byte of a 3-byte UTF-8 sequence landing in a previous chunk and being decoded independently, then the remaining bytes in the next chunk also being mis-decoded. Same signature as #43746's analysis.Timeline (rules out local renderer)
Scanning all
.jsonlin~/.claude/projects/<project>/foref bf bd:14 sessions / ~30 FFFD-containing lines over 3+ weeks.
I initially suspected
CLAUDE_CODE_NO_FLICKER=1(the experimental renderer, enabled 2026-04-05), but the earliest corruption predates it by 3 weeks (2026-03-15). The renderer writes nothing to.jsonl— the corruption is upstream of display.What I think this adds to #43746
@shonnaise's analysis focuses on the SSE
LineDecoder→decodeUTF8path for model output. My corruption is intoolUseResult.stdout, which is the captured stdout of a child process spawned by the Bash tool. This is a separate code path that wouldn't go through the Anthropic SDK's SSE decoder at all — it reads directly from the child process's stdout pipe.If
toolUseResult.stdoutis also exhibiting the exact same FFFD-at-chunk-boundary pattern, then either:TextDecoderwithout{ stream: true }in its subprocess-stdout-capture path (separate fix needed), orfetch().body/subprocess.stdoutReadableStream has the chunking edge case mentioned in the earlier comments, affecting every consumer equally.Repro trigger (probabilistic)
The corruption is more likely when Bash tool runs a command producing large Japanese stdout (e.g.,
findover a path with Japanese filenames, grep/cat of large Japanese-containing files, tool reports that pretty-print CJK tables). Short stdout almost never hits it. This matches the "chunk boundary lottery" hypothesis.Workaround
Same PostToolUse hook as in the original issue, but matched against
Bashas well — detects FFFD in command output and re-runs. Does not prevent corruption, only flags it.Happy to provide a sanitized excerpt of a JSONL transcript containing the
toolUseResult.stdoutcorruption if useful for triage.This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.