Skip to content

Commit d0f44d8

Browse files
fix!(core): Make HubSwitchGuard !Send to prevent thread corruption (#957)
## Description This PR does three important things, which are all interdependent, so I think it makes sense to do it all in a single PR. ### (1) Making `HubSwitchGuard` `!Send` This PR makes `HubSwitchGuard` `!Send` by adding `PhantomData<MutexGuard<'static, ()>>` while keeping it `Sync`. The type system now prevents the guard from being moved across threads at compile time. This change is important because `HubSwitchGuard` manages thread-local hub state, but was previously `Send`, allowing it to be moved to another thread. When dropped on the wrong thread, it could corrupt that thread's hub state instead of restoring the original thread. This change resolves #943. **Tests related to this change:** `!Send` is enforced by the compiler, so there are no additional tests here. ### (2) A new stack for spans to manage `HubSwitchGuards` As `HubSwitchGuard` is now `!Send`, we needed a new way to manage the `HubSwitchGuard` associated with a given span, in the `tracing` integration. Previously, we just had the guards live directly on the span via the `SentrySpanData` type, but this no longer works, since spans need to be `Send`. So, we now declare a thread-local mapping from span IDs to `HubSwitchGuard`s. The guards are stored in a stack, as each span will have one guard per span entry (spans can be entered multiple times, even on the same thread, and without the stack, we would restore the original hub too early). We drop the guards on span exit in LIFO order. **Tests related to this change:** [`span_reentrancy.rs`](https://github.com/getsentry/sentry-rust/pull/957/changes#diff-203b34b6e9f8f4bad9c8c43e05c781e119eea7b27648f257bfe7b35e912ba2b1) contains tests to validate correct span tree structure when re-entering the same span multiple times. A previous iteration of this PR only allowed a single guard per span; the test failed against this implementation because it produces an incorrect span structure (two transactions instead of one). The test only passes with the guard stack. ### (3) Forking the `Hub` on each span (re-)entry Change (2) is insufficient to make the [`span_reentrancy.rs`](https://github.com/getsentry/sentry-rust/pull/957/changes#diff-203b34b6e9f8f4bad9c8c43e05c781e119eea7b27648f257bfe7b35e912ba2b1) test pass because with only that change, there is still the fundamental problem that each span does not get its own `Hub` to manage state. Thus, in that test, I believe we were actually only ever using one hub, because there was no place we were forking the hub. So, on span exit, we prematurely [set the parent span on the hub](https://github.com/getsentry/sentry-rust/pull/957/changes#diff-5acb70e20dc764b608e1acf81b57fea59308624b7c2bc87906b310ff8b1f0eb2L372). Forking the hub ensures proper isolation, so the span gets set back at the right time (I also [suspect](https://github.com/getsentry/sentry-rust/pull/957/changes#r2773227449) we don't need to manually set it back, but I am unsure). This change resolves #946. **Tests related to this change:** [`span_cross_thread.rs`](https://github.com/getsentry/sentry-rust/pull/957/changes#diff-aa629e96442a0995ed2fd39dd68a18dd4be293732d2435b9aa5e03c848e12c38) ensures that entering the same span in multiple threads produces the correct span tree. Basically, it is a reproduction of #946. [`span_reentrancy.rs`](https://github.com/getsentry/sentry-rust/pull/957/changes#diff-203b34b6e9f8f4bad9c8c43e05c781e119eea7b27648f257bfe7b35e912ba2b1) also only passes with this change. ## Issues - Fixes #943 - Fixes [RUST-130](https://linear.app/getsentry/issue/RUST-130/hubswitchguard-should-not-be-send) - Fixes #946 - Fixes [RUST-132](https://linear.app/getsentry/issue/RUST-132/entering-the-same-span-several-times-causes-esoteric-behavior)
1 parent 108c51d commit d0f44d8

6 files changed

Lines changed: 321 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
- Added a `Envelope::into_items` method, which returns an iterator over owned [`EnvelopeItem`s](https://docs.rs/sentry/0.46.2/sentry/protocol/enum.EnvelopeItem.html) in the [`Envelope`](https://docs.rs/sentry/0.46.2/sentry/struct.Envelope.html) ([#983](https://github.com/getsentry/sentry-rust/pull/983)).
88
- Expose transport utilities ([#949](https://github.com/getsentry/sentry-rust/pull/949))
99

10+
### Fixes
11+
12+
- Fixed thread corruption bug where `HubSwitchGuard` could be dropped on wrong thread ([#957](https://github.com/getsentry/sentry-rust/pull/957))
13+
- **Breaking change**: `sentry_core::HubSwitchGuard` is now `!Send`, preventing it from being moved across threads. Code that previously sent the guard to another thread will no longer compile.
14+
1015
## 0.46.2
1116

1217
### New Features

sentry-core/src/hub_impl.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::cell::{Cell, UnsafeCell};
2-
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
2+
use std::marker::PhantomData;
3+
use std::sync::{Arc, LazyLock, MutexGuard, PoisonError, RwLock};
34
use std::thread;
45

56
use crate::Scope;
@@ -19,10 +20,14 @@ thread_local! {
1920
);
2021
}
2122

22-
/// A Hub switch guard used to temporarily swap
23-
/// active hub in thread local storage.
23+
/// A guard that temporarily swaps the active hub in thread-local storage.
24+
///
25+
/// This type is `!Send` because it manages thread-local state and must be
26+
/// dropped on the same thread where it was created.
2427
pub struct SwitchGuard {
2528
inner: Option<(Arc<Hub>, bool)>,
29+
/// Makes this type `!Send` while keeping it `Sync`.
30+
_not_send: PhantomData<MutexGuard<'static, ()>>,
2631
}
2732

2833
impl SwitchGuard {
@@ -41,7 +46,10 @@ impl SwitchGuard {
4146
let was_process_hub = is_process_hub.replace(false);
4247
Some((hub, was_process_hub))
4348
});
44-
SwitchGuard { inner }
49+
SwitchGuard {
50+
inner,
51+
_not_send: PhantomData,
52+
}
4553
}
4654

4755
fn swap(&mut self) -> Option<Arc<Hub>> {
Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::sync::Arc;
55

66
use bitflags::bitflags;
77
use sentry_core::protocol::Value;
8-
use sentry_core::{Breadcrumb, TransactionOrSpan};
8+
use sentry_core::{Breadcrumb, Hub, HubSwitchGuard, TransactionOrSpan};
99
use tracing_core::field::Visit;
1010
use tracing_core::{span, Event, Field, Level, Metadata, Subscriber};
1111
use tracing_subscriber::layer::{Context, Layer};
@@ -16,6 +16,9 @@ use crate::SENTRY_NAME_FIELD;
1616
use crate::SENTRY_OP_FIELD;
1717
use crate::SENTRY_TRACE_FIELD;
1818
use crate::TAGS_PREFIX;
19+
use span_guard_stack::SpanGuardStack;
20+
21+
mod span_guard_stack;
1922

2023
bitflags! {
2124
/// The action that Sentry should perform for a given [`Event`]
@@ -234,9 +237,7 @@ fn record_fields<'a, K: AsRef<str> + Into<Cow<'a, str>>>(
234237
/// the *current* span.
235238
pub(super) struct SentrySpanData {
236239
pub(super) sentry_span: TransactionOrSpan,
237-
parent_sentry_span: Option<TransactionOrSpan>,
238240
hub: Arc<sentry_core::Hub>,
239-
hub_switch_guard: Option<sentry_core::HubSwitchGuard>,
240241
}
241242

242243
impl<S> Layer<S> for SentryLayer<S>
@@ -334,12 +335,7 @@ where
334335
set_default_attributes(&mut sentry_span, span.metadata());
335336

336337
let mut extensions = span.extensions_mut();
337-
extensions.insert(SentrySpanData {
338-
sentry_span,
339-
parent_sentry_span,
340-
hub,
341-
hub_switch_guard: None,
342-
});
338+
extensions.insert(SentrySpanData { sentry_span, hub });
343339
}
344340

345341
/// Sets entered span as *current* sentry span. A tracing span can be
@@ -350,34 +346,47 @@ where
350346
None => return,
351347
};
352348

353-
let mut extensions = span.extensions_mut();
354-
if let Some(data) = extensions.get_mut::<SentrySpanData>() {
355-
data.hub_switch_guard = Some(sentry_core::HubSwitchGuard::new(data.hub.clone()));
356-
data.hub.configure_scope(|scope| {
349+
let extensions = span.extensions();
350+
if let Some(data) = extensions.get::<SentrySpanData>() {
351+
// We fork the hub (based on the hub associated with the span)
352+
// upon entering the span. This prevents data leakage if the span
353+
// is entered and exited multiple times.
354+
//
355+
// Further, Hubs are meant to manage thread-local state, even
356+
// though they can be shared across threads. As the span may being
357+
// entered on a different thread than where it was created, we need
358+
// to use a new hub to avoid altering state on the original thread.
359+
let hub = Arc::new(Hub::new_from_top(&data.hub));
360+
361+
hub.configure_scope(|scope| {
357362
scope.set_span(Some(data.sentry_span.clone()));
358-
})
359-
}
360-
}
363+
});
361364

362-
/// Set exited span's parent as *current* sentry span.
363-
fn on_exit(&self, id: &span::Id, ctx: Context<'_, S>) {
364-
let span = match ctx.span(id) {
365-
Some(span) => span,
366-
None => return,
367-
};
365+
let guard = HubSwitchGuard::new(hub);
368366

369-
let mut extensions = span.extensions_mut();
370-
if let Some(data) = extensions.get_mut::<SentrySpanData>() {
371-
data.hub.configure_scope(|scope| {
372-
scope.set_span(data.parent_sentry_span.clone());
367+
SPAN_GUARDS.with(|guards| {
368+
guards.borrow_mut().push(id.clone(), guard);
373369
});
374-
data.hub_switch_guard.take();
375370
}
376371
}
377372

373+
/// Drop the current span's [`HubSwitchGuard`] to restore the parent [`Hub`].
374+
fn on_exit(&self, id: &span::Id, _: Context<'_, S>) {
375+
SPAN_GUARDS.with(|guards| guards.borrow_mut().pop(id.clone()));
376+
}
377+
378378
/// When a span gets closed, finish the underlying sentry span, and set back
379379
/// its parent as the *current* sentry span.
380380
fn on_close(&self, id: span::Id, ctx: Context<'_, S>) {
381+
// Ensure all remaining Hub guards are dropped, to restore the original
382+
// Hub.
383+
//
384+
// By this point, the span probably should be fully executed, but we should
385+
// still ensure the guard is dropped in case this expectation is violated.
386+
SPAN_GUARDS.with(|guards| {
387+
guards.borrow_mut().remove(&id);
388+
});
389+
381390
let span = match ctx.span(&id) {
382391
Some(span) => span,
383392
None => return,
@@ -503,6 +512,9 @@ fn extract_span_data(
503512

504513
thread_local! {
505514
static VISITOR_BUFFER: RefCell<String> = const { RefCell::new(String::new()) };
515+
/// Hub switch guards keyed by span ID. Stored in thread-local so guards are
516+
/// always dropped on the same thread where they were created.
517+
static SPAN_GUARDS: RefCell<SpanGuardStack> = RefCell::new(SpanGuardStack::new());
506518
}
507519

508520
/// Records all span fields into a `BTreeMap`, reusing a mutable `String` as buffer.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//! This module contains code for stack-like storage for `HubSwitchGuard`s keyed
2+
//! by tracing span ID.
3+
4+
use std::collections::hash_map::Entry;
5+
use std::collections::HashMap;
6+
7+
use sentry_core::HubSwitchGuard;
8+
use tracing_core::span::Id as SpanId;
9+
10+
/// Holds per-span stacks of `HubSwitchGuard`s to handle span re-entrancy.
11+
///
12+
/// Each time a span is entered, we should push a new guard onto the stack.
13+
/// When the span exits, we should pop the guard from the stack.
14+
pub(super) struct SpanGuardStack {
15+
/// The map of span IDs to their respective guard stacks.
16+
guards: HashMap<SpanId, Vec<HubSwitchGuard>>,
17+
}
18+
19+
impl SpanGuardStack {
20+
/// Creates an empty guard stack map.
21+
pub(super) fn new() -> Self {
22+
Self {
23+
guards: HashMap::new(),
24+
}
25+
}
26+
27+
/// Pushes a guard for the given span ID, creating the stack if needed.
28+
pub(super) fn push(&mut self, id: SpanId, guard: HubSwitchGuard) {
29+
self.guards.entry(id).or_default().push(guard);
30+
}
31+
32+
/// Pops the most recent guard for the span ID, removing the stack when empty.
33+
pub(super) fn pop(&mut self, id: SpanId) -> Option<HubSwitchGuard> {
34+
match self.guards.entry(id) {
35+
Entry::Occupied(mut entry) => {
36+
let stack = entry.get_mut();
37+
let guard = stack.pop();
38+
if stack.is_empty() {
39+
entry.remove();
40+
}
41+
guard
42+
}
43+
Entry::Vacant(_) => None,
44+
}
45+
}
46+
47+
/// Removes all guards for the span ID without returning them.
48+
///
49+
/// This function guarantees that the guards are dropped in LIFO order.
50+
/// That way, the hub which was active when the span was first entered
51+
/// will be the one active after this function returns.
52+
///
53+
/// Typically, remove should only get called once the span is fully
54+
/// exited, so this removal order guarantee is mostly just defensive.
55+
pub(super) fn remove(&mut self, id: &SpanId) {
56+
self.guards
57+
.remove(id)
58+
.into_iter()
59+
.flatten()
60+
.rev() // <- we drop in reverse order
61+
.for_each(drop);
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::SpanGuardStack;
68+
use sentry_core::{Hub, HubSwitchGuard};
69+
use std::sync::Arc;
70+
use tracing_core::span::Id as SpanId;
71+
72+
#[test]
73+
fn pop_is_lifo() {
74+
let initial = Hub::current();
75+
let hub_a = Arc::new(Hub::new_from_top(initial.clone()));
76+
let hub_b = Arc::new(Hub::new_from_top(hub_a.clone()));
77+
78+
let mut stack = SpanGuardStack::new();
79+
let id = SpanId::from_u64(1);
80+
81+
stack.push(id.clone(), HubSwitchGuard::new(hub_a.clone()));
82+
assert!(Arc::ptr_eq(&Hub::current(), &hub_a));
83+
84+
stack.push(id.clone(), HubSwitchGuard::new(hub_b.clone()));
85+
assert!(Arc::ptr_eq(&Hub::current(), &hub_b));
86+
87+
drop(stack.pop(id.clone()).expect("guard for hub_b"));
88+
assert!(Arc::ptr_eq(&Hub::current(), &hub_a));
89+
90+
drop(stack.pop(id.clone()).expect("guard for hub_a"));
91+
assert!(Arc::ptr_eq(&Hub::current(), &initial));
92+
93+
assert!(stack.pop(id).is_none());
94+
}
95+
96+
#[test]
97+
fn remove_drops_all_guards_in_lifo_order() {
98+
let initial = Hub::current();
99+
let hub_a = Arc::new(Hub::new_from_top(initial.clone()));
100+
let hub_b = Arc::new(Hub::new_from_top(hub_a.clone()));
101+
102+
assert!(!Arc::ptr_eq(&hub_b, &initial));
103+
assert!(!Arc::ptr_eq(&hub_a, &initial));
104+
assert!(!Arc::ptr_eq(&hub_a, &hub_b));
105+
106+
let mut stack = SpanGuardStack::new();
107+
let id = SpanId::from_u64(2);
108+
109+
stack.push(id.clone(), HubSwitchGuard::new(hub_a.clone()));
110+
assert!(Arc::ptr_eq(&Hub::current(), &hub_a));
111+
112+
stack.push(id.clone(), HubSwitchGuard::new(hub_b.clone()));
113+
assert!(Arc::ptr_eq(&Hub::current(), &hub_b));
114+
115+
stack.remove(&id);
116+
assert!(Arc::ptr_eq(&Hub::current(), &initial));
117+
}
118+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
mod shared;
2+
3+
use sentry::protocol::Context;
4+
use std::thread;
5+
use std::time::Duration;
6+
7+
#[test]
8+
fn cross_thread_span_entries_share_transaction() {
9+
let transport = shared::init_sentry(1.0);
10+
11+
let span = tracing::info_span!("foo");
12+
let span2 = span.clone();
13+
14+
let handle1 = thread::spawn(move || {
15+
let _guard = span.enter();
16+
let _bar_span = tracing::info_span!("bar").entered();
17+
thread::sleep(Duration::from_millis(100));
18+
});
19+
20+
let handle2 = thread::spawn(move || {
21+
thread::sleep(Duration::from_millis(10));
22+
let _guard = span2.enter();
23+
let _baz_span = tracing::info_span!("baz").entered();
24+
thread::sleep(Duration::from_millis(50));
25+
});
26+
27+
handle1.join().unwrap();
28+
handle2.join().unwrap();
29+
30+
let data = transport.fetch_and_clear_envelopes();
31+
let transactions: Vec<_> = data
32+
.into_iter()
33+
.flat_map(|envelope| {
34+
envelope
35+
.items()
36+
.filter_map(|item| match item {
37+
sentry::protocol::EnvelopeItem::Transaction(transaction) => {
38+
Some(transaction.clone())
39+
}
40+
_ => None,
41+
})
42+
.collect::<Vec<_>>()
43+
})
44+
.collect();
45+
46+
assert_eq!(
47+
transactions.len(),
48+
1,
49+
"expected a single transaction for cross-thread span entries"
50+
);
51+
52+
let transaction = &transactions[0];
53+
assert_eq!(transaction.name.as_deref(), Some("foo"));
54+
55+
let trace = match transaction
56+
.contexts
57+
.get("trace")
58+
.expect("transaction should include trace context")
59+
{
60+
Context::Trace(trace) => trace,
61+
unexpected => panic!("expected trace context but got {unexpected:?}"),
62+
};
63+
64+
let bar_span = transaction
65+
.spans
66+
.iter()
67+
.find(|span| span.description.as_deref() == Some("bar"))
68+
.expect("expected span \"bar\" to be recorded in the transaction");
69+
let baz_span = transaction
70+
.spans
71+
.iter()
72+
.find(|span| span.description.as_deref() == Some("baz"))
73+
.expect("expected span \"baz\" to be recorded in the transaction");
74+
75+
assert_eq!(bar_span.parent_span_id, Some(trace.span_id));
76+
assert_eq!(baz_span.parent_span_id, Some(trace.span_id));
77+
}

0 commit comments

Comments
 (0)