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/// Extension trait for converting HTTP results to asupersync Outcome.
375///
376/// This bridges the HTTP error model with asupersync's 4-valued outcome
377/// (Ok, Err, Cancelled, Panicked).
378pub trait IntoOutcome<T, E> {
379    /// Converts this result into an asupersync Outcome.
380    fn into_outcome(self) -> Outcome<T, E>;
381}
382
383impl<T, E> IntoOutcome<T, E> for Result<T, E> {
384    fn into_outcome(self) -> Outcome<T, E> {
385        match self {
386            Ok(v) => Outcome::Ok(v),
387            Err(e) => Outcome::Err(e),
388        }
389    }
390}
391
392impl<T, E> IntoOutcome<T, E> for Result<T, CancelledError>
393where
394    E: Default,
395{
396    fn into_outcome(self) -> Outcome<T, E> {
397        match self {
398            Ok(v) => Outcome::Ok(v),
399            Err(CancelledError) => Outcome::Cancelled(CancelReason::user("request cancelled")),
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn cancelled_error_display() {
410        let err = CancelledError;
411        assert_eq!(format!("{err}"), "request cancelled");
412    }
413
414    #[test]
415    fn checkpoint_returns_error_when_cancel_requested() {
416        let cx = Cx::for_testing();
417        let ctx = RequestContext::new(cx, 1);
418        ctx.cx().set_cancel_requested(true);
419        assert!(ctx.checkpoint().is_err());
420    }
421
422    #[test]
423    fn deadline_defaults_to_none_and_is_never_exceeded() {
424        let ctx = RequestContext::new(Cx::for_testing(), 1);
425        assert_eq!(ctx.deadline(), None);
426        assert!(!ctx.deadline_exceeded());
427    }
428
429    #[test]
430    fn with_deadline_exposes_the_server_deadline() {
431        let deadline = Time::from_secs(5);
432        let ctx = RequestContext::new(Cx::for_testing(), 1).with_deadline(deadline);
433        assert_eq!(ctx.deadline(), Some(deadline));
434    }
435
436    #[test]
437    fn deadline_exceeded_reflects_the_runtime_clock() {
438        // Time::ZERO is always in the past on the runtime/wall clock.
439        let past = RequestContext::new(Cx::for_testing(), 1).with_deadline(Time::ZERO);
440        assert!(past.deadline_exceeded());
441
442        // A deadline far beyond any realistic clock value is never exceeded.
443        let future =
444            RequestContext::new(Cx::for_testing(), 1).with_deadline(Time::from_nanos(u64::MAX));
445        assert!(!future.deadline_exceeded());
446    }
447
448    #[test]
449    fn masked_defers_cancellation_at_checkpoint() {
450        let cx = Cx::for_testing();
451        let ctx = RequestContext::new(cx, 1);
452        ctx.cx().set_cancel_requested(true);
453
454        let result = ctx.masked(|| ctx.checkpoint());
455        assert!(result.is_ok());
456        assert!(ctx.checkpoint().is_err());
457    }
458
459    // ========================================================================
460    // Body Limit Tests
461    // ========================================================================
462
463    #[test]
464    fn body_limit_config_default() {
465        let config = BodyLimitConfig::default();
466        assert_eq!(config.max_size(), DEFAULT_MAX_BODY_SIZE);
467        assert_eq!(config.max_size(), 1024 * 1024); // 1MB
468    }
469
470    #[test]
471    fn body_limit_config_custom() {
472        let config = BodyLimitConfig::new(512 * 1024);
473        assert_eq!(config.max_size(), 512 * 1024); // 512KB
474    }
475
476    #[test]
477    fn request_context_default_body_limit() {
478        let cx = Cx::for_testing();
479        let ctx = RequestContext::new(cx, 1);
480        assert_eq!(ctx.max_body_size(), DEFAULT_MAX_BODY_SIZE);
481        assert_eq!(ctx.body_limit().max_size(), DEFAULT_MAX_BODY_SIZE);
482    }
483
484    #[test]
485    fn request_context_custom_body_limit() {
486        let cx = Cx::for_testing();
487        let ctx = RequestContext::with_body_limit(cx, 1, 2 * 1024 * 1024);
488        assert_eq!(ctx.max_body_size(), 2 * 1024 * 1024); // 2MB
489    }
490
491    #[test]
492    fn request_context_with_overrides_has_default_limit() {
493        let cx = Cx::for_testing();
494        let overrides = Arc::new(DependencyOverrides::new());
495        let ctx = RequestContext::with_overrides(cx, 1, overrides);
496        assert_eq!(ctx.max_body_size(), DEFAULT_MAX_BODY_SIZE);
497    }
498
499    #[test]
500    fn request_context_with_overrides_and_custom_limit() {
501        let cx = Cx::for_testing();
502        let overrides = Arc::new(DependencyOverrides::new());
503        let ctx = RequestContext::with_overrides_and_body_limit(cx, 1, overrides, 4 * 1024 * 1024);
504        assert_eq!(ctx.max_body_size(), 4 * 1024 * 1024); // 4MB
505    }
506
507    // ========================================================================
508    // bd-3st7: Request Isolation Tests
509    // ========================================================================
510
511    #[test]
512    #[allow(clippy::similar_names)]
513    fn request_id_isolation_unique_per_context() {
514        // Test that each RequestContext gets the request_id it was created with (bd-3st7)
515        let cx1 = Cx::for_testing();
516        let cx2 = Cx::for_testing();
517        let cx3 = Cx::for_testing();
518
519        let ctx1 = RequestContext::new(cx1, 100);
520        let ctx2 = RequestContext::new(cx2, 200);
521        let ctx3 = RequestContext::new(cx3, 300);
522
523        // Each context has its own request_id
524        assert_eq!(ctx1.request_id(), 100);
525        assert_eq!(ctx2.request_id(), 200);
526        assert_eq!(ctx3.request_id(), 300);
527
528        // Request IDs don't affect each other
529        assert_ne!(ctx1.request_id(), ctx2.request_id());
530        assert_ne!(ctx2.request_id(), ctx3.request_id());
531    }
532
533    #[test]
534    #[allow(clippy::similar_names)]
535    fn dependency_cache_isolation_per_request() {
536        // Test that each RequestContext has its own dependency cache (bd-3st7)
537        let cx1 = Cx::for_testing();
538        let cx2 = Cx::for_testing();
539
540        let ctx1 = RequestContext::new(cx1, 1);
541        let ctx2 = RequestContext::new(cx2, 2);
542
543        // Cache a value in ctx1's dependency cache
544        ctx1.dependency_cache().insert::<i32>(42);
545
546        // ctx2's cache should NOT have this value
547        let value1 = ctx1.dependency_cache().get::<i32>();
548        let value2 = ctx2.dependency_cache().get::<i32>();
549
550        assert!(value1.is_some(), "ctx1 should have cached value");
551        assert_eq!(value1.unwrap(), 42);
552        assert!(value2.is_none(), "ctx2 should NOT have ctx1's cached value");
553    }
554
555    #[test]
556    #[allow(clippy::similar_names)]
557    fn cleanup_stack_isolation_per_request() {
558        // Test that each RequestContext has its own cleanup stack (bd-3st7)
559        use std::sync::atomic::{AtomicUsize, Ordering};
560
561        let cleanup_counter1 = Arc::new(AtomicUsize::new(0));
562        let cleanup_counter2 = Arc::new(AtomicUsize::new(0));
563
564        let cx1 = Cx::for_testing();
565        let cx2 = Cx::for_testing();
566
567        let ctx1 = RequestContext::new(cx1, 1);
568        let ctx2 = RequestContext::new(cx2, 2);
569
570        // Register cleanup for ctx1
571        {
572            let counter = cleanup_counter1.clone();
573            ctx1.cleanup_stack().push(Box::new(move || {
574                Box::pin(async move {
575                    counter.fetch_add(1, Ordering::SeqCst);
576                })
577            }));
578        }
579
580        // Register cleanup for ctx2
581        {
582            let counter = cleanup_counter2.clone();
583            ctx2.cleanup_stack().push(Box::new(move || {
584                Box::pin(async move {
585                    counter.fetch_add(1, Ordering::SeqCst);
586                })
587            }));
588        }
589
590        // Run ctx1's cleanups
591        futures_executor::block_on(ctx1.cleanup_stack().run_cleanups());
592
593        // Only ctx1's cleanup should have run
594        assert_eq!(
595            cleanup_counter1.load(Ordering::SeqCst),
596            1,
597            "ctx1 cleanup should have run"
598        );
599        assert_eq!(
600            cleanup_counter2.load(Ordering::SeqCst),
601            0,
602            "ctx2 cleanup should NOT have run"
603        );
604
605        // Now run ctx2's cleanups
606        futures_executor::block_on(ctx2.cleanup_stack().run_cleanups());
607        assert_eq!(
608            cleanup_counter2.load(Ordering::SeqCst),
609            1,
610            "ctx2 cleanup should have run"
611        );
612    }
613
614    #[test]
615    #[allow(clippy::similar_names)]
616    fn cx_cancellation_isolation_per_request() {
617        // Test that cancelling one request's Cx doesn't affect others (bd-3st7)
618        let cx1 = Cx::for_testing();
619        let cx2 = Cx::for_testing();
620        let cx3 = Cx::for_testing();
621
622        let ctx1 = RequestContext::new(cx1, 1);
623        let ctx2 = RequestContext::new(cx2, 2);
624        let ctx3 = RequestContext::new(cx3, 3);
625
626        // Initially none are cancelled
627        assert!(ctx1.checkpoint().is_ok(), "ctx1 should not be cancelled");
628        assert!(ctx2.checkpoint().is_ok(), "ctx2 should not be cancelled");
629        assert!(ctx3.checkpoint().is_ok(), "ctx3 should not be cancelled");
630
631        // Cancel ctx2 only
632        ctx2.cx().set_cancel_requested(true);
633
634        // Only ctx2 should be cancelled
635        assert!(
636            ctx1.checkpoint().is_ok(),
637            "ctx1 should still not be cancelled"
638        );
639        assert!(ctx2.checkpoint().is_err(), "ctx2 should be cancelled");
640        assert!(
641            ctx3.checkpoint().is_ok(),
642            "ctx3 should still not be cancelled"
643        );
644    }
645
646    #[test]
647    #[allow(clippy::similar_names)]
648    fn body_limit_isolation_per_request() {
649        // Test that body limits are per-request (bd-3st7)
650        let cx1 = Cx::for_testing();
651        let cx2 = Cx::for_testing();
652
653        // Create contexts with different body limits
654        let ctx1 = RequestContext::with_body_limit(cx1, 1, 1024); // 1KB limit
655        let ctx2 = RequestContext::with_body_limit(cx2, 2, 1024 * 1024); // 1MB limit
656
657        // Each has its own limit
658        assert_eq!(ctx1.max_body_size(), 1024);
659        assert_eq!(ctx2.max_body_size(), 1024 * 1024);
660
661        // They don't affect each other
662        assert_ne!(ctx1.max_body_size(), ctx2.max_body_size());
663    }
664
665    #[test]
666    fn concurrent_requests_fully_isolated() {
667        // Simulate concurrent requests with different values and verify isolation (bd-3st7)
668        use std::thread;
669
670        const NUM_REQUESTS: usize = 100;
671        let results = Arc::new(parking_lot::Mutex::new(Vec::with_capacity(NUM_REQUESTS)));
672
673        let handles: Vec<_> = (0..NUM_REQUESTS)
674            .map(|i| {
675                let results = results.clone();
676                thread::spawn(move || {
677                    let cx = Cx::for_testing();
678                    let request_id = (i + 1) as u64 * 1000; // Unique ID per "request"
679                    let ctx = RequestContext::new(cx, request_id);
680
681                    // Cache a value unique to this request
682                    ctx.dependency_cache().insert::<u64>(request_id);
683
684                    // Verify we can retrieve our own value
685                    let cached = ctx.dependency_cache().get::<u64>();
686                    let retrieved = cached.unwrap_or(0);
687
688                    results.lock().push((request_id, retrieved));
689                })
690            })
691            .collect();
692
693        // Wait for all threads to complete
694        for handle in handles {
695            handle.join().expect("Thread panicked");
696        }
697
698        // Verify each request got exactly its own values
699        let results = results.lock();
700        assert_eq!(results.len(), NUM_REQUESTS);
701
702        for (request_id, retrieved) in results.iter() {
703            assert_eq!(
704                request_id, retrieved,
705                "Request {request_id} should retrieve its own cached value, not another request's"
706            );
707        }
708    }
709
710    #[test]
711    #[allow(clippy::similar_names)]
712    fn resolution_stack_isolation_per_request() {
713        // Test that resolution stacks are per-request for cycle detection (bd-3st7)
714        use crate::dependency::DependencyScope;
715
716        let cx1 = Cx::for_testing();
717        let cx2 = Cx::for_testing();
718
719        let ctx1 = RequestContext::new(cx1, 1);
720        let ctx2 = RequestContext::new(cx2, 2);
721
722        // Push i32 onto ctx1's resolution stack
723        ctx1.resolution_stack()
724            .push::<i32>("i32", DependencyScope::Request);
725
726        // ctx1 should detect the cycle when pushing i32 again
727        let cycle1 = ctx1.resolution_stack().check_cycle::<i32>("i32");
728        assert!(cycle1.is_some(), "ctx1 should detect cycle for i32");
729
730        // ctx2's resolution stack should be independent - no cycle
731        let cycle2 = ctx2.resolution_stack().check_cycle::<i32>("i32");
732        assert!(
733            cycle2.is_none(),
734            "ctx2 should NOT see ctx1's resolution stack"
735        );
736
737        // ctx2 can push i32 independently
738        ctx2.resolution_stack()
739            .push::<i32>("i32", DependencyScope::Request);
740        assert_eq!(ctx2.resolution_stack().depth(), 1);
741
742        // Both stacks are independent
743        assert_eq!(ctx1.resolution_stack().depth(), 1);
744        assert_eq!(ctx2.resolution_stack().depth(), 1);
745
746        // Clean up
747        ctx1.resolution_stack().pop();
748        ctx2.resolution_stack().pop();
749        assert!(ctx1.resolution_stack().is_empty());
750        assert!(ctx2.resolution_stack().is_empty());
751    }
752}