Skip to content

fix(claude-code): bound the stderr buffer without splitting a character - #5719

Closed
ntdatt812 wants to merge 0 commit into
tinyhumansai:mainfrom
ntdatt812:fix/claude-code-stderr-utf8
Closed

ntdatt812 wants to merge 0 commit into
tinyhumansai:mainfrom
ntdatt812:fix/claude-code-stderr-utf8

Conversation

@ntdatt812

@ntdatt812 ntdatt812 commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Sibling of #5718, same file family, different failure mode. Independent of it — either can land first.

The defect

The stderr drain caps its accumulator with a raw byte index (driver.rs:417-427):

acc.push_str(&String::from_utf8_lossy(&tmp[..n]));
if acc.len() > 16_384 {
    acc.truncate(16_384);
}

String::truncate takes a byte index and panics when it is not a character boundary. Proved with rustc on the exact shape:

bytes = [97, 195, 169], len = 3        // "aé"
is_char_boundary(2) = false
assertion failed: self.is_char_boundary(new_len)
truncate(2) panicked = true

So any stderr over 16 KiB whose 16384th byte lands inside a multi-byte character aborts the drain task.

Why it is invisible

The panic happens inside tokio::spawn, and the join is stderr_task.await.unwrap_or_default() (driver.rs:483). The JoinError is swallowed into an empty string, so driver.rs:486 reports

[claude-code][driver] exit Some(1) stderr=

The operator loses the entire error output for the turn, at exactly the moment they need it — a failing turn with a lot of stderr is the case most likely to exceed 16 KiB.

The fix

The repo already owns the right helper: util::text::utf8_safe_prefix_at_byte_boundary, whose doc comment describes this exact problem, and which tool_result_artifacts/mod.rs:58 and :432 already use correctly. This call site simply bypassed it — I checked the other two raw String::truncate calls in src/ (mcp/server/tools/params.rs:496, sandbox/cwd_jail/windows.rs:425) and both operate on provably ASCII input, so this was the only one.

Extracted as a pure push_bounded(&mut String, &str, usize) so it can be asserted without spawning a process, matching how every other testable piece of this file is factored.

Verification

cargo test -p openhuman --lib claude_code::driver     9 passed, 0 failed   (6 before)
cargo fmt --check                                     clean

Mutation-checked, after confirming the edit applied: restoring acc.truncate(max_bytes) reproduces the original panic inside the test —

panicked at driver.rs:36: assertion failed: self.is_char_boundary(new_len)
test result: FAILED. 8 passed; 1 failed

— so the test pins the panic itself, not merely the length.

Tests

run_turn is untested (it spawns a process), which is why the bound was never exercised. Three cases on the extracted helper:

  • a cap landing inside é, which must back up rather than panic;
  • a cap landing exactly on a boundary, which must not give up a character needlessly — I had this expectation wrong at first and the test caught me;
  • 600 chunks of Japanese driven over a 1 KiB cap the way the reader does it, asserting the result stays under the cap and is still valid UTF-8.

Plus a below-cap case and an ASCII exact-cap case.

Summary by CodeRabbit

  • Bug Fixes
    • Improved error handling for command output containing multibyte characters.
    • Prevented crashes when diagnostic output exceeds its size limit.
    • Preserved complete characters while limiting accumulated error output.

@ntdatt812
ntdatt812 requested a review from a team August 24, 2026 10:01
@coderabbitai

coderabbitai Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10a69531-60df-4484-a842-519831988029

📥 Commits

Reviewing files that changed from the base of the PR and between e1c332b and 130ce02.

📒 Files selected for processing (1)
  • src/openhuman/inference/provider/claude_code/driver.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The Claude Code driver now bounds stderr diagnostics at a fixed byte cap while preserving UTF-8 boundaries. Stderr draining uses the new helper. Unit tests cover multibyte input, repeated chunks, below-cap accumulation, and exact ASCII caps.

Changes

Stderr diagnostics

Layer / File(s) Summary
Bounded stderr accumulation and validation
src/openhuman/inference/provider/claude_code/driver.rs
The driver adds STDERR_DIAGNOSTIC_CAP and push_bounded. Stderr draining uses the helper. Tests cover UTF-8-safe truncation and cap behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 130ce

This localized change prevents stderr truncation from panicking on multibyte UTF-8 characters while preserving bounded output; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: senamakel

Poem

A rabbit bounds the stderr stream,
With UTF-8 paws and caps supreme.
No broken bytes leap apart,
Each chunk stays whole from end to start.
Tests hop twice, then settle light. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: bounding the Claude Code stderr buffer without splitting UTF-8 characters.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

Your free Security trial is over. An organization admin can activate billing to continue.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Warning

⚠️ This pull request shows signs of AI-generated slop (redundant_comments, trivial_assertion). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tinysweeper found nothing blocking. Approving.

$0.0000 · 0 in / 0 out · 233 embedded · openrouter/openai/text-embedding-3-small

