rust · tui

A panic must not take the terminal with it

Jun 15, 2026 · 12 min read

When a normal command-line program panics, you get a stack trace and your prompt back. When a TUI panics, you get neither. A terminal UI has, by the time anything interesting goes wrong, put the terminal into raw mode — echo off, line editing off — hidden the cursor, and possibly switched to the alternate screen. Those are all stateful changes to the terminal, and the terminal does not know or care that the process which made them just died. So the panic message prints into a screen that may vanish with the alt-screen buffer, the shell prompt comes back with echo still off, and the user is left typing reset into a terminal that doesn't show them typing it. The program didn't just crash; it salted the earth on the way out.

That failure mode is why panic handling in a TUI runtime is containment first, reporting second. The panic must be stopped inside the process, while the code that knows how to restore the terminal is still alive to run.

Here's how the inkrs-dioxus crate in inkrs — a Rust port of the Ink terminal UI library, running dioxus components against a terminal renderer — does exactly that: catch a component panic, turn it into a value (last_error: Option<String>), render a styled error panel instead of a frozen frame, and recover cleanly on the next tick. Every mechanism here has a test asserting it, and we'll end on the one that matters most.

One broken component is not a broken app

The premise a UI runtime has to accept is that user components will fail. Not "may" — will. A component is arbitrary user code invoked by the framework on every render, and the framework has no way to audit it. If one unwrap() in one status widget can kill the whole interface, the framework has made every component a single point of failure for every other component, which is exactly the coupling a component model exists to prevent.

Browser React learned this the slow way. React shipped in 2013; error boundaries — the componentDidCatch mechanism that lets a subtree fail without corrupting the tree around it — arrived with React 16 in 2017. Before that, an exception thrown during render could leave React's internal bookkeeping in an inconsistent state that produced cryptic errors on later renders, far from the component that actually failed. The lesson generalizes: a UI runtime that doesn't decide what a component failure means will have that decision made for it by whatever happens to be on the stack.

For a terminal runtime the stakes are strictly higher than in a browser. A browser tab that dies takes itself out; the tab is the sandbox. A TUI's sandbox is your shell session. So the equivalent of React's error boundary isn't a nicety to add in version 16: it's part of the minimum viable product.

What catch_unwind actually promises

Rust's tool for this is std::panic::catch_unwind. The contract is narrower than "try/catch for Rust", and the gaps are exactly where TUIs die.

A Rust panic, by default, unwinds: it walks back up the stack running destructors until it either exits the thread or hits a catch_unwind, which stops the unwind and hands you the panic payload as a Box<dyn Any + Send>. That payload is whatever was passed to panic!: for panic!("boom") a &'static str, for panic!("bad index {i}") a formatted String. Turning it back into text is a pair of downcasts, with a fallback for exotic payloads.

a panic must not take the terminal with it render stack runtime: Instance::tick catch_unwind boundary (panic hook armed) vdom rebuild user component panic!("boom") panic unwinds caught becomes a value last_error = Some("boom …") ERROR boom styled panel, still a valid frame terminal intact: raw mode off · cursor restored · next tick clean without it: shell left in raw mode, user types `reset` blind
A component panic unwinds up the render stack until a boundary catches it; the panic becomes a value in last_error, a styled error panel renders in place of a frozen frame, and the terminal is handed back — raw mode off, cursor restored — so the next tick renders cleanly.

What catch_unwind does not catch:

  • Aborts. If the panic escalates to abort() — a panic inside a destructor that's already running during a panic, for instance — there is no unwind to catch. The process dies.
  • Anything, if you compiled with panic = "abort". That Cargo profile setting replaces unwinding with an immediate abort, and every catch_unwind in the program becomes a no-op wrapper. A TUI runtime that relies on unwinding for terminal cleanup is making an assumption about its user's release profile, and it should say so out loud. inkrs's keep-the-app-running story is an unwinding-panics story; surviving panic = "abort" in any form needs the complementary mechanism: a panic hook, which runs even when there's no unwind to catch. Hold that thought; the hook is about to matter for a different reason.

So: catch_unwind stops the stack from collapsing, and the payload downcast gets you the message. That's the raw material. The interesting part is where inkrs has to put it.

The layer dioxus already provides — and why it isn't enough

inkrs doesn't drive components itself; it embeds dioxus_core's VirtualDom and lowers the resulting node tree to terminal output. And dioxus already did the first half of the work: it wraps every user component invocation in catch_unwind internally, and when a component panics it substitutes a synthetic CapturedPanic element into the tree. The app survives. Ship it?

Not quite. The doc comment on Vdom::last_error in inkrs-dioxus/src/lib.rs records what that gets you:

/// Dioxus already wraps every user component in `catch_unwind`
/// internally and surfaces a `CapturedPanic` element with a
/// not-very-useful `"Any { .. }"` Display.

