rtk-ai/rtk · error
stderr streaming thread panicked
Error message
stderr streaming thread panicked
What it means
This anyhow error is created at src/main.rs:2675-2677 in the `rtk proxy <cmd>` path when stderr_handle.join() returns Err, i.e. the thread spawned at src/main.rs:2643 to pump the child's stderr terminated via panic instead of returning. The closure is written panic-free on purpose: every I/O failure (read from the pipe, write/flush to the parent's stderr, including EPIPE when the consumer closed the terminal/pipe) is returned as io::Error through `?`, which surfaces as a different, context-less io error via the second `?` — not this message. So this message means a genuine panic ran inside the streaming closure (an indexing/unwrap bug introduced by a later edit, a stack overflow on the thread, or an abort-like failure), and the child's captured stderr bytes are lost.
Source
Thrown at src/main.rs:2677
err.flush()?;
}
Ok(captured)
});
let status = child
.0
.take()
.context("Child process missing")?
.wait()
.context(format!("Failed waiting for command: {}", cmd_name))?;
let stdout_bytes = stdout_handle
.join()
.map_err(|_| anyhow::anyhow!("stdout streaming thread panicked"))??;
let stderr_bytes = stderr_handle
.join()
.map_err(|_| anyhow::anyhow!("stderr streaming thread panicked"))??;
let stdout = String::from_utf8_lossy(&stdout_bytes);
let stderr = String::from_utf8_lossy(&stderr_bytes);
let full_output = format!("{}{}", stdout, stderr);
// Track usage (input = output since no filtering)
timer.track(
&format!("{} {}", cmd_name, cmd_args.join(" ")),
&format!("rtk proxy {} {}", cmd_name, cmd_args.join(" ")),
&full_output,
&full_output,
);
core::utils::exit_code_from_status(&status, &cmd_name)
}
Commands::Trust { list, yes } => {
hooks::trust::run_trust(list, yes)?;View on GitHub (pinned to d977e1c316)
Solutions
- Reproduce with RUST_BACKTRACE=1 (e.g. `RUST_BACKTRACE=1 rtk proxy <cmd> 2>/dev/full`) — the panic hook prints the original panic location inside the closure at src/main.rs:2643-2663, which is the real bug site, not line 2677.
- Audit the stderr/stdout closures for panic sources: replace any unwrap()/expect()/direct indexing with `?` and checked slicing; the loop already demonstrates the safe pattern (`let take = count.min(CAP - captured.len())`).
- Improve the join error to surface the panic payload (downcast to &str/String) so the next occurrence reports the panic message instead of a generic string.
- If you are an rtk user (not editing rtk), run the command directly without rtk to unblock, and report the panic backtrace to the rtk repo.
Example fix
// before (src/main.rs:2675)
let stderr_bytes = stderr_handle
.join()
.map_err(|_| anyhow::anyhow!("stderr streaming thread panicked"))??;
// after: surface the panic payload and keep io errors contextual
let stderr_bytes = stderr_handle.join().map_err(|p| {
let msg = p
.downcast_ref::<&str>()
.copied()
.or_else(|| p.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("unknown panic");
anyhow::anyhow!("stderr streaming thread panicked: {}", msg)
})?.context("streaming child stderr")?; Defensive patterns
Strategy: fallback
Try / catch
// If you embed this proxy pattern in your own code, wrap the joins so a
// streaming-thread panic degrades instead of aborting the caller:
use std::panic::{catch_unwind, AssertUnwindSafe};
let joined = catch_unwind(AssertUnwindSafe(|| stderr_handle.join()));
let stderr_bytes: Vec<u8> = match joined {
Ok(Ok(Ok(bytes))) => bytes,
Ok(Ok(Err(io_err))) => {
// real I/O failure (e.g. EPIPE): report context, keep going with empty capture
eprintln!("warning: stderr stream failed: {io_err}");
Vec::new()
}
_ => {
// thread panicked: fall back to no capture rather than unwinding here
eprintln!("warning: stderr streaming thread panicked");
Vec::new()
}
}; Prevention
- Keep the pump closures panic-free by construction: return io::Result and use `?` for every read/write/flush — never unwrap(), expect(), or unchecked slicing inside the thread.
- Mirror the existing bounded-capture idiom (`count.min(CAP - captured.len())`) whenever you edit the loop so slice bounds stay provably in range.
- If you are an rtk user hitting this, fall back to running the command without rtk (the raw command always works) and report the RUST_BACKTRACE=1 output, since the panic originates in rtk's thread, not your command.
- Test proxy streaming against hostile consumers (`rtk proxy <cmd> | head -1`, stderr to /dev/full) so EPIPE paths are exercised by io errors, confirming panics cannot arise from pipes.
When it happens
Trigger: Running `rtk proxy <command>` where the stderr pump thread panics: (1) any code change to the closure at src/main.rs:2643-2663 that adds unwrap()/expect()/slice indexing instead of `?`; (2) thread stack exhaustion while running the loop (pathological recursion added inside the loop); (3) a panic in std::io::stderr().lock() teardown during signal-driven exit (the custom SIGINT/SIGTERM handler at src/main.rs:2572-2581 kills the child and exits without joining). Note EPIPE on stderr writes does NOT trigger this — it yields an io::Result::Err path instead.
Common situations: A maintainer extends the streaming loop (e.g., adding TTY detection or line buffering) and introduces an unwrap or a buf[..n] slice with wrong bounds; tests pass because normal runs never hit the edge, then `rtk proxy cargo test` aborts with this message. Also seen when rtk runs under wrappers that close stderr early (piped into head) combined with a panic-prone closure edit — users misread it as a broken pipe, but the pipe case produces a plain io error.
Related errors
- stdout streaming thread panicked
- proxy requires a command to execute Usage: rtk proxy <comman
- invalid nextest summary regex
- invalid nextest starting regex
- invalid regex patterns
AI-assisted analysis of rtk-ai/rtk@d977e1c316 (2026-08-16).
Data as JSON: /api/errors/15eaab16a41e2e8c.
Report an issue: GitHub.