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

  1. 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.
  2. 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())`).
  3. 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.
  4. 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

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


AI-assisted analysis of rtk-ai/rtk@d977e1c316 (2026-08-16). Data as JSON: /api/errors/15eaab16a41e2e8c. Report an issue: GitHub.