@tinysweeper

tinysweeper Bot commented Aug 24, 2026

Copy link
Copy Markdown

How this change flows

1 changed behaviour across 8 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 42 further behaviours left out to keep the diagram readable.

flowchart LR
  n0["run_turn<br/>changed"]:::changed
  n1["join"]:::impacted
  n2["append_system_prompt_args"]:::impacted
  n3["build_stdin"]:::impacted
  n4["feed_bytes"]:::impacted
  n5["vec"]:::impacted
  n6["expect"]:::impacted
  n0 -->|calls| n1
  n0 -->|calls| n2
  n0 -->|calls| n3
  n0 -->|calls| n4
  n0 -->|calls| n5
  n0 -->|calls| n6
  n2 -->|calls| n1
  n2 -->|calls| n5
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge.

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Aug 24, 2026
@M3gA-Mind

Copy link
Copy Markdown
Collaborator

@ntdatt812 — my mistake, and I'm sorry: I closed this PR by accident and I cannot undo it from my side. Here is exactly what happened and exactly how to get it back.

What happened. I was rebasing this branch onto current main for you (it was CONFLICTING). The rebase itself went fine. But I was working in a checkout that another process moved out from under me between the rebase and the push, so the push sent main's tip (fa044d388) to fix/claude-code-stderr-utf8 instead of your rebased commit. With no commits ahead of base, GitHub auto-closed this PR — and closing it revoked the maintainer-can-modify grant, so I can no longer push the correct commit back to restore it. GitHub also refuses to reopen: "There are no new commits on the ntdatt812:fix/claude-code-stderr-utf8 branch."

Nothing is lost. Your work, rebased onto main with the conflict resolved and your authorship intact, is at:

https://github.com/M3gA-Mind/openhuman  branch: recover/5719-stderr-utf8  (commit e814c6dd0)

To restore, one of:

# from your clone
git remote add m3ga https://github.com/M3gA-Mind/openhuman.git
git fetch m3ga recover/5719-stderr-utf8
git checkout fix/claude-code-stderr-utf8
git reset --hard m3ga/recover/5719-stderr-utf8
git push --force-with-lease

That puts commits back on the branch, after which this PR can be reopened (the Reopen button should light up once the branch is non-empty). If it will not reopen, open a fresh PR from the same branch and I will re-link it.

What the rebase actually changed — one conflict, in src/openhuman/inference/provider/claude_code/driver.rs:

main has since moved the driver's inline mod tests { … } out into a separate driver_tests.rs (the _part_NN.rs / test-file split from #5856/#5857). Your three new tests were written against the old inline module. The resolution keeps main's out-of-line form:

#[cfg(test)]
#[path = "driver_tests.rs"]
mod tests;

and moves your push_bounded_never_splits_a_character, push_bounded_keeps_everything_below_the_cap and push_bounded_handles_an_ascii_cap_exactly into driver_tests.rs verbatim, de-indented one level. driver_tests.rs opens with use super::*;, so push_bounded is still in scope. Your production hunks — STDERR_DIAGNOSTIC_CAP, push_bounded, and the call site in the stderr drain — applied without conflict and are unchanged.

The fix itself is right and worth landing: String::truncate on a byte index really does panic mid-character, and the unwrap_or_default() on the join really does swallow it into an empty stderr=. Again, sorry for the detour.

@ntdatt812

Copy link
Copy Markdown
Contributor Author

No harm done — thank you for writing down exactly what happened and where the work was. That is a much better outcome than a silent force-push.

Branch restored. GitHub still refused to reopen this one — its recorded head is fa044d388, so it sees no commits — so the work is now at #5980, from the same branch. Please re-link it when you get a moment.

I did not take recover/5719-stderr-utf8 on trust, since it is a force-push into my branch from another fork. I verified it instead:

  • Authorship intact — Nguyen Thanh Dat <ntdat812@gmail.com>, original author date preserved.
  • Content is mine — the production hunks (STDERR_DIAGNOSTIC_CAP, push_bounded, the call site in the stderr drain, and the use of utf8_safe_prefix_at_byte_boundary) are byte-identical to my 130ce0231, and the three tests are verbatim, de-indented one level.
  • Your split resolution is the right one — driver.rs keeps main's out-of-line #[path = "driver_tests.rs"] mod tests;, and driver_tests.rs opens with use super::*;, so push_bounded stays in scope without re-exporting it.

Your recovery branch was cut against fa044d388, which is now 47 commits behind, so rather than pushing it as-is I cherry-picked it onto today's main — clean, no conflict.

Verification on the rebased head: the three push_bounded_* tests pass, and the layout gate passes — driver.rs 524 lines, driver_tests.rs 224, both under 750, tests external.

One thing worth flagging for whoever else you are rebasing for: the auto-close revoked maintainer-can-modify, so the same accident on any other PR of mine will need the same recovery path. Happy to do the rebases myself if that is easier — for the other conflicting ones you reviewed today, I am working through them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants