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:
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).
- 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.
- 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::drop → swap() → THREAD_HUB.with(...), and LocalKey::with panics with "cannot access a Thread Local Storage value during or after destruction".
- The panic invokes the hook installed by
sentry-panic's PanicIntegration, which accesses the same destroyed THREAD_HUB → second 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.rs — swap() 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.
DISCLAIMER: This issue has been investigated and written with the help of AI
Environment
sentry0.47.0,sentry-tracing0.47.0 (features:sentry-debug-images, panic integration enabled)aarch64-apple-ios), Rust library embedded in a native app via UniFFI (matrix-rust-sdk)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:
sentry-tracing0.47 introduced a thread-local guard store:SPAN_GUARDS: RefCell<SpanGuardStack>holdingHashMap<SpanId, Vec<HubSwitchGuard>>(sentry-tracing/src/layer/mod.rs).Enteredguards dropped on a different thread — the imbalance thedebug_assert_or_log!inon_exitmentions),HubSwitchGuards remain inSPAN_GUARDSat thread exit.sentry-core'sTHREAD_HUBis destroyed beforeSPAN_GUARDS, dropping the leftover guards runsSwitchGuard::drop→swap()→THREAD_HUB.with(...), andLocalKey::withpanics with "cannot access a Thread Local Storage value during or after destruction".sentry-panic'sPanicIntegration, which accesses the same destroyedTHREAD_HUB→ secondpanic_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)
Relevant code
sentry-core/src/hub_impl.rs—swap()useswith, which panics after TLS destruction;Dropcalls it unconditionally:sentry-tracing/src/layer/mod.rs— the guards live in athread_local!, so they are dropped by the TLS destructor machinery when a thread dies with unbalanced enter/exit: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.