fix(app): surgical broken-pipe stderr panic guard (TAURI-RUST-F) (#3772)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-22 10:15:24 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent bd3e3eab00
commit c09b118cbe
2 changed files with 303 additions and 0 deletions
+22
View File
@@ -57,6 +57,7 @@ mod ptt_overlay;
mod reset_reboot_schedule;
mod screen_capture;
mod slack_scanner;
mod stderr_panic_hook;
mod telegram_scanner;
mod webview_accounts;
mod webview_apis;
@@ -2061,6 +2062,18 @@ fn install_silent_x_error_handler() {
fn install_silent_x_error_handler() {}
pub fn run() {
// Neutralise a broken inherited stderr *pipe* BEFORE any `eprintln!` can
// fire. On Windows, when the GUI process inherits an stderr pipe whose
// parent end later closes, the next stdlib stderr write fails with a
// broken-pipe errno and `std::io::stdio::print_to` raises
// `panic!("failed printing to stderr: …")` on the main thread, aborting the
// app over an external condition (Sentry TAURI-RUST-F). Redirecting that
// pipe to NUL makes the write succeed (discarded) instead of panicking. A
// panic hook cannot fix this — it runs during unwinding and cannot stop the
// abort — so the cure is at the write path. No-op on non-Windows / when
// stderr is a console or file. See `stderr_panic_hook`.
stderr_panic_hook::neutralize_broken_parent_stderr();
// Must run before any GTK/CEF code that could trigger X calls — otherwise
// Xlib's default handler calls exit(1) on the first BadWindow and we never
// reach this line. See helper doc above for the full reasoning.
@@ -2284,6 +2297,15 @@ pub fn run() {
}
});
// Install the panic hook *after* `sentry::init` so `take_hook()` captures
// Sentry's panic integration as its chain target. The hook ALWAYS chains to
// the previous hook for every panic — it never swallows, so no crash is ever
// hidden from Sentry. The actual broken-pipe-on-stderr abort is prevented at
// the write path by `neutralize_broken_parent_stderr()` (called at the top of
// `run()`); this hook only adds a diagnostic breadcrumb for that family. See
// `stderr_panic_hook` for the full rationale (Sentry TAURI-RUST-F).
stderr_panic_hook::install();
// Optional smoke trigger for verifying the Sentry pipeline end-to-end.
// Run with `OPENHUMAN_TAURI_SENTRY_TEST=panic` to fire a panic, or
// `=message` to send a captured-message event. No-op when unset.
+281
View File
@@ -0,0 +1,281 @@
//! Broken-pipe-on-stderr crash hardening for the desktop GUI process.
//!
//! ## The bug (Sentry TAURI-RUST-F, message family
//! `panic: failed printing to stderr: The pipe is being closed. (os error 232)`)
//!
//! `std::io::stdio::print_to` (`library/std/src/io/stdio.rs`) turns a failed
//! stderr write into an unconditional `panic!("failed printing to stderr: {e}")`.
//! This panic is raised by the `eprint!` / `eprintln!` macros (and direct
//! `io::stderr().write*`) — NOT by the default panic printer, which ignores
//! stderr-write errors. So this is a *primary* panic from an explicit
//! diagnostic write hitting a dead stderr.
//!
//! In the GUI process (`openhuman::run`, the TAURI-RUST-F culprit) the
//! breakable handle is an inherited stderr *pipe*: the app is launched with its
//! stderr wired to a parent process (or a now-closed parent console). When that
//! parent end goes away, the next `eprintln!` anywhere in the `run()` call
//! graph fails with a broken-pipe errno (Windows `232` ERROR_NO_DATA / `109`
//! ERROR_BROKEN_PIPE, POSIX `32` EPIPE) and `print_to` panics on the main
//! thread — aborting an app the user never asked to close, over an external
//! condition (the parent going away).
//!
//! ## Why this is fixed at the write path, not in the panic hook
//!
//! A panic hook runs *during* unwinding; returning early from it does **not**
//! stop the unwind, and on the main thread the process still terminates. Worse,
//! an early return would skip the chained Sentry hook, hiding a crash that still
//! happens. A hook therefore cannot absorb this panic (Codex review, PR #3772).
//!
//! The real fix is to stop the write from erroring in the first place:
//! [`neutralize_broken_parent_stderr`] runs at GUI startup and, on Windows,
//! redirects an inherited stderr **pipe** to the `NUL` device. Subsequent
//! `eprintln!` writes then succeed (discarded) instead of EPIPE-panicking. GUI
//! diagnostics already flow to Sentry + file logging, not this pipe, so nothing
//! observable is lost — and console / file stderr (`FILE_TYPE_CHAR` /
//! `FILE_TYPE_DISK`, e.g. a dev console or a user `2> log.txt`) is left intact.
//!
//! [`install`] still installs a panic hook, but it **always** chains to the
//! previously-installed hook (Sentry's panic integration when called after
//! `sentry::init`). It never swallows a panic. For the broken-pipe family it
//! adds an informational breadcrumb so the context survives, but the panic is
//! still reported — defense-in-depth around the [`neutralize_broken_parent_stderr`]
//! source fix, never a substitute for it.
use std::panic::PanicHookInfo;
/// Extract the panic payload as a `&str` if it carries a string message.
///
/// Rust panic payloads are either `&'static str` (from `panic!("literal")`) or
/// `String` (from `panic!("{}", x)`). The stdlib stderr-write panic uses the
/// formatting form, so it is a `String`; we still check both to be safe.
fn payload_message<'a>(info: &'a PanicHookInfo<'_>) -> Option<&'a str> {
let payload = info.payload();
if let Some(s) = payload.downcast_ref::<&'static str>() {
Some(*s)
} else {
payload.downcast_ref::<String>().map(|s| s.as_str())
}
}
/// Classify whether a panic message is the "stderr write failed because the
/// (parent) pipe is closed" case.
///
/// Used only to tag a diagnostic breadcrumb — the panic is reported either way.
/// We anchor on the stdlib's stable prefix `"failed printing to stderr"` (the
/// literal in `std::io::stdio::print_to`) AND a broken-pipe signal, matched
/// locale-independently via the embedded OS error code:
/// * `os error 232` — Windows `ERROR_NO_DATA` ("The pipe is being closed.")
/// * `os error 109` — Windows `ERROR_BROKEN_PIPE` ("The pipe has been ended.")
/// * `os error 32` — POSIX `EPIPE` (defensive).
///
/// `std::io::Error`'s `Display` for an OS error always appends ` (os error N)`
/// regardless of system locale, so this matches the Chinese-locale variant
/// (`管道正在被关闭`) just as well as the English one.
pub(crate) fn is_broken_pipe_stderr_panic(message: &str) -> bool {
if !message.contains("failed printing to stderr") {
return false;
}
message.contains("(os error 232)") // Windows ERROR_NO_DATA — "pipe is being closed"
|| message.contains("(os error 109)") // Windows ERROR_BROKEN_PIPE
|| message.contains("(os error 32)") // POSIX EPIPE (defensive)
}
/// Core panic-hook body, factored out so the always-chain contract is testable.
///
/// For the broken-pipe-on-stderr family it records an informational breadcrumb
/// for context, then — for **every** panic without exception — calls `chain`
/// (the previously-installed hook, i.e. Sentry's panic integration). It never
/// returns early and never swallows: this hook cannot stop the unwind, so its
/// only job is to make sure the crash is always reported. Prevention lives in
/// [`neutralize_broken_parent_stderr`].
fn handle_panic(info: &PanicHookInfo<'_>, chain: &dyn Fn(&PanicHookInfo<'_>)) {
if let Some(message) = payload_message(info) {
if is_broken_pipe_stderr_panic(message) {
sentry::add_breadcrumb(sentry::Breadcrumb {
category: Some("panic".into()),
level: sentry::Level::Info,
message: Some(
"broken-pipe stderr panic (parent stderr closed); \
neutralize_broken_parent_stderr should have prevented this"
.into(),
),
..Default::default()
});
log::debug!("[stderr-panic-guard] broken-pipe stderr panic observed: {message}");
}
}
// ALWAYS chain — never hide a crash from Sentry.
chain(info);
}
/// Install the panic hook. Chains to the previously-installed hook (Sentry's
/// panic integration when called after `sentry::init`) for *every* panic.
pub(crate) fn install() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| {
handle_panic(info, &|i| previous(i));
}));
}
/// Source-level fix for the broken-pipe-on-stderr abort (Windows).
///
/// If the process's stderr is an inherited **pipe** (`FILE_TYPE_PIPE`), redirect
/// `STD_ERROR_HANDLE` to the `NUL` device so later `eprintln!` writes can never
/// fail with a broken-pipe errno (and so never reach `print_to`'s `panic!`).
/// Console (`FILE_TYPE_CHAR`) and file (`FILE_TYPE_DISK`) stderr are left
/// untouched, preserving legitimate diagnostics. No-op on non-Windows and when
/// stderr is not a pipe.
///
/// Call this at the very start of GUI startup, before any `eprintln!` can fire.
#[cfg(target_os = "windows")]
pub(crate) fn neutralize_broken_parent_stderr() {
use windows_sys::Win32::Foundation::{GENERIC_WRITE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, GetFileType, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_PIPE, OPEN_EXISTING,
};
use windows_sys::Win32::System::Console::{GetStdHandle, SetStdHandle, STD_ERROR_HANDLE};
// SAFETY: all calls below are plain Win32 FFI with no preconditions beyond
// valid constants. GetStdHandle returns a borrowed handle we do not close;
// the NUL handle we open is intentionally leaked for the process lifetime
// (it becomes the new stderr). Failures degrade to a no-op.
unsafe {
let stderr = GetStdHandle(STD_ERROR_HANDLE);
if stderr == INVALID_HANDLE_VALUE || stderr.is_null() {
return;
}
// Only redirect an inherited pipe — the breakable handle in the
// TAURI-RUST-F GUI case. Leave a real console / file redirect alone.
if GetFileType(stderr) != FILE_TYPE_PIPE {
return;
}
// "NUL" wide string, NUL-terminated.
let nul: [u16; 4] = [b'N' as u16, b'U' as u16, b'L' as u16, 0];
let nul_handle = CreateFileW(
nul.as_ptr(),
GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
);
if nul_handle == INVALID_HANDLE_VALUE || nul_handle.is_null() {
return;
}
// Redirect process stderr to NUL. Rust's std fetches STD_ERROR_HANDLE
// per write on Windows, so this takes effect for all later eprintln!.
SetStdHandle(STD_ERROR_HANDLE, nul_handle);
}
}
/// No-op on non-Windows: the inherited-pipe broken-stderr abort is the Windows
/// GUI case (TAURI-RUST-F). POSIX broken-pipe is matched defensively in
/// [`is_broken_pipe_stderr_panic`] for breadcrumb tagging only.
#[cfg(not(target_os = "windows"))]
pub(crate) fn neutralize_broken_parent_stderr() {}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn classifies_windows_pipe_closing_232_as_broken_pipe() {
assert!(is_broken_pipe_stderr_panic(
"failed printing to stderr: The pipe is being closed. (os error 232)"
));
}
#[test]
fn classifies_chinese_locale_variant_via_os_error_code() {
// Localized strerror, but the `(os error 232)` suffix is locale-stable.
assert!(is_broken_pipe_stderr_panic(
"failed printing to stderr: 管道正在被关闭。 (os error 232)"
));
}
#[test]
fn classifies_windows_broken_pipe_109() {
assert!(is_broken_pipe_stderr_panic(
"failed printing to stderr: The pipe has been ended. (os error 109)"
));
}
#[test]
fn classifies_posix_epipe_32() {
assert!(is_broken_pipe_stderr_panic(
"failed printing to stderr: Broken pipe (os error 32)"
));
}
#[test]
fn does_not_classify_assertion_panic() {
assert!(!is_broken_pipe_stderr_panic(
"assertion `left == right` failed\n left: 1\n right: 2"
));
}
#[test]
fn does_not_classify_arbitrary_panic() {
assert!(!is_broken_pipe_stderr_panic(
"index out of bounds: the len is 0 but the index is 3"
));
}
#[test]
fn does_not_classify_stdout_broken_pipe() {
assert!(!is_broken_pipe_stderr_panic(
"failed printing to stdout: The pipe is being closed. (os error 232)"
));
}
#[test]
fn does_not_classify_stderr_panic_with_unrelated_error() {
assert!(!is_broken_pipe_stderr_panic(
"failed printing to stderr: Permission denied (os error 13)"
));
}
#[test]
fn empty_message_is_not_broken_pipe() {
assert!(!is_broken_pipe_stderr_panic(""));
}
// The contract the Codex review demanded: the hook must NEVER hide a crash
// from Sentry. `handle_panic` must call `chain` for EVERY panic — including
// the broken-pipe family it used to swallow.
fn run_handle_panic_and_record_chain(panic_msg: &'static str) -> bool {
let chained = Arc::new(AtomicBool::new(false));
let chained_for_hook = Arc::clone(&chained);
// Trigger a panic to obtain a real PanicHookInfo, observe it in our hook.
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let flag = Arc::clone(&chained_for_hook);
handle_panic(info, &move |_| flag.store(true, Ordering::SeqCst));
}));
let _ = std::panic::catch_unwind(|| panic!("{panic_msg}"));
std::panic::set_hook(prev);
chained.load(Ordering::SeqCst)
}
// Combined into one test: each case installs the GLOBAL panic hook, so they
// must run serially (cargo parallelises separate #[test] fns).
#[test]
fn every_panic_class_chains_to_previous_hook() {
// The exact broken-pipe case that used to be swallowed must now reach
// Sentry (Codex review, PR #3772) — and so must an ordinary panic.
assert!(
run_handle_panic_and_record_chain(
"failed printing to stderr: The pipe is being closed. (os error 232)"
),
"broken-pipe stderr panic must still chain to the previous (Sentry) hook"
);
assert!(
run_handle_panic_and_record_chain("assertion `left == right` failed"),
"ordinary panic must chain to the previous (Sentry) hook"
);
}
}