From f3fefd8a9f57aa5e6ad7e8d99b994712132ed639 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Mon, 24 Aug 2026 17:01:03 +0700 Subject: [PATCH 1/2] fix(claude-code): bound the stderr buffer without splitting a character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stderr drain capped its accumulator with a raw byte index: 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: "aé" -> bytes [97, 195, 169], is_char_boundary(2) = false truncate(2) -> assertion failed: self.is_char_boundary(new_len) So any stderr over 16 KiB whose 16384th byte lands inside a multi-byte character aborts the drain task. The panic happens inside `tokio::spawn` and the join is `stderr_task.await.unwrap_or_default()`, so it is swallowed into an empty string and the operator gets [claude-code][driver] exit Some(1) stderr= losing the entire error output for that turn. The repo already owns the right helper — `util::text::utf8_safe_prefix_at_byte_boundary`, used correctly by `tool_result_artifacts` — this call site simply bypassed it. Extracted as a pure `push_bounded` so it can be asserted without spawning a process, matching how every other testable piece of this file is factored. --- .../inference/provider/claude_code/driver.rs | 31 +++++++++++-- .../provider/claude_code/driver_tests.rs | 44 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/openhuman/inference/provider/claude_code/driver.rs b/src/openhuman/inference/provider/claude_code/driver.rs index 11417843710..4b9cb7e64a3 100644 --- a/src/openhuman/inference/provider/claude_code/driver.rs +++ b/src/openhuman/inference/provider/claude_code/driver.rs @@ -18,6 +18,28 @@ use tokio::sync::mpsc; /// infinite loop, MCP deadlock) we kill the child and surface a timeout. const TURN_TIMEOUT: Duration = Duration::from_secs(300); +/// How much of the child's stderr we keep for diagnostics. +const STDERR_DIAGNOSTIC_CAP: usize = 16_384; + +/// Append `chunk` to `acc`, keeping at most `max_bytes` and never splitting a +/// character. +/// +/// `String::truncate` takes a *byte* index and panics when it is not a character +/// boundary, so bounding this accumulator with `acc.truncate(16_384)` aborted the +/// task the moment a multi-byte character straddled the cap. The panic happened +/// inside `tokio::spawn`, and the join is `unwrap_or_default()`, so it surfaced as +/// an empty stderr string: the operator lost the whole error output for that turn +/// and saw `exit Some(1) stderr=`. +fn push_bounded(acc: &mut String, chunk: &str, max_bytes: usize) { + acc.push_str(chunk); + if acc.len() > max_bytes { + let keep = utf8_safe_prefix_at_byte_boundary(acc, max_bytes).len(); + acc.truncate(keep); + } +} + +use crate::openhuman::util::text::utf8_safe_prefix_at_byte_boundary; + use super::event_mapper::EventMapper; use super::input_builder::build_stdin; use super::session_store::{generate_uuid_v4, is_uuid_v4, SessionStore}; @@ -421,10 +443,11 @@ pub async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result { if n == 0 { break; } - acc.push_str(&String::from_utf8_lossy(&tmp[..n])); - if acc.len() > 16_384 { - acc.truncate(16_384); - } + push_bounded( + &mut acc, + &String::from_utf8_lossy(&tmp[..n]), + STDERR_DIAGNOSTIC_CAP, + ); } acc }); diff --git a/src/openhuman/inference/provider/claude_code/driver_tests.rs b/src/openhuman/inference/provider/claude_code/driver_tests.rs index 081e03d29f5..de3951a74db 100644 --- a/src/openhuman/inference/provider/claude_code/driver_tests.rs +++ b/src/openhuman/inference/provider/claude_code/driver_tests.rs @@ -178,3 +178,47 @@ fn full_access_reads_persisted_toggle_when_env_unset() { } let _ = std::fs::remove_dir_all(&ws); } + +/// `String::truncate` takes a byte index and panics when it is not a character +/// boundary, so bounding the stderr accumulator with `acc.truncate(16_384)` +/// aborted the drain task whenever a multi-byte character straddled the cap. +/// The join is `unwrap_or_default()`, so the operator saw `stderr=` and lost +/// the whole error output for that turn. +#[test] +fn push_bounded_never_splits_a_character() { + // "aéb" is 4 bytes: a=0, é=1..2, b=3. A cap of 2 lands *inside* 'é', so the + // helper must back up to byte 1 -- `String::truncate(2)` would panic here. + let mut acc = String::new(); + push_bounded(&mut acc, "aéb", 2); + assert_eq!(acc, "a", "must back up to the boundary, not split it"); + + // A cap of 3 lands exactly on a boundary, so nothing is given up needlessly. + let mut acc = String::new(); + push_bounded(&mut acc, "aéb", 3); + assert_eq!(acc, "aé"); + assert!(acc.len() <= 3); + + // The same, driven the way the reader does it: many small chunks over the cap. + let mut acc = String::new(); + for _ in 0..600 { + push_bounded(&mut acc, "日本語テキスト", 1024); + } + assert!(acc.len() <= 1024); + // The real assertion: it is still valid UTF-8 and did not panic getting here. + assert!(std::str::from_utf8(acc.as_bytes()).is_ok()); +} + +#[test] +fn push_bounded_keeps_everything_below_the_cap() { + let mut acc = String::new(); + push_bounded(&mut acc, "hello ", 64); + push_bounded(&mut acc, "world", 64); + assert_eq!(acc, "hello world"); +} + +#[test] +fn push_bounded_handles_an_ascii_cap_exactly() { + let mut acc = String::new(); + push_bounded(&mut acc, "abcdef", 3); + assert_eq!(acc, "abc"); +} From 623378a645af36abcf1cc25d8d78234ca0981c68 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 12 Sep 2026 04:29:45 +0300 Subject: [PATCH 2/2] fix(test): remove stray blank lines in driver_tests Removed an extraneous blank line at the top of the file and added one before the last test function to improve readability without changing any test behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/inference/provider/claude_code/driver_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/inference/provider/claude_code/driver_tests.rs b/src/openhuman/inference/provider/claude_code/driver_tests.rs index e3bfa47a239..c57b3a9e7e2 100644 --- a/src/openhuman/inference/provider/claude_code/driver_tests.rs +++ b/src/openhuman/inference/provider/claude_code/driver_tests.rs @@ -1,4 +1,3 @@ - use super::*; #[test] @@ -223,6 +222,7 @@ fn push_bounded_handles_an_ascii_cap_exactly() { super::push_bounded(&mut acc, "abcdef", 3); assert_eq!(acc, "abc"); } + #[test] fn parse_error_events_produce_a_log_line() { let ev = ClaudeCodeEvent::ParseError {