From 6821f294e2c5f33e44c0a8277f6bad6f96b059da Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 21 May 2026 00:12:59 +0800 Subject: [PATCH] fix(rlm): decode stdout lossily --- crates/tui/src/repl/runtime.rs | 62 ++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/repl/runtime.rs b/crates/tui/src/repl/runtime.rs index cf2bddc0..71e4ead8 100644 --- a/crates/tui/src/repl/runtime.rs +++ b/crates/tui/src/repl/runtime.rs @@ -288,15 +288,12 @@ impl PythonRuntime { async fn read_until_ready(&mut self, ready_sentinel: &str) -> Result<(), String> { loop { - let mut line = String::new(); - let n = self - .stdout - .read_line(&mut line) - .await - .map_err(|e| format!("stdout read: {e}"))?; - if n == 0 { - return Err("Python interpreter closed stdout before ready signal".to_string()); - } + let line = match self.read_stdout_line_lossy().await? { + Some(line) => line, + None => { + return Err("Python interpreter closed stdout before ready signal".to_string()); + } + }; let trimmed = line.trim_end_matches(['\n', '\r']); if trimmed == ready_sentinel { return Ok(()); @@ -305,6 +302,20 @@ impl PythonRuntime { } } + async fn read_stdout_line_lossy(&mut self) -> Result, String> { + let mut buf = Vec::new(); + let n = self + .stdout + .read_until(b'\n', &mut buf) + .await + .map_err(|e| format!("stdout read: {e}"))?; + if n == 0 { + Ok(None) + } else { + Ok(Some(String::from_utf8_lossy(&buf).into_owned())) + } + } + /// Execute a Python code block with no RPC dispatcher. Used for inline /// `repl` blocks where `llm_query()` should fall back to a sentinel. pub async fn execute(&mut self, code: &str) -> Result { @@ -352,15 +363,12 @@ impl PythonRuntime { let read_loop = async { loop { - let mut line = String::new(); - let n = self - .stdout - .read_line(&mut line) - .await - .map_err(|e| format!("stdout read: {e}"))?; - if n == 0 { - return Err("Python interpreter closed stdout mid-round".to_string()); - } + let line = match self.read_stdout_line_lossy().await? { + Some(line) => line, + None => { + return Err("Python interpreter closed stdout mid-round".to_string()); + } + }; let trimmed = line.trim_end_matches(['\n', '\r']); if let Some(rest) = trimmed.strip_prefix(&done_prefix) { @@ -1079,6 +1087,24 @@ mod tests { rt.shutdown().await; } + #[tokio::test] + async fn non_utf8_stdout_decodes_lossy_and_runtime_survives() { + let mut rt = PythonRuntime::new().await.expect("spawn"); + let round = rt + .execute( + "import sys\n\ + sys.stdout.buffer.write(b'bad:\\xff\\n')\n\ + sys.stdout.buffer.flush()\n\ + print('after invalid')", + ) + .await + .expect("execute"); + + assert!(round.stdout.contains("bad:\u{fffd}"), "{}", round.stdout); + assert!(round.stdout.contains("after invalid"), "{}", round.stdout); + rt.shutdown().await; + } + #[tokio::test] async fn variables_persist_across_rounds() { let mut rt = PythonRuntime::new().await.expect("spawn");