Encountered panic: Any { .. } — that's the entire diagnostic. Dioxus caught the payload but its default Display doesn't downcast it, so the message the developer wrote into their panic! is sitting in a Box<dyn Any> that nothing reads. The failure is contained but illegible: the app keeps running and nobody can tell you why the middle pane just went blank.

And there's a structural problem underneath the cosmetic one. Because dioxus catches the panic inside its own render machinery, the unwind never reaches inkrs at all. You can't recover a payload from a catch_unwind you don't own. Whatever inkrs wraps around vdom.rebuild(...) will simply never see component panics — dioxus ate them two stack frames down.

A panic hook with a thread-local switch

The way out is the other panic API: std::panic::set_hook. The panic hook runs at the start of a panic, before unwinding begins, with access to the original payload and the source location — regardless of who eventually catches the unwind. Dioxus can swallow the payload all it likes; the hook already saw it.

The catch is that the hook is process-global, and a library has no business hijacking panic reporting for the whole program. inkrs threads that needle with a depth counter in a thread-local: the hook is installed once per process, but it only does anything while a Vdom operation is in flight on the current thread. This is ensure_panic_hook in lib.rs, verbatim apart from trimming:

fn ensure_panic_hook() {
    static INSTALL: Once = Once::new();
    INSTALL.call_once(|| {
        let previous = panic::take_hook();
        panic::set_hook(Box::new(move |info| {
            let capturing = PANIC_CAPTURE_DEPTH.with(|d| *d.borrow() > 0);
            if capturing {
                // Extract the panic payload as a String. Match the order
                // `std::panic` uses internally: try `&'static str`, then
                // `String`, then fall back to a debug placeholder.
                let payload = info.payload();
                let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
                    (*s).to_string()
                } else if let Some(s) = payload.downcast_ref::<String>() {
                    s.clone()
                } else {
                    "<non-string panic payload>".to_string()
                };
                let loc = info
                    .location()
                    .map(|l| format!(" at {}:{}:{}", l.file(), l.line(), l.column()))
                    .unwrap_or_default();
                CAPTURED_PANIC.with(|slot| {
                    *slot.borrow_mut() = Some(format!("{msg}{loc}"));
                });
            } else {
                previous(info);
            }
        }));
    });
}

There's the payload downcast from the previous section, in its natural habitat. Note that it lives in the hook, not after a catch_unwind, precisely because the hook is the only place inkrs is guaranteed to see the payload before dioxus's internal catch swallows it. The message gets the source location appended, and outside a capture region the hook delegates to whatever was installed before it, so a panic on some unrelated thread still prints normally.

The capture region itself is a small bracket around any code that drives the VirtualDom:

fn with_panic_capture<R>(f: impl FnOnce() -> R) -> (Option<R>, Option<String>) {
    ensure_panic_hook();
    PANIC_CAPTURE_DEPTH.with(|d| *d.borrow_mut() += 1);
    // Clear any leftover capture from a prior run on this thread.
    CAPTURED_PANIC.with(|slot| {
        *slot.borrow_mut() = None;
    });

    let result = panic::catch_unwind(AssertUnwindSafe(f)).ok();

    PANIC_CAPTURE_DEPTH.with(|d| *d.borrow_mut() -= 1);
    let captured = CAPTURED_PANIC.with(|slot| slot.borrow_mut().take());
    (result, captured)
}

Two layers of defense are visible here, and they catch different things. The hook records the message for panics dioxus catches internally (component bodies). The catch_unwind handles the panics dioxus does not catch — its effect runner and event dispatch run user closures bare, so a panicking use_effect would otherwise unwind straight through the bridge and out of the runtime. There's a test pinning exactly that: panic_in_use_effect_is_captured in tests/errors_port.rs asserts that a use_effect(|| panic!("effect-boom")) neither kills the test thread nor gets lost — last_error() reports the message. Event handlers get the same treatment: fire_event_on wraps dioxus's handle_event in with_panic_capture, because a panic in an onkey closure deserves the same fate as a panic in a component body.

Everything the machinery catches funnels into one field on the persistent VirtualDom wrapper:

pub struct Vdom {
    vdom: VirtualDom,
    builder: DomBuilder,
    /// Last panic message captured during a rebuild or pump cycle, if any.
    last_error: Option<String>,
}

A panic is now a value. Vdom::new and Vdom::tick run their rebuild-and-pump cycles inside with_panic_capture and store the result; the host can inspect last_error() like any other piece of state, log it, or render it. Which is the next problem — because something still has to appear on screen for the frame that failed.

The styled fallback

The naive options for the failed frame are a frozen stale frame (looks like a hang) or dioxus's Encountered panic: Any { .. } (looks like a bug in the framework, which, fairly). Upstream Ink renders a bordered red error panel instead, and inkrs-dioxus/src/error_boundary.rs ports that. Its module comment states the division of labor plainly — dioxus supplies the capture, this module supplies the styled fallback:

