Skip to content

SwitchGuard::drop panics during TLS destruction (then double-panics in the panic hook), aborting the process #1237

Description

@Velin92

DISCLAIMER: This issue has been investigated and written with the help of AI

Environment

  • Crates: sentry 0.47.0, sentry-tracing 0.47.0 (features: sentry-debug-images, panic integration enabled)
  • Platform: iOS (aarch64-apple-ios), Rust library embedded in a native app via UniFFI (matrix-rust-sdk)
  • Runtime: tokio multi-threaded runtime (including blocking pool threads)
  • The relevant code paths are unchanged on current master (checked as of 0.48.4)

Summary

Since our dependency was bumped to 0.47.0 (matrix-rust-sdk#6332) we see fatal aborts in production iOS crash reports (~50 events / 20 users within two weeks). The chain is:

  1. sentry-tracing 0.47 introduced a thread-local guard store: SPAN_GUARDS: RefCell<SpanGuardStack> holding HashMap<SpanId, Vec<HubSwitchGuard>> (sentry-tracing/src/layer/mod.rs).
  2. If a thread exits while spans are still entered (e.g. tokio blocking-pool threads that time out and die, and/or Entered guards dropped on a different thread — the imbalance the debug_assert_or_log! in on_exit mentions), HubSwitchGuards remain in SPAN_GUARDS at thread exit.
  3. During thread exit, TLS destructors run in unspecified order. When sentry-core's THREAD_HUB is destroyed before SPAN_GUARDS, dropping the leftover guards runs SwitchGuard::dropswap()THREAD_HUB.with(...), and LocalKey::with panics with "cannot access a Thread Local Storage value during or after destruction".
  4. The panic invokes the hook installed by sentry-panic's PanicIntegration, which accesses the same destroyed THREAD_HUBsecond panic_access_error → panic-while-panicking → std::process::abort().

So a bookkeeping imbalance that should at worst leak memory instead aborts the whole process, and the panic hook guarantees the escalation.

Stack trace (symbolicated, from an iOS crash report)

std::sys::thread_local::guard::apple::enable::run_dtors (apple.rs:28)
std::sys::thread_local::destructors::list::run (list.rs:31)
std::sys::thread_local::native::lazy::destroy
hashbrown::raw::RawTable<T>::drop            <- SPAN_GUARDS map being destroyed
alloc::vec::Vec<T>::drop                     <- Vec<HubSwitchGuard> for a span
sentry_core::hub_impl::SwitchGuard::drop
std::thread::local::panic_access_error (local.rs:435)   <- THREAD_HUB already destroyed
core::panicking::panic_fmt (panicking.rs:80)
__rustc::rust_begin_unwind (panicking.rs:689)
std::panicking::panic_with_hook (panicking.rs:833)
alloc::boxed::Box<T>::call (boxed.rs:2220)
sentry_panic::PanicIntegration::setup::{{closure}}::{{closure}}
std::thread::local::LocalKey<T>::with
std::thread::local::panic_access_error (local.rs:435)   <- panic hook hits the same dead TLS
core::panicking::panic_fmt (panicking.rs:80)
std::panicking::panic_with_hook (panicking.rs)
std::process::abort (process.rs:2535)
std::sys::pal::unix::abort_internal (mod.rs:363)

Relevant code

sentry-core/src/hub_impl.rsswap() uses with, which panics after TLS destruction; Drop calls it unconditionally:

fn swap(&mut self) -> Option<Arc<Hub>> {
    if let Some((mut hub, was_process_hub)) = self.inner.take() {
        Some(THREAD_HUB.with(|(thread_hub, is_process_hub)| { /* ... */ }))
    } else {
        None
    }
}

impl Drop for SwitchGuard {
    fn drop(&mut self) {
        let _ = self.swap();
    }
}

sentry-tracing/src/layer/mod.rs — the guards live in a thread_local!, so they are dropped by the TLS destructor machinery when a thread dies with unbalanced enter/exit:

thread_local! {
    /// Hub switch guards keyed by span ID.
    ///
    /// Guard bookkeeping is thread-local by design. Correctness expects
    /// balanced enter/exit callbacks on the same thread.
    static SPAN_GUARDS: RefCell<SpanGuardStack> = RefCell::new(SpanGuardStack::new());
}

Why this is new in 0.47

Before 0.47, the hub switch guard was kept in the span's extensions (registry-owned, not TLS), so a dying thread never dropped guards from inside TLS destructors. 0.47's re-entrancy fix moved them into SPAN_GUARDS, creating the destructor-ordering hazard.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugSomething isn't workingRustSDK

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions