Skip to main content

log_io/
context.rs

1//! Thread-local logging context.
2//!
3//! Context carries ambient key-value data attached to every record
4//! emitted on the current thread: stash a `trace_id` or `request_id`
5//! at the start of a request and every log line below it carries the
6//! value. [`with_field`] (and friends) push a value and return a
7//! [`ContextGuard`] that removes it when dropped.
8//!
9//! # Rules
10//!
11//! * **Shadowing.** When two active entries share a key, the most
12//!   recent one wins and the record carries the key once.
13//! * **Capacity.** A snapshot carries at most [`MAX_CONTEXT_SLOTS`]
14//!   fields (the most recent ones) and the thread keeps at most that
15//!   many entries; pushing beyond it evicts the oldest entry, whose
16//!   guard then becomes a no-op.
17//! * **Reentrancy.** Sinks and formats may log, push context, or drop
18//!   guards while a record is being dispatched. Changes made during
19//!   dispatch take effect when the outermost dispatch on the thread
20//!   finishes; a record emitted from inside a sink sees the context
21//!   that was active when the outer record started.
22//! * **Thread teardown.** Guards dropped while thread-local storage is
23//!   being destroyed do nothing instead of aborting the process.
24//!
25//! # Async tasks
26//!
27//! Context is per thread, and a task in a work-stealing runtime can
28//! resume on a different thread after every `.await`. That is why
29//! [`ContextGuard`] is neither `Send` nor `Sync`: a guard held across
30//! an `.await` makes the future `!Send`, so `tokio::spawn` and similar
31//! reject it at compile time instead of silently attaching one
32//! request's fields to another request's records.
33//!
34//! For tasks, capture the context as a [`ContextSnapshot`] (an owned,
35//! `Send + Sync`, cheaply clonable value) and re-install it where the
36//! work runs:
37//!
38//! ```
39//! use log_io::context;
40//!
41//! let _req = context::with_request_id("req-7");
42//! let snapshot = context::capture();
43//!
44//! std::thread::spawn(move || {
45//!     // Re-install on the worker thread for the duration of a closure.
46//!     snapshot.scope(|| {
47//!         assert_eq!(context::len(), 1);
48//!     });
49//! })
50//! .join()
51//! .unwrap();
52//! ```
53//!
54//! For futures, [`ContextSnapshot::wrap`] re-installs the snapshot
55//! around every `poll`, which works with any executor:
56//!
57//! ```ignore
58//! let snapshot = log_io::context::capture();
59//! tokio::spawn(snapshot.wrap(async move {
60//!     handle_request().await; // every record carries the captured fields
61//! }));
62//! ```
63//!
64//! `!Send` only helps where the executor requires `Send`. Futures that
65//! share one thread without that requirement (`join!` or `select!`
66//! inside a task, `LocalSet` / `spawn_local`, `block_on`) can each hold
67//! a guard across an `.await`, and an unwrapped future that does so
68//! leaves its fields on the thread while it is suspended: every future
69//! polled on that thread in the meantime logs them. Wrap such futures
70//! with [`ContextSnapshot::wrap`] (use `ContextSnapshot::default()` when
71//! there is nothing to capture). A wrapped future keeps the guards it
72//! holds across an `.await` with itself between polls, so its fields
73//! are visible exactly while it is being polled. The rule of thumb:
74//! never hold a [`ContextGuard`] across an `.await` in a future that is
75//! not wrapped.
76
77use core::cell::{Cell, RefCell};
78use core::future::Future;
79use core::marker::PhantomData;
80use core::pin::Pin;
81use core::task::{Context as TaskContext, Poll};
82use std::sync::Arc;
83
84use crate::record::{Field, same_output_key};
85use crate::value::{OwnedField, Value};
86
87/// Maximum number of context fields attached to one record, and the
88/// maximum number of live context entries per thread.
89pub const MAX_CONTEXT_SLOTS: usize = 16;
90
91enum Entry {
92    Field { id: u64, field: OwnedField },
93    Snapshot { id: u64, fields: Arc<[OwnedField]> },
94}
95
96impl Entry {
97    const fn id(&self) -> u64 {
98        match self {
99            Self::Field { id, .. } | Self::Snapshot { id, .. } => *id,
100        }
101    }
102}
103
104enum Pending {
105    Push(Entry),
106    Remove(u64),
107    Clear,
108}
109
110struct ThreadContext {
111    entries: RefCell<Vec<Entry>>,
112    /// Changes requested while `entries` was borrowed by a snapshot.
113    pending: RefCell<Vec<Pending>>,
114    has_pending: Cell<bool>,
115    next_id: Cell<u64>,
116}
117
118impl ThreadContext {
119    const fn new() -> Self {
120        Self {
121            entries: RefCell::new(Vec::new()),
122            pending: RefCell::new(Vec::new()),
123            has_pending: Cell::new(false),
124            next_id: Cell::new(1),
125        }
126    }
127
128    fn next_id(&self) -> u64 {
129        let id = self.next_id.get();
130        self.next_id.set(id.wrapping_add(1).max(1));
131        id
132    }
133
134    /// Apply `op` now if possible, otherwise queue it until the current
135    /// snapshot borrow ends.
136    fn apply_or_defer(&self, op: Pending) {
137        match self.entries.try_borrow_mut() {
138            Ok(mut entries) => apply(&mut entries, op),
139            Err(_) => {
140                if let Ok(mut pending) = self.pending.try_borrow_mut() {
141                    pending.push(op);
142                    self.has_pending.set(true);
143                }
144            }
145        }
146    }
147
148    fn apply_pending(&self) {
149        if !self.has_pending.get() {
150            return;
151        }
152        let Ok(mut entries) = self.entries.try_borrow_mut() else {
153            // An outer snapshot is still active; it applies them later.
154            return;
155        };
156        let Ok(mut pending) = self.pending.try_borrow_mut() else {
157            return;
158        };
159        for op in pending.drain(..) {
160            apply(&mut entries, op);
161        }
162        self.has_pending.set(false);
163    }
164}
165
166fn apply(entries: &mut Vec<Entry>, op: Pending) {
167    match op {
168        Pending::Push(entry) => {
169            if entries.len() >= MAX_CONTEXT_SLOTS {
170                drop(entries.remove(0));
171            }
172            entries.push(entry);
173        }
174        Pending::Remove(id) => {
175            if let Some(pos) = entries.iter().rposition(|e| e.id() == id) {
176                drop(entries.remove(pos));
177            }
178        }
179        Pending::Clear => entries.clear(),
180    }
181}
182
183thread_local! {
184    static CONTEXT: ThreadContext = const { ThreadContext::new() };
185}
186
187/// RAII guard that removes a context entry when dropped.
188///
189/// Returned by [`with_field`], [`with_trace_id`], [`with_request_id`]
190/// and [`ContextSnapshot::attach`]. Guards may be dropped in any order.
191///
192/// The guard is deliberately `!Send` and `!Sync`: context lives in the
193/// thread that created the guard, so the guard must be dropped there.
194/// Holding one across an `.await` makes the future `!Send`. Use
195/// [`ContextSnapshot`] to carry context into other threads or tasks.
196///
197/// ```compile_fail
198/// let guard = log_io::context::with_field("tenant", "a");
199/// std::thread::spawn(move || drop(guard)); // error: `ContextGuard` is not `Send`
200/// ```
201#[must_use = "context is removed when this guard is dropped; bind it to a variable"]
202#[derive(Debug)]
203pub struct ContextGuard {
204    /// `0` means the entry was never installed (thread-local storage
205    /// was unavailable), so dropping does nothing.
206    id: u64,
207    _not_send: PhantomData<*const ()>,
208}
209
210impl ContextGuard {
211    const fn new(id: u64) -> Self {
212        Self {
213            id,
214            _not_send: PhantomData,
215        }
216    }
217}
218
219impl Drop for ContextGuard {
220    fn drop(&mut self) {
221        if self.id == 0 {
222            return;
223        }
224        let id = self.id;
225        // `try_with`: during thread-local teardown there is nothing to
226        // remove, and panicking in a destructor there aborts.
227        let _tls_may_be_gone = CONTEXT.try_with(|ctx| ctx.apply_or_defer(Pending::Remove(id)));
228    }
229}
230
231fn push(make: impl FnOnce(u64) -> Entry) -> ContextGuard {
232    CONTEXT
233        .try_with(|ctx| {
234            let id = ctx.next_id();
235            ctx.apply_or_defer(Pending::Push(make(id)));
236            ContextGuard::new(id)
237        })
238        .unwrap_or(ContextGuard::new(0))
239}
240
241/// Push a key/value pair onto the current thread's context.
242///
243/// The value is copied into owned storage (lazy `Display` / `Debug` /
244/// `Error` values are rendered now), so `with_field` allocates; the
245/// per-record cost of attaching context does not.
246///
247/// # Examples
248///
249/// ```
250/// use log_io::context;
251///
252/// {
253///     let _user = context::with_field("user_id", 42_u64);
254///     assert_eq!(context::len(), 1);
255/// }
256/// assert_eq!(context::len(), 0);
257/// ```
258pub fn with_field<'a, V: Into<Value<'a>>>(key: &str, value: V) -> ContextGuard {
259    let field = OwnedField::new(key, value.into());
260    push(move |id| Entry::Field { id, field })
261}
262
263/// Push a `trace_id` field. See [`with_field`].
264pub fn with_trace_id(id: &str) -> ContextGuard {
265    with_field("trace_id", Value::Str(id))
266}
267
268/// Push a `request_id` field. See [`with_field`].
269pub fn with_request_id(id: &str) -> ContextGuard {
270    with_field("request_id", Value::Str(id))
271}
272
273/// Run `f` with the current thread's context as a borrowed slice of
274/// [`Field`]s, oldest first, with shadowed keys removed.
275///
276/// No allocation: the slice lives on the stack. Context changes made
277/// inside `f` (including from sinks) are applied when the outermost
278/// snapshot on this thread ends.
279///
280/// # Examples
281///
282/// ```
283/// use log_io::{context, Value};
284///
285/// let _a = context::with_field("user", "outer");
286/// let _b = context::with_field("user", "inner");
287/// context::with_snapshot(|fields| {
288///     assert_eq!(fields.len(), 1);
289///     assert_eq!(fields[0].value, Value::Str("inner"));
290/// });
291/// ```
292pub fn with_snapshot<R>(f: impl FnOnce(&[Field<'_>]) -> R) -> R {
293    // During thread-local teardown the context is gone: no fields.
294    // Probing first keeps `f` in this frame, and nothing between the
295    // probe and `with` can run this thread's TLS destructors, so `with`
296    // cannot hit its "destroyed" panic path.
297    if CONTEXT.try_with(|_| ()).is_err() {
298        return f(&[]);
299    }
300    CONTEXT.with(|ctx| {
301        let out = match ctx.entries.try_borrow() {
302            Ok(entries) => {
303                let mut buf = [EMPTY; MAX_CONTEXT_SLOTS];
304                let len = materialize(&entries, &mut buf);
305                f(&buf[..len])
306            }
307            // Only possible while a mutation is in progress, which never
308            // spans user code; treat as empty rather than panic.
309            Err(_) => f(&[]),
310        };
311        ctx.apply_pending();
312        out
313    })
314}
315
316const EMPTY: Field<'static> = Field {
317    key: "",
318    value: Value::Null,
319};
320
321/// Fill `buf` with the visible fields (newest per key, at most
322/// `MAX_CONTEXT_SLOTS`, the most recent ones), oldest first.
323fn materialize<'e>(entries: &'e [Entry], buf: &mut [Field<'e>; MAX_CONTEXT_SLOTS]) -> usize {
324    let mut len = 0;
325    'entries: for entry in entries.iter().rev() {
326        let fields: &[OwnedField] = match entry {
327            Entry::Field { field, .. } => core::slice::from_ref(field),
328            Entry::Snapshot { fields, .. } => fields,
329        };
330        for owned in fields.iter().rev() {
331            if len == MAX_CONTEXT_SLOTS {
332                break 'entries;
333            }
334            if buf[..len]
335                .iter()
336                .any(|f| same_output_key(f.key, &owned.key))
337            {
338                continue;
339            }
340            buf[len] = owned.as_field();
341            len += 1;
342        }
343    }
344    buf[..len].reverse();
345    len
346}
347
348/// Remove every context entry on the current thread. Existing guards
349/// become no-ops.
350///
351/// Intended for tests and for resetting pooled threads; normal code
352/// relies on [`ContextGuard`] drops.
353pub fn clear() {
354    let _tls_may_be_gone = CONTEXT.try_with(|ctx| ctx.apply_or_defer(Pending::Clear));
355}
356
357/// Number of context fields a record emitted now would carry (after
358/// shadowing and the [`MAX_CONTEXT_SLOTS`] cap).
359#[must_use]
360pub fn len() -> usize {
361    with_snapshot(|fields| fields.len())
362}
363
364/// Capture the current thread's context as an owned snapshot.
365///
366/// Allocates once (an `Arc`). Cloning the snapshot is a reference-count
367/// increment.
368///
369/// # Examples
370///
371/// ```
372/// use log_io::context;
373///
374/// let snapshot = {
375///     let _t = context::with_trace_id("abc");
376///     context::capture()
377/// };
378/// assert_eq!(context::len(), 0);
379/// let _restored = snapshot.attach();
380/// assert_eq!(context::len(), 1);
381/// ```
382#[must_use]
383pub fn capture() -> ContextSnapshot {
384    with_snapshot(ContextSnapshot::from_fields)
385}
386
387/// An owned, immutable copy of a set of context fields.
388///
389/// `Send + Sync` and cheap to clone, so it can be moved into threads,
390/// tasks, and closures. Install it on the current thread with
391/// [`ContextSnapshot::attach`], [`ContextSnapshot::scope`] or
392/// [`ContextSnapshot::wrap`]. An attached snapshot behaves like a
393/// group of [`with_field`] entries: fields pushed later shadow it, and
394/// it shadows fields pushed earlier.
395#[derive(Clone, Default)]
396pub struct ContextSnapshot {
397    fields: Option<Arc<[OwnedField]>>,
398}
399
400impl ContextSnapshot {
401    /// Build a snapshot from explicit fields, without touching the
402    /// thread's context. Later fields shadow earlier ones with the same
403    /// key.
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// use log_io::context::ContextSnapshot;
409    /// use log_io::Field;
410    ///
411    /// let snap = ContextSnapshot::from_fields(&[Field::new("tenant", "acme")]);
412    /// assert_eq!(snap.len(), 1);
413    /// ```
414    #[must_use]
415    pub fn from_fields(fields: &[Field<'_>]) -> Self {
416        if fields.is_empty() {
417            return Self::default();
418        }
419        let mut owned: Vec<OwnedField> = Vec::with_capacity(fields.len());
420        for field in fields {
421            if let Some(existing) = owned
422                .iter_mut()
423                .find(|o| same_output_key(&o.key, field.key))
424            {
425                *existing = OwnedField::new(field.key, field.value);
426            } else {
427                owned.push(OwnedField::new(field.key, field.value));
428            }
429        }
430        Self {
431            fields: Some(owned.into()),
432        }
433    }
434
435    /// Number of fields in the snapshot.
436    #[must_use]
437    pub fn len(&self) -> usize {
438        self.fields.as_deref().map_or(0, <[OwnedField]>::len)
439    }
440
441    /// `true` if the snapshot has no fields.
442    #[must_use]
443    pub fn is_empty(&self) -> bool {
444        self.len() == 0
445    }
446
447    /// Iterate over the snapshot's fields in insertion order.
448    pub fn iter(&self) -> impl Iterator<Item = Field<'_>> {
449        self.fields
450            .as_deref()
451            .unwrap_or(&[])
452            .iter()
453            .map(OwnedField::as_field)
454    }
455
456    /// Install the snapshot on the current thread until the returned
457    /// guard is dropped. Does not allocate.
458    pub fn attach(&self) -> ContextGuard {
459        match &self.fields {
460            Some(fields) => {
461                let fields = Arc::clone(fields);
462                push(move |id| Entry::Snapshot { id, fields })
463            }
464            None => ContextGuard::new(0),
465        }
466    }
467
468    /// Run `f` with the snapshot installed on the current thread.
469    pub fn scope<R>(&self, f: impl FnOnce() -> R) -> R {
470        let _guard = self.attach();
471        f()
472    }
473
474    /// Wrap a future so the snapshot is installed around every poll.
475    ///
476    /// Works with any executor, including work-stealing ones: whichever
477    /// thread polls the future sees the snapshot for exactly the
478    /// duration of the poll.
479    ///
480    /// Context the future itself pushes and holds across an `.await`
481    /// (a [`ContextGuard`] alive in the future's state) is taken off the
482    /// thread when the poll returns `Pending` and put back at the start
483    /// of the next poll, so other futures polled on the same thread do
484    /// not see it. Such a future is `!Send`, so it always resumes on the
485    /// thread that created the guard.
486    ///
487    /// The future is boxed once (one allocation per wrapped future) so
488    /// the wrapper can be `Unpin` without `unsafe` code; holding context
489    /// across an `.await` allocates once more, on first use.
490    ///
491    /// # Examples
492    ///
493    /// ```
494    /// use std::future::Future;
495    /// use std::pin::pin;
496    /// use std::task::{Context, Poll, Waker};
497    /// use log_io::context::{self, ContextSnapshot};
498    ///
499    /// /// Returns `Pending` once, like an `.await` on I/O.
500    /// struct YieldOnce(bool);
501    /// impl Future for YieldOnce {
502    ///     type Output = ();
503    ///     fn poll(mut self: std::pin::Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> {
504    ///         if self.0 { Poll::Ready(()) } else { self.0 = true; Poll::Pending }
505    ///     }
506    /// }
507    ///
508    /// let mut task = pin!(ContextSnapshot::default().wrap(async {
509    ///     let _tenant = context::with_field("tenant", "a");
510    ///     YieldOnce(false).await;
511    ///     assert_eq!(context::len(), 1); // back while this future runs
512    /// }));
513    /// let mut cx = Context::from_waker(Waker::noop());
514    /// assert!(task.as_mut().poll(&mut cx).is_pending());
515    /// assert_eq!(context::len(), 0); // not visible to anything else
516    /// assert!(task.as_mut().poll(&mut cx).is_ready());
517    /// ```
518    pub fn wrap<F: Future>(&self, future: F) -> WithContext<F> {
519        WithContext {
520            future: Box::pin(future),
521            snapshot: self.clone(),
522            held: Vec::new(),
523            held_ids: Vec::new(),
524        }
525    }
526}
527
528impl core::fmt::Debug for ContextSnapshot {
529    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
530        f.debug_map()
531            .entries(self.iter().map(|field| (field.key, field.value)))
532            .finish()
533    }
534}
535
536/// A future with a [`ContextSnapshot`] installed around every poll.
537/// Created by [`ContextSnapshot::wrap`].
538#[must_use = "futures do nothing unless polled"]
539pub struct WithContext<F> {
540    future: Pin<Box<F>>,
541    snapshot: ContextSnapshot,
542    /// Context entries the future pushed and still holds, kept here
543    /// while it is suspended.
544    held: Vec<Entry>,
545    /// Ids of `held` while they are installed during a poll.
546    held_ids: Vec<u64>,
547}
548
549impl<F: Future> Future for WithContext<F> {
550    type Output = F::Output;
551
552    fn poll(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
553        let this = &mut *self;
554        // Entries created on this thread from now on have ids >= this.
555        let first_new_id = CONTEXT.try_with(|ctx| ctx.next_id.get()).unwrap_or(0);
556        let snapshot_guard = this.snapshot.attach();
557        reinstall(&mut this.held, &mut this.held_ids);
558        let poll = this.future.as_mut().poll(cx);
559        if poll.is_pending() && first_new_id != 0 {
560            take_task_entries(
561                first_new_id,
562                snapshot_guard.id,
563                &this.held_ids,
564                &mut this.held,
565            );
566        }
567        drop(snapshot_guard);
568        poll
569    }
570}
571
572/// Push entries kept by a suspended [`WithContext`] back onto the
573/// thread, remembering their ids.
574fn reinstall(held: &mut Vec<Entry>, held_ids: &mut Vec<u64>) {
575    held_ids.clear();
576    if held.is_empty() {
577        return;
578    }
579    let _tls_may_be_gone = CONTEXT.try_with(|ctx| {
580        for entry in held.drain(..) {
581            held_ids.push(entry.id());
582            ctx.apply_or_defer(Pending::Push(entry));
583        }
584    });
585}
586
587/// Move the entries a future pushed during its poll (new ids other than
588/// its snapshot's, plus the ones reinstalled for it) off the thread.
589fn take_task_entries(
590    first_new_id: u64,
591    snapshot_id: u64,
592    reinstalled: &[u64],
593    held: &mut Vec<Entry>,
594) {
595    let _tls_may_be_gone = CONTEXT.try_with(|ctx| {
596        // Borrowed only while a snapshot is being read (a poll from
597        // inside a sink); leave the entries in place then.
598        let Ok(mut entries) = ctx.entries.try_borrow_mut() else {
599            return;
600        };
601        let mut index = 0;
602        while index < entries.len() {
603            let id = entries[index].id();
604            let owned_by_task =
605                (id >= first_new_id && id != snapshot_id) || reinstalled.contains(&id);
606            if owned_by_task {
607                held.push(entries.remove(index));
608            } else {
609                index += 1;
610            }
611        }
612    });
613}
614
615impl<F> core::fmt::Debug for WithContext<F> {
616    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
617        f.debug_struct("WithContext")
618            .field("snapshot", &self.snapshot)
619            .finish_non_exhaustive()
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    fn keys() -> Vec<String> {
628        with_snapshot(|f| f.iter().map(|f| f.key.to_owned()).collect())
629    }
630
631    #[test]
632    fn test_context_is_isolated_per_thread() {
633        clear();
634        let _g = with_field("k", 1_u64);
635        assert_eq!(len(), 1);
636        std::thread::spawn(|| assert_eq!(len(), 0)).join().unwrap();
637        clear();
638    }
639
640    #[test]
641    fn test_context_guard_removes_on_drop() {
642        clear();
643        {
644            let _g = with_field("k", 1_u64);
645            assert_eq!(len(), 1);
646        }
647        assert_eq!(len(), 0);
648    }
649
650    #[test]
651    fn test_context_nested_scopes_layer_and_unwind() {
652        clear();
653        let outer = with_field("outer", 1_u64);
654        {
655            let _inner = with_field("inner", 2_u64);
656            assert_eq!(keys(), ["outer", "inner"]);
657        }
658        assert_eq!(keys(), ["outer"]);
659        drop(outer);
660        assert_eq!(len(), 0);
661    }
662
663    #[test]
664    fn test_context_out_of_order_drop() {
665        clear();
666        let a = with_field("a", 1_u64);
667        let b = with_field("b", 2_u64);
668        drop(a);
669        assert_eq!(keys(), ["b"]);
670        drop(b);
671        assert_eq!(len(), 0);
672    }
673
674    #[test]
675    fn test_context_inner_scope_shadows_outer_key() {
676        // The audit's R06 duplicate-key half.
677        clear();
678        let _a = with_field("user", "outer");
679        let b = with_field("user", "inner");
680        with_snapshot(|f| {
681            assert_eq!(f.len(), 1);
682            assert_eq!(f[0].value, Value::Str("inner"));
683        });
684        drop(b);
685        with_snapshot(|f| assert_eq!(f[0].value, Value::Str("outer")));
686        clear();
687    }
688
689    #[test]
690    fn test_context_slot_overflow_evicts_oldest() {
691        clear();
692        let mut guards = Vec::new();
693        for i in 0..(MAX_CONTEXT_SLOTS + 5) {
694            guards.push(with_field(&format!("k{i}"), i as u64));
695        }
696        with_snapshot(|f| {
697            assert_eq!(f.len(), MAX_CONTEXT_SLOTS);
698            assert_eq!(f[0].key, "k5", "oldest entries are evicted");
699            assert_eq!(f[MAX_CONTEXT_SLOTS - 1].key, "k20");
700        });
701        drop(guards);
702        assert_eq!(len(), 0);
703    }
704
705    #[test]
706    fn test_context_trace_and_request_id_helpers() {
707        clear();
708        let _t = with_trace_id("abc");
709        let _r = with_request_id("xyz");
710        with_snapshot(|f| {
711            assert_eq!(f[0].key, "trace_id");
712            assert_eq!(f[0].value, Value::Str("abc"));
713            assert_eq!(f[1].key, "request_id");
714            assert_eq!(f[1].value, Value::Str("xyz"));
715        });
716        clear();
717    }
718
719    #[test]
720    fn test_context_with_field_inside_snapshot_is_deferred_not_panicking() {
721        // The audit's R02 reproduction, at the module level.
722        clear();
723        let guard = with_snapshot(|_| with_field("x", 1_u32));
724        assert_eq!(keys(), ["x"], "applied after the snapshot ended");
725        drop(guard);
726        assert_eq!(len(), 0);
727    }
728
729    #[test]
730    fn test_context_guard_dropped_inside_snapshot_is_removed_afterwards() {
731        // The audit's R12 reproduction, at the module level.
732        clear();
733        let guard = with_field("k", 1_u32);
734        with_snapshot(|f| {
735            assert_eq!(f.len(), 1);
736            drop(guard);
737        });
738        assert_eq!(len(), 0, "removal must not leak");
739    }
740
741    #[test]
742    fn test_context_nested_snapshots_see_outer_context() {
743        clear();
744        let _g = with_field("k", 1_u32);
745        with_snapshot(|outer| {
746            assert_eq!(outer.len(), 1);
747            with_snapshot(|inner| assert_eq!(inner.len(), 1));
748            let _late = with_field("late", 2_u32);
749        });
750        assert_eq!(keys(), ["k"], "the late guard was pushed and removed");
751        clear();
752    }
753
754    #[test]
755    fn test_context_clear_inside_snapshot_is_deferred() {
756        clear();
757        let _g = with_field("k", 1_u32);
758        with_snapshot(|_| clear());
759        assert_eq!(len(), 0);
760    }
761
762    #[test]
763    fn test_context_guard_drop_during_tls_teardown_does_not_abort() {
764        // The audit's LOG-008 reproduction: a guard stored in another
765        // thread-local that is destroyed after `CONTEXT`.
766        thread_local! {
767            static HOLD: RefCell<Option<ContextGuard>> = const { RefCell::new(None) };
768        }
769        for order in 0..2 {
770            std::thread::spawn(move || {
771                if order == 0 {
772                    HOLD.with(|h| drop(h.borrow_mut().take()));
773                    let g = with_field("k", 1_u32);
774                    HOLD.with(|h| *h.borrow_mut() = Some(g));
775                } else {
776                    let g = with_field("k", 1_u32);
777                    HOLD.with(|h| *h.borrow_mut() = Some(g));
778                }
779            })
780            .join()
781            .expect("thread must exit cleanly");
782        }
783    }
784
785    #[test]
786    fn test_snapshot_capture_attach_scope() {
787        clear();
788        let snapshot = {
789            let _t = with_trace_id("t-1");
790            let _u = with_field("user", "u-1");
791            capture()
792        };
793        assert_eq!(len(), 0);
794        assert_eq!(snapshot.len(), 2);
795        assert!(!snapshot.is_empty());
796        let seen = snapshot.scope(keys);
797        assert_eq!(seen, ["trace_id", "user"]);
798        assert_eq!(len(), 0);
799        let handle = {
800            let snapshot = snapshot.clone();
801            std::thread::spawn(move || snapshot.scope(keys))
802        };
803        assert_eq!(handle.join().unwrap(), ["trace_id", "user"]);
804        let debug = format!("{snapshot:?}");
805        assert!(debug.contains("trace_id"), "{debug}");
806    }
807
808    #[test]
809    fn test_snapshot_shadowing_with_thread_fields() {
810        clear();
811        let snapshot = ContextSnapshot::from_fields(&[
812            Field::new("tenant", "a"),
813            Field::new("tenant", "b"),
814            Field::new("region", "eu"),
815        ]);
816        assert_eq!(snapshot.len(), 2);
817        let _early = with_field("region", "us");
818        let _attached = snapshot.attach();
819        let _late = with_field("tenant", "c");
820        with_snapshot(|f| {
821            let pairs: Vec<(&str, Value<'_>)> = f.iter().map(|f| (f.key, f.value)).collect();
822            assert_eq!(
823                pairs,
824                [("region", Value::Str("eu")), ("tenant", Value::Str("c"))]
825            );
826        });
827        clear();
828    }
829
830    #[test]
831    fn test_empty_snapshot_attach_is_noop() {
832        clear();
833        let empty = capture();
834        assert!(empty.is_empty());
835        assert_eq!(empty.iter().count(), 0);
836        let _g = empty.attach();
837        assert_eq!(len(), 0);
838        assert_eq!(ContextSnapshot::from_fields(&[]).len(), 0);
839    }
840
841    #[test]
842    fn test_with_context_future_installs_snapshot_per_poll() {
843        use std::task::Waker;
844
845        /// Yields once, then reports the context length.
846        struct YieldThenLen(bool);
847        impl Future for YieldThenLen {
848            type Output = usize;
849            fn poll(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<usize> {
850                if self.0 {
851                    Poll::Ready(len())
852                } else {
853                    self.0 = true;
854                    cx.waker().wake_by_ref();
855                    Poll::Pending
856                }
857            }
858        }
859
860        clear();
861        let snapshot = ContextSnapshot::from_fields(&[Field::new("req", "r1")]);
862        let mut fut = snapshot.wrap(YieldThenLen(false));
863        assert!(format!("{fut:?}").starts_with("WithContext"));
864        let waker = Waker::noop().clone();
865        let mut cx = TaskContext::from_waker(&waker);
866        assert!(Pin::new(&mut fut).poll(&mut cx).is_pending());
867        assert_eq!(len(), 0, "nothing leaks between polls");
868        // Resume on a different thread, as a work-stealing executor might.
869        let out = std::thread::spawn(move || {
870            let waker = Waker::noop().clone();
871            let mut cx = TaskContext::from_waker(&waker);
872            Pin::new(&mut fut).poll(&mut cx)
873        })
874        .join()
875        .unwrap();
876        assert_eq!(out, Poll::Ready(1));
877    }
878
879    /// Returns `Pending` once.
880    struct YieldOnce(bool);
881    impl Future for YieldOnce {
882        type Output = ();
883        fn poll(mut self: Pin<&mut Self>, _: &mut TaskContext<'_>) -> Poll<()> {
884            if self.0 {
885                Poll::Ready(())
886            } else {
887                self.0 = true;
888                Poll::Pending
889            }
890        }
891    }
892
893    /// Poll `a` and `b` alternately on this thread until both finish, as
894    /// `join!` or a `LocalSet` does.
895    fn join_on_this_thread<'a>(
896        a: Pin<&'a mut dyn Future<Output = ()>>,
897        b: Pin<&'a mut dyn Future<Output = ()>>,
898    ) {
899        let mut cx = TaskContext::from_waker(std::task::Waker::noop());
900        let (mut a, mut b) = (Some(a), Some(b));
901        while a.is_some() || b.is_some() {
902            for slot in [&mut a, &mut b] {
903                if let Some(fut) = slot.as_mut() {
904                    if fut.as_mut().poll(&mut cx).is_ready() {
905                        *slot = None;
906                    }
907                }
908            }
909        }
910    }
911
912    #[test]
913    fn test_wrapped_future_keeps_held_context_from_sibling_futures() {
914        // LOG-R04: future A holds a guard across an `.await` while its
915        // sibling B is polled on the same thread.
916        clear();
917        let seen_by_b = std::rc::Rc::new(RefCell::new(Vec::new()));
918        let seen_by_a = std::rc::Rc::new(RefCell::new(Vec::new()));
919        let (log_b, log_a) = (seen_by_b.clone(), seen_by_a.clone());
920        let task = ContextSnapshot::from_fields(&[Field::new("req", "r1")]);
921        let a = core::pin::pin!(task.wrap(async move {
922            let _tenant = with_field("tenant", "A");
923            YieldOnce(false).await;
924            log_a.borrow_mut().push(keys());
925            {
926                let _nested = ContextSnapshot::from_fields(&[Field::new("n", 1_u8)]).attach();
927                YieldOnce(false).await;
928                log_a.borrow_mut().push(keys());
929            }
930            YieldOnce(false).await;
931            log_a.borrow_mut().push(keys());
932        }));
933        let b = core::pin::pin!(async move {
934            for _ in 0..4 {
935                log_b.borrow_mut().push(keys());
936                YieldOnce(false).await;
937            }
938        });
939        join_on_this_thread(a, b);
940        for seen in seen_by_b.borrow().iter() {
941            assert!(seen.is_empty(), "B saw A's context: {seen:?}");
942        }
943        assert_eq!(
944            *seen_by_a.borrow(),
945            [
946                vec!["req", "tenant"],
947                vec!["req", "tenant", "n"],
948                vec!["req", "tenant"],
949            ]
950        );
951        assert_eq!(len(), 0, "nothing is left on the thread");
952    }
953
954    #[test]
955    fn test_wrapped_future_dropped_while_suspended_leaves_nothing() {
956        clear();
957        let mut cx = TaskContext::from_waker(std::task::Waker::noop());
958        let mut task = Box::pin(ContextSnapshot::default().wrap(async {
959            let _held = with_field("held", 1_u8);
960            YieldOnce(false).await;
961        }));
962        assert!(task.as_mut().poll(&mut cx).is_pending());
963        assert_eq!(len(), 0);
964        drop(task);
965        assert_eq!(len(), 0);
966        let _outer = with_field("outer", 1_u8);
967        assert_eq!(keys(), ["outer"]);
968        clear();
969    }
970
971    #[test]
972    fn test_snapshot_is_send_and_sync() {
973        fn assert_send_sync<T: Send + Sync>() {}
974        assert_send_sync::<ContextSnapshot>();
975        assert_send_sync::<WithContext<core::future::Ready<()>>>();
976    }
977}