#[component]
pub fn ErrorOverviewBoundary(children: DxElement) -> DxElement {
    rsx! {
        ErrorBoundary {
            handle_error: |ctx: ErrorContext| {
                // Take the first captured error; fall back to a generic
                // message if the context is (unexpectedly) empty.
                let (message, stack) = ctx
                    .error()
                    .as_ref()
                    .map(split_message_stack)
                    .unwrap_or_else(|| ("Unknown error".to_string(), None));
                overview_rsx(message, stack)
            },
            {children}
        }
    }
}

overview_rsx builds a round-bordered red box with a white-on-red ERROR badge, the message, and — if split_message_stack extracted one from the error's Debug form — a dimmed list of chain/backtrace lines. A component blowing up now looks like a component blowing up, in a frame that is still a valid frame, in a terminal that is still a functioning terminal.

Scoping works the way a React user would expect, and it's tested: boundary_scopes_error_to_subtree (in error_boundary.rs's test module) renders a sibling text next to a boundary-wrapped panicking component and asserts the sibling still renders while the fallback appears. One broken component, not a broken app.

There's one dispatch decision that makes this composable, over in Vdom::snapshot. When a panic was captured, the bridge substitutes the captured message for the whole tree only if dioxus's implicit root boundary is the one holding the error. If a user-placed ErrorOverviewBoundary caught it first, the root boundary is clean and the tree renders as-is — the user's fallback panel appears in place, surrounded by the unaffected rest of the UI. Without that check, the global substitution would steamroll every carefully placed boundary in the tree.

Recovery: the next tick is innocent

Containing the panic and rendering a panel is the visible half. The subtle half is what happens on the next frame. Dioxus's error boundary holds its captured error until told otherwise — that's correct behavior for a boundary — but it means a component that panicked once would show the fallback forever, even after the condition that caused the panic is gone. The bridge's policy is different: an error belongs to the tick that produced it. Vdom::tick implements that policy in its first four lines:

pub fn tick(&mut self) -> InkElement {
    // If we were in an errored state, clear dioxus's `ErrorBoundary`
    // context first so the next render path actually invokes the
    // user component instead of returning the boundary's fallback.
    if self.last_error.is_some() {
        clear_dioxus_error(&mut self.vdom);
    }
    let (_, last_error) = with_panic_capture(|| {
        pump_to_quiescence(&mut self.vdom, &mut self.builder);
    });
    // A successful tick (no fresh panic) clears the stored error so a
    // recovered render reports `last_error() == None`.
    self.last_error = last_error;
    self.snapshot()
}

clear_dioxus_error reaches into the runtime's root error-boundary scope, calls ErrorContext::clear_errors on it, and marks the user scope dirty so the next pump actually re-invokes the component body instead of replaying the fallback. And because with_panic_capture returns None when nothing panicked, the assignment self.last_error = last_error doubles as the reset: a clean tick doesn't keep the old error, it overwrites it with the absence of one.

The proof is a test, subsequent_render_after_panic_recovers in tests/errors_port.rs, and it's the single most reassuring test in the crate:

let should_panic = Arc::new(AtomicBool::new(true));
let flag = should_panic.clone();

let mut vdom = Vdom::new(move || {
    if flag.load(Ordering::SeqCst) {
        panic!("boom");
    }
    rsx! { text { "recovered" } }
});

// First render: panic captured.
assert!(vdom.last_error().is_some());

// Flip the flag and tick again — the second render should be clean.
should_panic.store(false, Ordering::SeqCst);
vdom.mark_root_dirty();
let element = vdom.tick();

let rendered = inkrs_render::render_to_string(&element);
assert!(rendered.contains("recovered"));
assert!(vdom.last_error().is_none());

Same Vdom, same component closure, no restart: the first render panics and is captured; the second renders "recovered" and reports no error. The panic was an event, not a state. Even the pathological case is pinned down — boundary_fallback_can_itself_panic in tests/nested_error_boundaries.rs makes the fallback panic and asserts the bridge still comes back with a snapshot and a populated last_error instead of unwinding out of Vdom::new. The error handler's error handler works.

The ugliest frame

Add it up and a component panic in inkrs travels a long, deliberate road: the process-wide hook (armed only inside a capture region) records the payload as text; dioxus's internal catch_unwind — or the bridge's own, for effects and event handlers — stops the unwind; the message lands in last_error; the snapshot renders either the user's styled boundary panel or the substituted message; and the next tick clears the boundary and tries again, innocent until proven panicking. At no point did the stack unwind past the code that owns the terminal. The cursor comes back. Echo comes back. Nobody types reset blind.

None of this shows up in a demo GIF. Demos are made of the frames where everything worked. But the difference between a toy TUI framework and one you'd put in front of users is almost entirely in the frames where something didn't, because that's the frame where the framework either hands the terminal back or takes it down with the process. Judge a framework by its ugliest frame; it's the one your users will remember.


Thoughts? Find me on LinkedIn.