Skip to main content

fastapi_core/
context.rs

1//! Request context with asupersync integration.
2//!
3//! [`RequestContext`] wraps asupersync's [`Cx`] to provide request-scoped
4//! capabilities for HTTP request handling.
5
6use asupersync::types::CancelReason;
7use asupersync::{Budget, Cx, Outcome, RegionId, TaskId, Time};
8use std::sync::Arc;
9
10use crate::dependency::{CleanupStack, DependencyCache, DependencyOverrides, ResolutionStack};
11
12/// Default maximum body size: 1MB.
13pub const DEFAULT_MAX_BODY_SIZE: usize = 1024 * 1024;
14
15/// Configuration for request body limits.
16///
17/// This struct holds the body size limit configuration that applies to a request.
18/// It can be configured at the application level (via `AppConfig`) and optionally
19/// overridden on a per-route basis.
20#[derive(Debug, Clone, Copy)]
21pub struct BodyLimitConfig {
22    /// Maximum body size in bytes.
23    max_size: usize,
24}
25
26impl Default for BodyLimitConfig {
27    fn default() -> Self {
28        Self {
29            max_size: DEFAULT_MAX_BODY_SIZE,
30        }
31    }
32}
33
34impl BodyLimitConfig {
35    /// Creates a new body limit config with the specified maximum size.
36    #[must_use]
37    pub fn new(max_size: usize) -> Self {
38        Self { max_size }
39    }
40
41    /// Returns the maximum body size in bytes.
42    #[must_use]
43    pub fn max_size(self) -> usize {
44        self.max_size
45    }
46}
47
48/// Request context that wraps asupersync's capability context.
49///
50/// `RequestContext` provides access to:
51/// - Request-scoped identity (request ID, trace context)
52/// - Cancellation checkpoints for cancel-safe handlers
53/// - Budget/deadline awareness for timeout enforcement
54/// - Region-scoped spawning for background work
55/// - Body size limit configuration for DoS prevention
56///
57/// # Example
58///
59/// ```ignore
60/// async fn handler(ctx: &RequestContext) -> impl IntoResponse {
61///     // Check for client disconnect
62///     ctx.checkpoint()?;
63///
64///     // Get remaining time budget
65///     let remaining = ctx.remaining_budget();
66///
67///     // Check body size limit
68///     let max_body = ctx.body_limit().max_size();
69///
70///     // Do work...
71///     "Hello, World!"
72/// }
73/// ```
74#[derive(Debug, Clone)]
75pub struct RequestContext {
76    /// The underlying capability context.
77    cx: Cx,
78    /// Unique request identifier for tracing.
79    request_id: u64,
80    /// Request-scoped dependency cache.
81    dependency_cache: Arc<DependencyCache>,
82    /// Dependency overrides (primarily for testing).
83    dependency_overrides: Arc<DependencyOverrides>,
84    /// Stack tracking dependencies currently being resolved (for cycle detection).
85    resolution_stack: Arc<ResolutionStack>,
86    /// Cleanup functions to run after handler completion (LIFO order).
87    cleanup_stack: Arc<CleanupStack>,
88    /// Body size limit configuration for this request.
89    body_limit: BodyLimitConfig,
90    /// Absolute server deadline for this request, on the runtime clock.
91    ///
92    /// Set by the server from its configured request timeout. `None` means no
93    /// server deadline applies (e.g. WebSocket upgrades or tests that never
94    /// set one). Middleware with externally visible side effects (caches,
95    /// replay stores) should consult [`Self::deadline_exceeded`] before
96    /// publishing, because the server abandons the response once the deadline
97    /// passes.
98    deadline: Option<Time>,
99}
100
101impl RequestContext {
102    /// Creates a new request context from an asupersync Cx.
103    ///
104    /// This is typically called by the server when accepting a new request,
105    /// creating a new region for the request lifecycle. Uses the default
106    /// body size limit (1MB).
107    #[must_use]
108    pub fn new(cx: Cx, request_id: u64) -> Self {
109        Self {
110            cx,
111            request_id,
112            dependency_cache: Arc::new(DependencyCache::new()),
113            dependency_overrides: Arc::new(DependencyOverrides::new()),
114            resolution_stack: Arc::new(ResolutionStack::new()),
115            cleanup_stack: Arc::new(CleanupStack::new()),
116            body_limit: BodyLimitConfig::default(),
117            deadline: None,
118        }
119    }
120
121    /// Creates a new request context with a custom body size limit.
122    ///
123    /// Use this when the application has configured a specific `max_body_size`
124    /// in `AppConfig`, or when a route has an override.
125    #[must_use]
126    pub fn with_body_limit(cx: Cx, request_id: u64, max_body_size: usize) -> Self {
127        Self {
128            cx,
129            request_id,
130            dependency_cache: Arc::new(DependencyCache::new()),
131            dependency_overrides: Arc::new(DependencyOverrides::new()),
132            resolution_stack: Arc::new(ResolutionStack::new()),
133            cleanup_stack: Arc::new(CleanupStack::new()),
134            body_limit: BodyLimitConfig::new(max_body_size),
135            deadline: None,
136        }
137    }
138
139    /// Creates a new request context with shared dependency overrides.
140    #[must_use]
141    pub fn with_overrides(cx: Cx, request_id: u64, overrides: Arc<DependencyOverrides>) -> Self {
142        Self {
143            cx,
144            request_id,
145            dependency_cache: Arc::new(DependencyCache::new()),
146            dependency_overrides: overrides,
147            resolution_stack: Arc::new(ResolutionStack::new()),
148            cleanup_stack: Arc::new(CleanupStack::new()),
149            body_limit: BodyLimitConfig::default(),
150            deadline: None,
151        }
152    }
153
154    /// Creates a new request context with overrides and a custom body size limit.
155    #[must_use]
156    pub fn with_overrides_and_body_limit(
157        cx: Cx,
158        request_id: u64,
159        overrides: Arc<DependencyOverrides>,
160        max_body_size: usize,
161    ) -> Self {
162        Self {
163            cx,
164            request_id,
165            dependency_cache: Arc::new(DependencyCache::new()),
166            dependency_overrides: overrides,
167            resolution_stack: Arc::new(ResolutionStack::new()),
168            cleanup_stack: Arc::new(CleanupStack::new()),
169            body_limit: BodyLimitConfig::new(max_body_size),
170            deadline: None,
171        }
172    }
173
174    /// Sets the absolute server deadline for this request.
175    ///
176    /// The deadline is on the runtime clock (the same clock as
177    /// [`Cx::now`]). The server sets it from its configured request timeout
178    /// before invoking the middleware chain and handler, and abandons the
179    /// response (returning 504 to the client) once it passes.
180    #[must_use]
181    pub fn with_deadline(mut self, deadline: Time) -> Self {
182        self.deadline = Some(deadline);
183        self
184    }
185
186    /// Returns the absolute server deadline for this request, if one applies.
187    #[must_use]
188    pub fn deadline(&self) -> Option<Time> {
189        self.deadline
190    }
191
192    /// Returns true when the server deadline for this request has passed.
193    ///
194    /// Middleware with externally visible side effects (replay caches,
195    /// coalescers, stores) must check this before publishing a response: once
196    /// the deadline passes, the server no longer delivers the handler's
197    /// response to the client, so publishing it would let observers replay a
198    /// response the original caller never received.
199    ///
200    /// Returns false when no server deadline was set.
201    #[must_use]
202    pub fn deadline_exceeded(&self) -> bool {
203        self.deadline
204            .is_some_and(|deadline| self.cx.now() >= deadline)
205    }
206
207    /// Returns the unique request identifier.
208    ///
209    /// Useful for logging and tracing across the request lifecycle.
210    #[must_use]
211    pub fn request_id(&self) -> u64 {
212        self.request_id
213    }
214
215    /// Returns the dependency cache for this request.
216    #[must_use]
217    pub fn dependency_cache(&self) -> &DependencyCache {
218        &self.dependency_cache
219    }
220
221    /// Returns the dependency overrides registry.
222    #[must_use]
223    pub fn dependency_overrides(&self) -> &DependencyOverrides {
224        &self.dependency_overrides
225    }
226
227    /// Returns the resolution stack for cycle detection.
228    #[must_use]
229    pub fn resolution_stack(&self) -> &ResolutionStack {
230        &self.resolution_stack
231    }
232
233    /// Returns the cleanup stack for registering cleanup functions.
234    ///
235    /// Cleanup functions run after the handler completes in LIFO order.
236    #[must_use]
237    pub fn cleanup_stack(&self) -> &CleanupStack {
238        &self.cleanup_stack
239    }
240
241    /// Returns the body limit configuration for this request.
242    ///
243    /// This can be used by body extractors (e.g., `Json<T>`) to enforce
244    /// size limits and prevent DoS attacks.
245    #[must_use]
246    pub fn body_limit(&self) -> &BodyLimitConfig {
247        &self.body_limit
248    }
249
250    /// Returns the maximum body size in bytes for this request.
251    ///
252    /// This is a convenience method equivalent to `ctx.body_limit().max_size()`.
253    #[must_use]
254    pub fn max_body_size(&self) -> usize {
255        self.body_limit.max_size()
256    }
257
258    /// Returns the underlying region ID from asupersync.
259    ///
260    /// The region represents the request's lifecycle scope - all spawned
261    /// tasks belong to this region and will be cleaned up when the
262    /// request completes or is cancelled.
263    #[must_use]
264    pub fn region_id(&self) -> RegionId {
265        self.cx.region_id()
266    }
267
268    /// Returns the current task ID.
269    #[must_use]
270    pub fn task_id(&self) -> TaskId {
271        self.cx.task_id()
272    }
273
274    /// Returns the current budget.
275    ///
276    /// The budget represents the remaining computational resources (time, polls)
277    /// available for this request. When exhausted, the request should be
278    /// cancelled gracefully.
279    #[must_use]
280    pub fn budget(&self) -> Budget {
281        self.cx.budget()
282    }
283
284    /// Checks if cancellation has been requested.
285    ///
286    /// This includes client disconnection, timeout, or explicit cancellation.
287    /// Handlers should check this periodically and exit early if true.
288    #[must_use]
289    pub fn is_cancelled(&self) -> bool {
290        self.cx.is_cancel_requested()
291    }
292
293    /// Cooperative cancellation checkpoint.
294    ///
295    /// Call this at natural suspension points in your handler to allow
296    /// graceful cancellation. Returns `Err` if cancellation is pending.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the request has been cancelled and cancellation
301    /// is not currently masked.
302    ///
303    /// # Example
304    ///
305    /// ```ignore
306    /// async fn process_items(ctx: &RequestContext, items: Vec<Item>) -> Result<(), HttpError> {
307    ///     for item in items {
308    ///         ctx.checkpoint()?;  // Allow cancellation between items
309    ///         process_item(item).await?;
310    ///     }
311    ///     Ok(())
312    /// }
313    /// ```
314    pub fn checkpoint(&self) -> Result<(), CancelledError> {
315        self.cx.checkpoint().map_err(|_| CancelledError)
316    }
317
318    /// Executes a closure with cancellation masked.
319    ///
320    /// While masked, `checkpoint()` will not return an error even if
321    /// cancellation is pending. Use this for critical sections that
322    /// must complete atomically.
323    ///
324    /// # Example
325    ///
326    /// ```ignore
327    /// // Commit transaction - must not be interrupted
328    /// ctx.masked(|| {
329    ///     db.commit().await?;
330    ///     Ok(())
331    /// })
332    /// ```
333    pub fn masked<F, R>(&self, f: F) -> R
334    where
335        F: FnOnce() -> R,
336    {
337        self.cx.masked(f)
338    }
339
340    /// Records a trace event for this request.
341    ///
342    /// Events are associated with the request's trace context and can be
343    /// used for debugging and observability.
344    pub fn trace(&self, message: &str) {
345        self.cx.trace(message);
346    }
347
348    /// Returns a reference to the underlying asupersync Cx.
349    ///
350    /// Use this when you need direct access to asupersync primitives,
351    /// such as spawning tasks or using combinators.
352    #[must_use]
353    pub fn cx(&self) -> &Cx {
354        &self.cx
355    }
356}
357
358/// Error returned when a request has been cancelled.
359///
360/// This is returned by `checkpoint()` when the request should stop
361/// processing. The server will convert this to an appropriate HTTP
362/// response (typically 499 Client Closed Request or 504 Gateway Timeout).
363#[derive(Debug, Clone, Copy)]
364pub struct CancelledError;
365
366impl std::fmt::Display for CancelledError {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        write!(f, "request cancelled")
369    }
370}
371
372impl std::error::Error for CancelledError {}
373
374/// Lets handlers write `ctx.checkpoint()?` inside a `Result<_, HttpError>`:
375/// a cancelled request maps to `499 Client Closed Request`, matching how the
376/// server reports an `Outcome::Cancelled` with a generic reason.
377impl From<CancelledError> for crate::error::HttpError {
378    fn from(_: CancelledError) -> Self {
379        crate::error::HttpError::new(crate::response::StatusCode::CLIENT_CLOSED_REQUEST)
380            .with_detail("request cancelled")
381    }
382}
383
384impl crate::response::IntoResponse for CancelledError {
385    fn into_response(self) -> crate::response::Response {
386        crate::error::HttpError::from(self).into_response()
387    }
388}
389
390/// Extension trait for converting HTTP results to asupersync Outcome.
391///
392/// This bridges the HTTP error model with asupersync's 4-valued outcome
393/// (Ok, Err, Cancelled, Panicked).
394pub trait IntoOutcome<T, E> {
395    /// Converts this result into an asupersync Outcome.
396    fn into_outcome(self) -> Outcome<T, E>;
397}
398
399impl<T, E> IntoOutcome<T, E> for Result<T, E> {
400    fn into_outcome(self) -> Outcome<T, E> {
401        match self {
402            Ok(v) => Outcome::Ok(v),
403            Err(e) => Outcome::Err(e),
404        }
405    }
406}
407
408impl<T, E> IntoOutcome<T, E> for Result<T, CancelledError>
409where
410    E: Default,
411{
412    fn into_outcome(self) -> Outcome<T, E> {
413        match self {
414            Ok(v) => Outcome::Ok(v),
415            Err(CancelledError) => Outcome::Cancelled(CancelReason::user("request cancelled")),
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn cancelled_error_display() {
426        let err = CancelledError;
427        assert_eq!(format!("{err}"), "request cancelled");
428    }
429
430    #[test]
431    fn checkpoint_returns_error_when_cancel_requested() {
432        let cx = Cx::for_testing();
433        let ctx = RequestContext::new(cx, 1);
434        ctx.cx().set_cancel_requested(true);
435        assert!(ctx.checkpoint().is_err());
436    }
437
438    #[test]
439    fn deadline_defaults_to_none_and_is_never_exceeded() {
440        let ctx = RequestContext::new(Cx::for_testing(), 1);
441        assert_eq!(ctx.deadline(), None);
442        assert!(!ctx.deadline_exceeded());
443    }
444
445    #[test]
446    fn with_deadline_exposes_the_server_deadline() {
447        let deadline = Time::from_secs(5);
448        let ctx = RequestContext::new(Cx::for_testing(), 1).with_deadline(deadline);
449        assert_eq!(ctx.deadline(), Some(deadline));
450    }
451
452    #[test]
453    fn deadline_exceeded_reflects_the_runtime_clock() {
454        // Time::ZERO is always in the past on the runtime/wall clock.
455        let past = RequestContext::new(Cx::for_testing(), 1).with_deadline(Time::ZERO);
456        assert!(past.deadline_exceeded());
457
458        // A deadline far beyond any realistic clock value is never exceeded.
459        let future =
460            RequestContext::new(Cx::for_testing(), 1).with_deadline(Time::from_nanos(u64::MAX));
461        assert!(!future.deadline_exceeded());
462    }
463
464    #[test]
465    fn masked_defers_cancellation_at_checkpoint() {
466        let cx = Cx::for_testing();
467        let ctx = RequestContext::new(cx, 1);
468        ctx.cx().set_cancel_requested(true);
469
470        let result = ctx.masked(|| ctx.checkpoint());
471        assert!(result.is_ok());
472        assert!(ctx.checkpoint().is_err());
473    }
474
475    // ========================================================================
476    // Body Limit Tests
477    // ========================================================================
478
479    #[test]
480    fn body_limit_config_default() {
481        let config = BodyLimitConfig::default();
482        assert_eq!(config.max_size(), DEFAULT_MAX_BODY_SIZE);
483        assert_eq!(config.max_size(), 1024 * 1024); // 1MB
484    }
485
486    #[test]
487    fn body_limit_config_custom() {
488        let config = BodyLimitConfig::new(512 * 1024);
489        assert_eq!(config.max_size(), 512 * 1024); // 512KB
490    }
491
492    #[test]
493    fn request_context_default_body_limit() {
494        let cx = Cx::for_testing();
495        let ctx = RequestContext::new(cx, 1);
496        assert_eq!(ctx.max_body_size(), DEFAULT_MAX_BODY_SIZE);
497        assert_eq!(ctx.body_limit().max_size(), DEFAULT_MAX_BODY_SIZE);
498    }
499
500    #[test]
501    fn request_context_custom_body_limit() {
502        let cx = Cx::for_testing();
503        let ctx = RequestContext::with_body_limit(cx, 1, 2 * 1024 * 1024);
504        assert_eq!(ctx.max_body_size(), 2 * 1024 * 1024); // 2MB
505    }
506
507    #[test]
508    fn request_context_with_overrides_has_default_limit() {
509        let cx = Cx::for_testing();
510        let overrides = Arc::new(DependencyOverrides::new());
511        let ctx = RequestContext::with_overrides(cx, 1, overrides);
512        assert_eq!(ctx.max_body_size(), DEFAULT_MAX_BODY_SIZE);
513    }
514
515    #[test]
516    fn request_context_with_overrides_and_custom_limit() {
517        let cx = Cx::for_testing();
518        let overrides = Arc::new(DependencyOverrides::new());
519        let ctx = RequestContext::with_overrides_and_body_limit(cx, 1, overrides, 4 * 1024 * 1024);
520        assert_eq!(ctx.max_body_size(), 4 * 1024 * 1024); // 4MB
521    }
522
523    // ========================================================================
524    // bd-3st7: Request Isolation Tests
525    // ========================================================================
526
527    #[test]
528    #[allow(clippy::similar_names)]
529    fn request_id_isolation_unique_per_context() {
530        // Test that each RequestContext gets the request_id it was created with (bd-3st7)
531        let cx1 = Cx::for_testing();
532        let cx2 = Cx::for_testing();
533        let cx3 = Cx::for_testing();
534
535        let ctx1 = RequestContext::new(cx1, 100);
536        let ctx2 = RequestContext::new(cx2, 200);
537        let ctx3 = RequestContext::new(cx3, 300);
538
539        // Each context has its own request_id
540        assert_eq!(ctx1.request_id(), 100);
541        assert_eq!(ctx2.request_id(), 200);
542        assert_eq!(ctx3.request_id(), 300);
543
544        // Request IDs don't affect each other
545        assert_ne!(ctx1.request_id(), ctx2.request_id());
546        assert_ne!(ctx2.request_id(), ctx3.request_id());
547    }
548
549    #[test]
550    #[allow(clippy::similar_names)]
551    fn dependency_cache_isolation_per_request() {
552        // Test that each RequestContext has its own dependency cache (bd-3st7)
553        let cx1 = Cx::for_testing();
554        let cx2 = Cx::for_testing();
555
556        let ctx1 = RequestContext::new(cx1, 1);
557        let ctx2 = RequestContext::new(cx2, 2);
558
559        // Cache a value in ctx1's dependency cache
560        ctx1.dependency_cache().insert::<i32>(42);
561
562        // ctx2's cache should NOT have this value
563        let value1 = ctx1.dependency_cache().get::<i32>();
564        let value2 = ctx2.dependency_cache().get::<i32>();
565
566        assert!(value1.is_some(), "ctx1 should have cached value");
567        assert_eq!(value1.unwrap(), 42);
568        assert!(value2.is_none(), "ctx2 should NOT have ctx1's cached value");
569    }
570
571    #[test]
572    #[allow(clippy::similar_names)]
573    fn cleanup_stack_isolation_per_request() {
574        // Test that each RequestContext has its own cleanup stack (bd-3st7)
575        use std::sync::atomic::{AtomicUsize, Ordering};
576
577        let cleanup_counter1 = Arc::new(AtomicUsize::new(0));
578        let cleanup_counter2 = Arc::new(AtomicUsize::new(0));
579
580        let cx1 = Cx::for_testing();
581        let cx2 = Cx::for_testing();
582
583        let ctx1 = RequestContext::new(cx1, 1);
584        let ctx2 = RequestContext::new(cx2, 2);
585
586        // Register cleanup for ctx1
587        {
588            let counter = cleanup_counter1.clone();
589            ctx1.cleanup_stack().push(Box::new(move || {
590                Box::pin(async move {
591                    counter.fetch_add(1, Ordering::SeqCst);
592                })
593            }));
594        }
595
596        // Register cleanup for ctx2
597        {
598            let counter = cleanup_counter2.clone();
599            ctx2.cleanup_stack().push(Box::new(move || {
600                Box::pin(async move {
601                    counter.fetch_add(1, Ordering::SeqCst);
602                })
603            }));
604        }
605
606        // Run ctx1's cleanups
607        futures_executor::block_on(ctx1.cleanup_stack().run_cleanups());
608
609        // Only ctx1's cleanup should have run
610        assert_eq!(
611            cleanup_counter1.load(Ordering::SeqCst),
612            1,
613            "ctx1 cleanup should have run"
614        );
615        assert_eq!(
616            cleanup_counter2.load(Ordering::SeqCst),
617            0,
618            "ctx2 cleanup should NOT have run"
619        );
620
621        // Now run ctx2's cleanups
622        futures_executor::block_on(ctx2.cleanup_stack().run_cleanups());
623        assert_eq!(
624            cleanup_counter2.load(Ordering::SeqCst),
625            1,
626            "ctx2 cleanup should have run"
627        );
628    }
629
630    #[test]
631    #[allow(clippy::similar_names)]
632    fn cx_cancellation_isolation_per_request() {
633        // Test that cancelling one request's Cx doesn't affect others (bd-3st7)
634        let cx1 = Cx::for_testing();
635        let cx2 = Cx::for_testing();
636        let cx3 = Cx::for_testing();
637
638        let ctx1 = RequestContext::new(cx1, 1);
639        let ctx2 = RequestContext::new(cx2, 2);
640        let ctx3 = RequestContext::new(cx3, 3);
641
642        // Initially none are cancelled
643        assert!(ctx1.checkpoint().is_ok(), "ctx1 should not be cancelled");
644        assert!(ctx2.checkpoint().is_ok(), "ctx2 should not be cancelled");
645        assert!(ctx3.checkpoint().is_ok(), "ctx3 should not be cancelled");
646
647        // Cancel ctx2 only
648        ctx2.cx().set_cancel_requested(true);
649
650        // Only ctx2 should be cancelled
651        assert!(
652            ctx1.checkpoint().is_ok(),
653            "ctx1 should still not be cancelled"
654        );
655        assert!(ctx2.checkpoint().is_err(), "ctx2 should be cancelled");
656        assert!(
657            ctx3.checkpoint().is_ok(),
658            "ctx3 should still not be cancelled"
659        );
660    }
661
662    #[test]
663    #[allow(clippy::similar_names)]
664    fn body_limit_isolation_per_request() {
665        // Test that body limits are per-request (bd-3st7)
666        let cx1 = Cx::for_testing();
667        let cx2 = Cx::for_testing();
668
669        // Create contexts with different body limits
670        let ctx1 = RequestContext::with_body_limit(cx1, 1, 1024); // 1KB limit
671        let ctx2 = RequestContext::with_body_limit(cx2, 2, 1024 * 1024); // 1MB limit
672
673        // Each has its own limit
674        assert_eq!(ctx1.max_body_size(), 1024);
675        assert_eq!(ctx2.max_body_size(), 1024 * 1024);
676
677        // They don't affect each other
678        assert_ne!(ctx1.max_body_size(), ctx2.max_body_size());
679    }
680
681    #[test]
682    fn concurrent_requests_fully_isolated() {
683        // Simulate concurrent requests with different values and verify isolation (bd-3st7)
684        use std::thread;
685
686        const NUM_REQUESTS: usize = 100;
687        let results = Arc::new(parking_lot::Mutex::new(Vec::with_capacity(NUM_REQUESTS)));
688
689        let handles: Vec<_> = (0..NUM_REQUESTS)
690            .map(|i| {
691                let results = results.clone();
692                thread::spawn(move || {
693                    let cx = Cx::for_testing();
694                    let request_id = (i + 1) as u64 * 1000; // Unique ID per "request"
695                    let ctx = RequestContext::new(cx, request_id);
696
697                    // Cache a value unique to this request
698                    ctx.dependency_cache().insert::<u64>(request_id);
699
700                    // Verify we can retrieve our own value
701                    let cached = ctx.dependency_cache().get::<u64>();
702                    let retrieved = cached.unwrap_or(0);
703
704                    results.lock().push((request_id, retrieved));
705                })
706            })
707            .collect();
708
709        // Wait for all threads to complete
710        for handle in handles {
711            handle.join().expect("Thread panicked");
712        }
713
714        // Verify each request got exactly its own values
715        let results = results.lock();
716        assert_eq!(results.len(), NUM_REQUESTS);
717
718        for (request_id, retrieved) in results.iter() {
719            assert_eq!(
720                request_id, retrieved,
721                "Request {request_id} should retrieve its own cached value, not another request's"
722            );
723        }
724    }
725
726    #[test]
727    #[allow(clippy::similar_names)]
728    fn resolution_stack_isolation_per_request() {
729        // Test that resolution stacks are per-request for cycle detection (bd-3st7)
730        use crate::dependency::DependencyScope;
731
732        let cx1 = Cx::for_testing();
733        let cx2 = Cx::for_testing();
734
735        let ctx1 = RequestContext::new(cx1, 1);
736        let ctx2 = RequestContext::new(cx2, 2);
737
738        // Push i32 onto ctx1's resolution stack
739        ctx1.resolution_stack()
740            .push::<i32>("i32", DependencyScope::Request);
741
742        // ctx1 should detect the cycle when pushing i32 again
743        let cycle1 = ctx1.resolution_stack().check_cycle::<i32>("i32");
744        assert!(cycle1.is_some(), "ctx1 should detect cycle for i32");
745
746        // ctx2's resolution stack should be independent - no cycle
747        let cycle2 = ctx2.resolution_stack().check_cycle::<i32>("i32");
748        assert!(
749            cycle2.is_none(),
750            "ctx2 should NOT see ctx1's resolution stack"
751        );
752
753        // ctx2 can push i32 independently
754        ctx2.resolution_stack()
755            .push::<i32>("i32", DependencyScope::Request);
756        assert_eq!(ctx2.resolution_stack().depth(), 1);
757
758        // Both stacks are independent
759        assert_eq!(ctx1.resolution_stack().depth(), 1);
760        assert_eq!(ctx2.resolution_stack().depth(), 1);
761
762        // Clean up
763        ctx1.resolution_stack().pop();
764        ctx2.resolution_stack().pop();
765        assert!(ctx1.resolution_stack().is_empty());
766        assert!(ctx2.resolution_stack().is_empty());
767    }
768}