Skip to content

Commit 3da679d

Browse files
# This is a combination of 8 commits.
# This is the 1st commit message: fix(core): Make HubSwitchGuard !Send to prevent thread corruption HubSwitchGuard manages thread-local hub state but was Send, allowing it to be moved to another thread. When dropped on the wrong thread, it would corrupt that thread's hub state instead of restoring the original thread. To fix this, add PhantomData<MutexGuard<'static, ()>> to make the guard !Send while keeping it Sync. This prevents the guard from being moved across threads at compile time. Additionally, refactor sentry-tracing to store guards in thread-local storage keyed by span ID instead of in span extensions. This fixes a related bug where multiple threads entering the same span would clobber each other's guards. Fixes #943 Refs RUST-130 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 108c51d commit 3da679d

File tree

6 files changed

+321
-31
lines changed

6 files changed

+321
-31
lines changed

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)