Skip to main content

webx_core/
dispatch_runtime.rs

1//! Per-request dispatch runtime — instance-scoped provider and handler cache.
2//!
3//! `Host` stores a [`DispatchRuntime`] and scopes it to each HTTP request via
4//! a task-local. Macro-generated dispatch and `Mediator` read from the active
5//! runtime when present, falling back to process-wide globals for backward
6//! compatibility (in-process `Mediator::send` without HTTP context).
7
8use std::future::Future;
9use std::sync::Arc;
10
11use crate::route::scan::{try_global_provider, HandlerCache};
12
13tokio::task_local! {
14    static ACTIVE_RUNTIME: Option<Arc<DispatchRuntime>>;
15    /// Provider of the DI scope currently active for `dispatch_provider()`.
16    ///
17    /// Set by [`DispatchRuntime::run_in_scope`] (hosted services) so scoped
18    /// dependencies resolved by `dispatch_provider().get_owned()` bind to that
19    /// scope instead of the root provider's scoped cache.
20    static ACTIVE_SCOPE_PROVIDER: Option<Arc<rust_dix::ServiceProvider>>;
21}
22
23/// Provider + handler registry for one host instance.
24#[derive(Clone)]
25pub struct DispatchRuntime {
26    provider: Arc<rust_dix::ServiceProvider>,
27    handler_cache: Arc<HandlerCache>,
28}
29
30impl DispatchRuntime {
31    pub fn new(provider: Arc<rust_dix::ServiceProvider>, handler_cache: Arc<HandlerCache>) -> Self {
32        Self {
33            provider,
34            handler_cache,
35        }
36    }
37
38    pub fn provider(&self) -> &Arc<rust_dix::ServiceProvider> {
39        &self.provider
40    }
41
42    pub fn handler_cache(&self) -> &Arc<HandlerCache> {
43        &self.handler_cache
44    }
45
46    /// Run an async block with this runtime active for the current task.
47    pub async fn run<F, R>(&self, f: F) -> R
48    where
49        F: Future<Output = R>,
50    {
51        ACTIVE_RUNTIME.scope(Some(Arc::new(self.clone())), f).await
52    }
53
54    /// Run an async block with a **fresh DI scope** active.
55    ///
56    /// `dispatch_provider()` resolves within the scope while the block runs, so
57    /// a hosted service that does `dispatch_provider().get_owned::<DbContext>()`
58    /// gets a context bound to *this* scope (transitive scoped dependencies
59    /// included), not one cached on the root provider. The scope is disposed
60    /// when the block finishes.
61    ///
62    /// Hosted services are started and stopped inside this, which is what makes
63    /// their scoped dependencies per-run instead of process-wide singletons.
64    pub async fn run_in_scope<F, R>(self: &Arc<Self>, f: F) -> R
65    where
66        F: Future<Output = R>,
67    {
68        let scope = self.provider.scope();
69        let scoped_provider = Arc::clone(scope.scope_provider());
70        let result = ACTIVE_RUNTIME
71            .scope(
72                Some(Arc::clone(self)),
73                ACTIVE_SCOPE_PROVIDER.scope(Some(scoped_provider), f),
74            )
75            .await;
76        scope.dispose();
77        result
78    }
79}
80
81/// Provider for HTTP dispatch and `#[handler(inject)]` factories.
82///
83/// Prefers the active [`DispatchRuntime`] (HTTP requests and hosted services started
84/// by `Host`). Falls back to the deprecated process-wide shim when
85/// [`set_global_provider`](crate::route::scan::set_global_provider) was called manually.
86pub fn dispatch_provider() -> Arc<rust_dix::ServiceProvider> {
87    // A scope opened by `run_in_scope` (hosted service start/stop) wins: its
88    // provider owns the scoped cache, so scoped dependencies stay per-scope.
89    if let Ok(Some(provider)) = ACTIVE_SCOPE_PROVIDER.try_with(|p| p.clone()) {
90        return provider;
91    }
92
93    if let Ok(Some(provider)) =
94        ACTIVE_RUNTIME.try_with(|rt| rt.as_ref().map(|r| Arc::clone(&r.provider)))
95    {
96        return provider;
97    }
98
99    if let Some(provider) = try_global_provider() {
100        tracing::warn!(
101            "dispatch_provider() using deprecated global ServiceProvider shim; \
102             prefer Host::dispatch_runtime().run() or host.provider()"
103        );
104        return provider;
105    }
106
107    panic!(
108        "No active DispatchRuntime and no deprecated global ServiceProvider. \
109         HTTP and hosted services run inside Host::dispatch_runtime().run(); \
110         for tests use host.dispatch_runtime().run with an async block, or host.provider()."
111    );
112}
113
114/// Handler registry for the active dispatch context.
115///
116/// Prefers the active [`DispatchRuntime`]; falls back to a lazy-built process-wide
117/// registry (inventory is process-wide; contents are identical per host).
118pub fn dispatch_handler_cache() -> Arc<HandlerCache> {
119    if let Ok(Some(cache)) =
120        ACTIVE_RUNTIME.try_with(|rt| rt.as_ref().map(|r| Arc::clone(&r.handler_cache)))
121    {
122        return cache;
123    }
124
125    #[allow(deprecated)]
126    {
127        HandlerCache::get_or_init()
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use rust_dix::ServiceCollection;
135    use std::sync::atomic::{AtomicUsize, Ordering};
136
137    #[derive(Debug)]
138    struct ScopedThing(usize);
139
140    fn runtime_with_scoped_counter() -> (Arc<DispatchRuntime>, Arc<AtomicUsize>) {
141        let created = Arc::new(AtomicUsize::new(0));
142        let counter = Arc::clone(&created);
143        let svc = ServiceCollection::new().scoped::<ScopedThing>(move |_| {
144            let n = counter.fetch_add(1, Ordering::SeqCst);
145            Arc::new(ScopedThing(n))
146        });
147        let provider = svc.build().expect("provider");
148        let runtime = Arc::new(DispatchRuntime::new(
149            provider,
150            Arc::new(HandlerCache::build()),
151        ));
152        (runtime, created)
153    }
154
155    /// #20: hosted services run inside a DI scope, so `dispatch_provider()`
156    /// resolves against a per-run scoped cache instead of the root provider.
157    #[tokio::test]
158    async fn run_in_scope_gives_each_run_its_own_scoped_cache() {
159        let (runtime, created) = runtime_with_scoped_counter();
160
161        let same_within_one_scope = runtime
162            .run_in_scope(async {
163                let first = dispatch_provider().get::<ScopedThing>().expect("scoped");
164                let second = dispatch_provider().get::<ScopedThing>().expect("scoped");
165                Arc::ptr_eq(&first, &second)
166            })
167            .await;
168        assert!(
169            same_within_one_scope,
170            "scoped service must be cached per scope"
171        );
172        assert_eq!(created.load(Ordering::SeqCst), 1);
173
174        runtime
175            .run_in_scope(async {
176                let again = dispatch_provider().get::<ScopedThing>().expect("scoped");
177                assert_eq!(again.0, 1, "a new scope must not reuse the root cache");
178            })
179            .await;
180        assert_eq!(created.load(Ordering::SeqCst), 2);
181    }
182
183    /// Outside `run_in_scope` the runtime's own provider is used (the scope
184    /// override is scoped, not global).
185    #[tokio::test]
186    async fn scope_provider_does_not_leak_outside_run_in_scope() {
187        let (runtime, _created) = runtime_with_scoped_counter();
188        runtime
189            .run(async {
190                assert!(
191                    dispatch_provider().get::<ScopedThing>().is_ok(),
192                    "runtime provider still resolves the service"
193                );
194            })
195            .await;
196    }
197}