Skip to main content

cratefield_runtime_native/
runtime.rs

1//! The `Native` runtime builder (issue #19): the counterpart of
2//! `cratefield_runtime_cloudflare::Cloudflare` for a self-hosted binary.
3//!
4//! ```ignore
5//! # use std::sync::Arc;
6//! # fn db() -> Arc<dyn cratefield_core::Database> { unimplemented!() }
7//! # fn rate_limiter() -> Arc<dyn cratefield_core::RateLimiter> { unimplemented!() }
8//! # fn kv() -> Arc<dyn cratefield_core::KeyValue> { unimplemented!() }
9//! # fn mailer() -> Arc<dyn cratefield_core::Mailer> { unimplemented!() }
10//! use cratefield_runtime_native::Native;
11//!
12//! let runtime = Native::new()
13//!     .db_arc(db())                       // Postgres (adapter-postgres) or Sqlite
14//!     .rate_limiter_arc(rate_limiter())   // RedisRateLimiter, optional
15//!     .kv_arc(kv())                       // RedisKv, optional
16//!     .mailer_arc(mailer());              // Resend::from_env(..), as on Workers
17//! # let _runtime: Native = runtime;
18//! ```
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use cratefield_core::{
24    Auth, Blob, BoundedHttpClient, Captcha, Classifier, Clock, CustomHostnames, Database, Embedder,
25    HarnessConfig, KeyValue, Mailer, Payments, Port, Ports, Push, RateLimiter, Realtime, Runtime,
26    TextModel, Tracker, UlidIdGen, VectorIndex,
27};
28
29use crate::config::EnvConfig;
30use crate::ports::{ReqwestClient, SpawnDefer, TokioClock};
31
32static WARNED_SIGNER: AtomicBool = AtomicBool::new(false);
33
34fn warn_once(flag: &AtomicBool, message: &str) {
35    // One `swap`, not `load`-then-`store`: two threads can both read `false`
36    // in the gap between the two operations and both log, which is the one
37    // thing a "once" warning exists to prevent. The idiom `core`'s
38    // production-readiness acceptance flags already use (harness.rs:558).
39    if !flag.swap(true, Ordering::Relaxed) {
40        tracing::warn!("{message}");
41    }
42}
43
44/// The native runtime. Adapters are passed in as instances (the same
45/// pattern as the Cloudflare runtime's `.mailer(..)`/`.captcha(..)`):
46/// a Postgres or SQLite [`Database`](cratefield_core::Database) from the
47/// matching adapter crate, a [`RedisRateLimiter`](crate::RedisRateLimiter)
48/// and [`RedisKv`](crate::RedisKv) over a shared Redis connection
49/// manager, `Resend`/`Turnstile` unchanged from the Workers path.
50///
51/// Config — including `HARNESS_SECRET` for the `Signer` port — is read
52/// from `std::env` through the same [`cratefield_core::Config`] trait
53/// ([`EnvConfig`]), so module keys behave identically on both runtimes.
54#[derive(Clone, Default)]
55pub struct Native {
56    db: Option<Arc<dyn Database>>,
57    rate_limiter: Option<Arc<dyn RateLimiter>>,
58    kv: Option<Arc<dyn KeyValue>>,
59    blob: Option<Arc<dyn Blob>>,
60    push: Option<Arc<dyn Push>>,
61    payments: Option<Arc<dyn Payments>>,
62    tracker: Option<Arc<dyn Tracker>>,
63    realtime: Option<Arc<dyn Realtime>>,
64    /// The `TextModel` port (issue #429): like `mailer`, the adapter is
65    /// built from the venture's vendor keys and passed in — there is no
66    /// vendor-neutral binding to sniff out of the environment.
67    text_model: Option<Arc<dyn TextModel>>,
68    /// The `Classifier` port (issue #456), the sibling of `text_model`:
69    /// the adapter is passed in the same way. An adapter that reaches a
70    /// Workers AI binding still arrives here as an instance, so the two
71    /// runtimes wire this port identically.
72    classifier: Option<Arc<dyn Classifier>>,
73    /// The `VectorIndex` port (issue #561): in-process
74    /// [`ExactVectorIndex`](cratefield_core::ExactVectorIndex) or a hosted
75    /// index's adapter, passed in like every other adapter here.
76    vector_index: Option<Arc<dyn VectorIndex>>,
77    /// The `Embedder` port (issue #561), the input side of `vector_index`.
78    embedder: Option<Arc<dyn Embedder>>,
79    /// The `CustomHostnames` port (issue #590): like `mailer`, the adapter
80    /// is passed in — the Cloudflare for `SaaS` adapter
81    /// (`cratefield-adapter-cloudflare-saas`) is portable over the
82    /// `HttpClient` port and runs here unchanged.
83    custom_hostnames: Option<Arc<dyn CustomHostnames>>,
84    mailer: Option<Arc<dyn Mailer>>,
85    captcha: Option<Arc<dyn Captcha>>,
86    auth: Option<Arc<dyn Auth>>,
87    /// Whether to assemble the `Auth` port from the environment.
88    auth_from_env: bool,
89    /// Whether to assemble the `Push` port from `std::env` (issue #191).
90    /// Built lazily in `ports()` and memoised for the process, so a venture
91    /// may call `push_from_env()` before a tokio runtime exists.
92    #[cfg(feature = "push")]
93    push_from_env: bool,
94    #[cfg(feature = "push")]
95    assembled_push: Arc<std::sync::OnceLock<(Arc<dyn Push>, cratefield_push_wiring::PushWiring)>>,
96}
97
98impl Native {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// The `Database` port: `Postgres` (`cratefield-adapter-postgres`) or
104    /// `SqliteDatabase` (`cratefield-adapter-sqlite`) — any `Database`
105    /// implementation.
106    #[must_use]
107    pub fn db(mut self, db: impl Database + 'static) -> Self {
108        self.db = Some(Arc::new(db));
109        self
110    }
111
112    /// `db` for an already-shared database.
113    #[must_use]
114    pub fn db_arc(mut self, db: Arc<dyn Database>) -> Self {
115        self.db = Some(db);
116        self
117    }
118
119    /// The `RateLimiter` port: [`RedisRateLimiter`](crate::RedisRateLimiter) in this crate, or a
120    /// test fake.
121    #[must_use]
122    pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self {
123        self.rate_limiter = Some(Arc::new(limiter));
124        self
125    }
126
127    /// `rate_limiter` for an already-shared limiter.
128    #[must_use]
129    pub fn rate_limiter_arc(mut self, limiter: Arc<dyn RateLimiter>) -> Self {
130        self.rate_limiter = Some(limiter);
131        self
132    }
133
134    /// The `KeyValue` port: [`RedisKv`](crate::RedisKv) in this crate, or a test fake.
135    #[must_use]
136    pub fn kv(mut self, kv: impl KeyValue + 'static) -> Self {
137        self.kv = Some(Arc::new(kv));
138        self
139    }
140
141    /// `kv` for an already-shared store.
142    #[must_use]
143    pub fn kv_arc(mut self, kv: Arc<dyn KeyValue>) -> Self {
144        self.kv = Some(kv);
145        self
146    }
147
148    /// The `Blob` port: a directory store ([`crate::DirBlob`]) or an
149    /// S3-compatible adapter.
150    #[must_use]
151    pub fn blob_arc(mut self, blob: Arc<dyn Blob>) -> Self {
152        self.blob = Some(blob);
153        self
154    }
155
156    /// The `Push` port: an APNs adapter (`cratefield-adapter-apns`) or a test
157    /// fake. Notifications reach a device over the runtime's `HttpClient`.
158    #[must_use]
159    pub fn push_arc(mut self, push: Arc<dyn Push>) -> Self {
160        self.push = Some(push);
161        self
162    }
163
164    /// Assembles the `Push` port from the process environment instead of
165    /// taking an adapter (issue #191): one `RoutingPush` over whichever of
166    /// APNs, FCM and Web Push the deployment configured, built by the same
167    /// `cratefield_push_wiring::build_push` the Workers runtime, `fz push`
168    /// and `fz doctor` call, so the four cannot drift apart on a variable
169    /// name.
170    ///
171    /// The port is then always provided, even with nothing configured: the
172    /// router answers `NotConfigured` for every recipient, exactly as an
173    /// unconfigured adapter does. `serve_on` logs the resulting
174    /// [`PushWiring`](cratefield_push_wiring::PushWiring) report once at cold
175    /// start, and an explicit `.push_arc(..)` still wins.
176    ///
177    /// # This is a declaration, and `fz doctor` is what checks it
178    ///
179    /// Declaring the port rather than gating it on what `std::env` holds at
180    /// build time is deliberate, and it keeps this runtime's answer the same
181    /// as the Workers one's: there a Worker's `Env` does not exist when
182    /// `Harness::build` runs, so a venture that built here and refused there
183    /// (or the reverse) is the divergence issue #143 was about.
184    ///
185    /// `fz doctor` is the gate that used to be the build's. It runs where
186    /// the deployment's environment is known, and refuses a production
187    /// venture whose modules require push and whose environment routes no
188    /// transport at all (issue #191).
189    #[cfg(feature = "push")]
190    #[must_use]
191    pub fn push_from_env(mut self) -> Self {
192        self.push_from_env = true;
193        self
194    }
195
196    /// The environment-assembled `Push` port and its report, built once per
197    /// process.
198    ///
199    /// `None` when the venture did not ask for it — **and** when it passed
200    /// an explicit adapter, which wins: assembling a stack of adapters
201    /// nothing will serve with costs a `.p8` parse, an RSA parse and a VAPID
202    /// scalar, and then reports on transports the venture deliberately
203    /// overrode.
204    #[cfg(feature = "push")]
205    fn env_push(&self) -> Option<&(Arc<dyn Push>, cratefield_push_wiring::PushWiring)> {
206        if !self.push_from_env || self.push.is_some() {
207            return None;
208        }
209        Some(self.assembled_push.get_or_init(|| {
210            let clock: Arc<dyn Clock> = Arc::new(TokioClock);
211            let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
212                Arc::new(ReqwestClient::new()),
213                Arc::clone(&clock),
214            ));
215            cratefield_push_wiring::build_push(&EnvConfig, &http, &clock)
216        }))
217    }
218
219    /// Which push transports this deployment configured. `serve_on` logs
220    /// this at cold start; nothing else needs it.
221    ///
222    /// `None` when the venture did not call
223    /// [`push_from_env`](Self::push_from_env), or passed an explicit adapter
224    /// that wins over it — there is nothing to report about an environment
225    /// nothing reads.
226    ///
227    /// Call it *after* `ports()`, as `serve_on` does: `ports()` has then
228    /// already assembled and memoised the adapters, and this is the getter
229    /// it looks like.
230    #[cfg(feature = "push")]
231    #[must_use]
232    pub fn push_wiring(&self) -> Option<&cratefield_push_wiring::PushWiring> {
233        self.env_push().map(|(_, wiring)| wiring)
234    }
235
236    /// The `Payments` port: a Stripe adapter (`cratefield-adapter-stripe`) or a
237    /// test fake, over the runtime's `HttpClient`.
238    #[must_use]
239    pub fn payments_arc(mut self, payments: Arc<dyn Payments>) -> Self {
240        self.payments = Some(payments);
241        self
242    }
243
244    /// The `Tracker` port: a [`cratefield_core::RoutingTracker`] over the
245    /// per-tracker adapters the venture configured, or a test fake.
246    ///
247    /// Unlike every other adapter on this harness, the adapter holds no
248    /// credential: which tracker to file into and under whose token are
249    /// tenant data, supplied per call by the module that files — see
250    /// [`cratefield_core::Tracker`] for why that differs from the
251    /// Resend/Stripe shape.
252    #[must_use]
253    pub fn tracker_arc(mut self, tracker: Arc<dyn Tracker>) -> Self {
254        self.tracker = Some(tracker);
255        self
256    }
257
258    /// The `Realtime` port: the in-process room registry
259    /// ([`crate::InProcessRealtime`]) built from the module's `RoomHandler`.
260    #[must_use]
261    pub fn realtime_arc(mut self, realtime: Arc<dyn Realtime>) -> Self {
262        self.realtime = Some(realtime);
263        self
264    }
265
266    /// The `TextModel` port (issue #429): an adapter over the venture's
267    /// vendor of choice for each [`ModelTier`](cratefield_core::ModelTier),
268    /// or one `RoutingTextModel` over both tiers. The same pattern as
269    /// `mailer`.
270    #[must_use]
271    pub fn text_model(mut self, text_model: impl TextModel + 'static) -> Self {
272        self.text_model = Some(Arc::new(text_model));
273        self
274    }
275
276    /// `text_model` for an already-shared adapter.
277    #[must_use]
278    pub fn text_model_arc(mut self, text_model: Arc<dyn TextModel>) -> Self {
279        self.text_model = Some(text_model);
280        self
281    }
282
283    /// The `Classifier` port (issue #456): a typed, calibrated decision —
284    /// "which of these is it, and how sure are you" — from a purpose-built
285    /// classifier, a Workers AI binding (on the Workers twin) or an LLM
286    /// behind the `TextModel` port. The same pattern as `text_model`.
287    #[must_use]
288    pub fn classifier(mut self, classifier: impl Classifier + 'static) -> Self {
289        self.classifier = Some(Arc::new(classifier));
290        self
291    }
292
293    /// `classifier` for an already-shared adapter.
294    #[must_use]
295    pub fn classifier_arc(mut self, classifier: Arc<dyn Classifier>) -> Self {
296        self.classifier = Some(classifier);
297        self
298    }
299
300    /// The `VectorIndex` port (issue #561): nearest-neighbour search over
301    /// the namespaces a module asks for — in-process
302    /// [`ExactVectorIndex`](cratefield_core::ExactVectorIndex) or a hosted
303    /// index's adapter.
304    #[must_use]
305    pub fn vector_index_arc(mut self, index: Arc<dyn VectorIndex>) -> Self {
306        self.vector_index = Some(index);
307        self
308    }
309
310    /// The `Embedder` port (issue #561): text into embedding vectors, as
311    /// wide as the `vector_index` wired alongside it expects.
312    #[must_use]
313    pub fn embedder_arc(mut self, embedder: Arc<dyn Embedder>) -> Self {
314        self.embedder = Some(embedder);
315        self
316    }
317
318    /// The `CustomHostnames` port (issue #590): a customer's own hostname
319    /// served by a venture. Like `mailer`, the adapter is built from the
320    /// venture's vendor keys and passed in — the Cloudflare for `SaaS`
321    /// adapter is `cratefield-adapter-cloudflare-saas`, portable over the
322    /// `HttpClient` port.
323    #[must_use]
324    pub fn custom_hostnames(mut self, custom_hostnames: impl CustomHostnames + 'static) -> Self {
325        self.custom_hostnames = Some(Arc::new(custom_hostnames));
326        self
327    }
328
329    /// `custom_hostnames` for an already-shared adapter.
330    #[must_use]
331    pub fn custom_hostnames_arc(mut self, custom_hostnames: Arc<dyn CustomHostnames>) -> Self {
332        self.custom_hostnames = Some(custom_hostnames);
333        self
334    }
335
336    #[must_use]
337    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
338        self.mailer = Some(Arc::new(mailer));
339        self
340    }
341
342    #[must_use]
343    pub fn mailer_arc(mut self, mailer: Arc<dyn Mailer>) -> Self {
344        self.mailer = Some(mailer);
345        self
346    }
347
348    #[must_use]
349    pub fn captcha(mut self, captcha: impl Captcha + 'static) -> Self {
350        self.captcha = Some(Arc::new(captcha));
351        self
352    }
353
354    #[must_use]
355    pub fn captcha_arc(mut self, captcha: Arc<dyn Captcha>) -> Self {
356        self.captcha = Some(captcha);
357        self
358    }
359
360    /// Wires who a request's credentials speak for (issue #153).
361    ///
362    /// Without one the deployment cannot identify a caller, and a module
363    /// that declares [`Port::Auth`] is refused composition rather than
364    /// mounted and left guessing.
365    #[must_use]
366    pub fn auth(mut self, auth: impl Auth + 'static) -> Self {
367        self.auth = Some(Arc::new(auth));
368        self
369    }
370
371    #[must_use]
372    pub fn auth_arc(mut self, auth: Arc<dyn Auth>) -> Self {
373        self.auth = Some(auth);
374        self
375    }
376
377    /// Assembles the `Auth` port from `AUTH_ISSUER` and `AUTH_CLIENT_ID`.
378    ///
379    /// The port is provided either way, matching the Cloudflare runtime:
380    /// a venture generated by `fz build` wires this whenever one of its
381    /// tables is not `public-read`, and the two runtimes answering
382    /// differently for the same venture would be a difference between
383    /// two deployments of one manifest.
384    #[must_use]
385    pub fn auth_from_env(mut self) -> Self {
386        self.auth_from_env = true;
387        self
388    }
389
390    /// Resolves the port bundle once per process from the builder and
391    /// `std::env`: config, `Signer` (from `HARNESS_SECRET` — warn once
392    /// and leave unset when missing, exactly like the Cloudflare
393    /// runtime), `HttpClient` over reqwest, `Clock` over the system
394    /// clock, `IdGen`, and `Defer` over `tokio::spawn`.
395    ///
396    /// Unlike the Workers runtime — which resolves bindings per event —
397    /// a native process builds one bundle and shares it: every adapter
398    /// here is an `Arc`, so `clone_ports` snapshots cheaply.
399    #[must_use]
400    pub fn ports(&self) -> Ports {
401        let config: Arc<dyn cratefield_core::Config> = Arc::new(EnvConfig);
402        let mut ports = Ports::with_config(Arc::clone(&config));
403
404        ports.db.clone_from(&self.db);
405        ports.rate_limiter.clone_from(&self.rate_limiter);
406        ports.kv.clone_from(&self.kv);
407        ports.blob.clone_from(&self.blob);
408        ports.push.clone_from(&self.push);
409        ports.payments.clone_from(&self.payments);
410        ports.tracker.clone_from(&self.tracker);
411        ports.realtime.clone_from(&self.realtime);
412        ports.text_model.clone_from(&self.text_model);
413        ports.classifier.clone_from(&self.classifier);
414        ports.vector_index.clone_from(&self.vector_index);
415        ports.embedder.clone_from(&self.embedder);
416        ports.custom_hostnames.clone_from(&self.custom_hostnames);
417        ports.mailer.clone_from(&self.mailer);
418        ports.captcha.clone_from(&self.captcha);
419        ports.auth.clone_from(&self.auth);
420
421        // An explicit adapter wins; otherwise assemble one from the
422        // environment when the venture asked for it (issue #191).
423        #[cfg(feature = "push")]
424        if ports.push.is_none()
425            && let Some((push, _)) = self.env_push()
426        {
427            ports.push = Some(Arc::clone(push));
428        }
429
430        match HarnessConfig::from_config(&*config) {
431            Ok(parsed) => {
432                // Pseudonymise logged emails with a key derived from the
433                // harness secret, not a bare (reversible) hash — issue #135.
434                cratefield_core::set_log_pseudonym_key(parsed.harness_secret.as_bytes());
435                ports.signer = Some(Arc::new(parsed.signer()));
436            }
437            Err(err) => warn_once(&WARNED_SIGNER, &format!("Signer port not provided: {err}")),
438        }
439
440        let clock: Arc<dyn Clock> = Arc::new(TokioClock);
441        ports.clock = Some(Arc::clone(&clock));
442        let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
443            Arc::new(ReqwestClient::new()),
444            Arc::clone(&clock),
445        ));
446        ports.http = Some(Arc::clone(&http));
447        // After the clock and the HTTP client, which the verifier needs
448        // to fetch the issuer's key set.
449        if ports.auth.is_none() && self.auth_from_env {
450            ports.auth = Some(cratefield_auth_client::from_config(
451                ports.config.as_ref(),
452                http,
453                clock,
454            ));
455        }
456        ports.id_gen = Some(Arc::new(UlidIdGen));
457        ports.defer = Some(Arc::new(SpawnDefer));
458        ports
459    }
460}
461
462impl Runtime for Native {
463    /// The static set used by `Harness::build`. `Signer` is included:
464    /// its presence is validated at `ports()` time (and by `fz doctor`),
465    /// mirroring the Cloudflare runtime.
466    fn provides(&self) -> Vec<Port> {
467        let mut provided = vec![
468            Port::Signer,
469            Port::HttpClient,
470            Port::Clock,
471            Port::IdGen,
472            Port::Defer,
473        ];
474        if self.db.is_some() {
475            provided.push(Port::Db);
476        }
477        // Provided whatever the environment holds — see `auth_from_env`.
478        if self.auth.is_some() || self.auth_from_env {
479            provided.push(Port::Auth);
480        }
481        if self.rate_limiter.is_some() {
482            provided.push(Port::RateLimiter);
483        }
484        if self.kv.is_some() {
485            provided.push(Port::KeyValue);
486        }
487        if self.blob.is_some() {
488            provided.push(Port::Blob);
489        }
490        // `push_from_env` provides the port whatever the environment holds:
491        // with nothing configured the router answers `NotConfigured` for
492        // every recipient, which is a provided port that sends nothing, not
493        // an absent one (issue #191).
494        //
495        // `std::env` is readable here, unlike on Workers — and consulting it
496        // is still the wrong move: `provides()` would then answer one thing
497        // in CI and another on the box, and a venture would build here and
498        // refuse to build on its Workers twin, which is the divergence
499        // issue #143 was about. `fz doctor` carries the refusal instead, on
500        // both runtimes, where the deployment's environment is known.
501        #[cfg(feature = "push")]
502        let push_provided = self.push.is_some() || self.push_from_env;
503        #[cfg(not(feature = "push"))]
504        let push_provided = self.push.is_some();
505        if push_provided {
506            provided.push(Port::Push);
507        }
508        if self.payments.is_some() {
509            provided.push(Port::Payments);
510        }
511        if self.tracker.is_some() {
512            provided.push(Port::Tracker);
513        }
514        if self.realtime.is_some() {
515            provided.push(Port::Realtime);
516        }
517        if self.text_model.is_some() {
518            provided.push(Port::TextModel);
519        }
520        if self.classifier.is_some() {
521            provided.push(Port::Classifier);
522        }
523        if self.vector_index.is_some() {
524            provided.push(Port::VectorIndex);
525        }
526        if self.embedder.is_some() {
527            provided.push(Port::Embedder);
528        }
529        if self.custom_hostnames.is_some() {
530            provided.push(Port::CustomHostnames);
531        }
532        if self.mailer.is_some() {
533            provided.push(Port::Mailer);
534        }
535        if self.captcha.is_some() {
536            provided.push(Port::Captcha);
537        }
538        provided
539    }
540
541    /// The same production gate the Cloudflare runtime applies
542    /// (issue #133), which this runtime never did (issue #143): a
543    /// Turnstile adapter that is not hostname-bound, or left fail-open,
544    /// cannot protect a production `HumanForm` route, so the port is
545    /// present but not usable. Without this a self-hosted venture booted
546    /// past a check its Workers twin refuses. Adapters that do not report
547    /// (`binding() == None`) count as effective when present: presence is
548    /// all the runtime can know about them.
549    fn effectively_configured(&self, port: Port) -> bool {
550        match port {
551            Port::Captcha => self.captcha.as_ref().is_some_and(|captcha| {
552                captcha
553                    .binding()
554                    .is_none_or(|binding| binding.hostname_bound && !binding.fail_open)
555            }),
556            _ => self.provides().contains(&port),
557        }
558    }
559}
560
561/// Snapshots a [`Ports`] bundle by cloning the `Arc` of every [`Port`]
562/// and the config (core's `Ports` is not `Clone`; every field is a shared
563/// handle, so this is the whole job). `dispatcher` and `tenants` are not
564/// ports and are left `None`. Used to give the cron scheduler the same
565/// adapters the HTTP router serves with.
566pub(crate) fn clone_ports(ports: &Ports) -> Ports {
567    let mut snapshot = Ports::with_config(Arc::clone(&ports.config));
568    snapshot.db.clone_from(&ports.db);
569    snapshot.auth.clone_from(&ports.auth);
570    snapshot.mailer.clone_from(&ports.mailer);
571    snapshot.captcha.clone_from(&ports.captcha);
572    snapshot.rate_limiter.clone_from(&ports.rate_limiter);
573    snapshot.signer.clone_from(&ports.signer);
574    snapshot.kv.clone_from(&ports.kv);
575    snapshot.blob.clone_from(&ports.blob);
576    snapshot.push.clone_from(&ports.push);
577    snapshot.payments.clone_from(&ports.payments);
578    snapshot.tracker.clone_from(&ports.tracker);
579    snapshot.realtime.clone_from(&ports.realtime);
580    snapshot.text_model.clone_from(&ports.text_model);
581    snapshot.classifier.clone_from(&ports.classifier);
582    snapshot.vector_index.clone_from(&ports.vector_index);
583    snapshot.embedder.clone_from(&ports.embedder);
584    snapshot
585        .custom_hostnames
586        .clone_from(&ports.custom_hostnames);
587    snapshot.http.clone_from(&ports.http);
588    snapshot.clock.clone_from(&ports.clock);
589    snapshot.id_gen.clone_from(&ports.id_gen);
590    snapshot.defer.clone_from(&ports.defer);
591    snapshot.url_safety.clone_from(&ports.url_safety);
592    snapshot.actors.clone_from(&ports.actors);
593    snapshot
594}
595
596#[cfg(test)]
597mod tests {
598    use super::clone_ports;
599    use cratefield_core::{Port, Runtime};
600    use std::sync::Arc;
601
602    #[test]
603    fn clone_ports_carries_every_port() {
604        // Walks `Port::ALL`, so a port added to the enum and not copied
605        // here fails this — the way `auth` was dropped (issue #449).
606        let source = cratefield_testing::full_fake_ports();
607        assert!(
608            Port::ALL.iter().all(|port| source.has(*port)),
609            "the source bundle must wire every port for this to prove anything"
610        );
611        let snapshot = clone_ports(&source);
612        for port in Port::ALL {
613            assert!(snapshot.has(*port), "clone_ports drops {}", port.name());
614        }
615    }
616
617    #[test]
618    fn the_vector_ports_are_provided_only_when_wired() {
619        // `ExactVectorIndex` and the kit's `FakeEmbedder` are real ports.
620        let wired = super::Native::new()
621            .vector_index_arc(Arc::new(cratefield_core::ExactVectorIndex::new(3)))
622            .embedder_arc(Arc::new(cratefield_testing::FakeEmbedder));
623        assert!(wired.provides().contains(&Port::VectorIndex));
624        assert!(wired.provides().contains(&Port::Embedder));
625        let bare = super::Native::new();
626        assert!(!bare.provides().contains(&Port::VectorIndex));
627        assert!(!bare.provides().contains(&Port::Embedder));
628    }
629
630    #[test]
631    fn custom_hostnames_is_provided_only_when_wired() {
632        let wired = super::Native::new().custom_hostnames(
633            cratefield_testing::FakeCustomHostnames::new("cratefield.app"),
634        );
635        assert!(wired.provides().contains(&Port::CustomHostnames));
636        let bare = super::Native::new();
637        assert!(!bare.provides().contains(&Port::CustomHostnames));
638    }
639
640    /// A "warn once" flag two racing threads both read as unset is a "warn
641    /// twice": `load`-then-`store` has a gap the other thread walks straight
642    /// through. Both runtimes build their runtime from several threads, so the
643    /// gap is reachable. The subscriber is installed before any `warn_once`
644    /// call, because a callsite's interest is fixed at its first execution.
645    mod warn_once_race {
646        use super::super::warn_once;
647        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
648        use std::sync::{Arc, Barrier, OnceLock};
649
650        /// The message the test warns with, and how many times the installed
651        /// subscriber saw it. A process-wide counter: the threads race across
652        /// cores, so a thread-local one would only prove what that core saw.
653        const MARKER: &str = "warn-once-race-marker";
654
655        struct Counter(Arc<AtomicUsize>);
656
657        impl tracing::Subscriber for Counter {
658            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
659                true
660            }
661
662            fn new_span(&self, _attributes: &tracing::span::Attributes<'_>) -> tracing::span::Id {
663                tracing::span::Id::from_u64(1)
664            }
665
666            fn record(&self, _id: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
667
668            fn record_follows_from(&self, _id: &tracing::span::Id, _follows: &tracing::span::Id) {}
669
670            fn event(&self, event: &tracing::Event<'_>) {
671                struct Marker<'a>(&'a str, &'a AtomicUsize);
672                impl tracing::field::Visit for Marker<'_> {
673                    fn record_debug(
674                        &mut self,
675                        field: &tracing::field::Field,
676                        value: &dyn std::fmt::Debug,
677                    ) {
678                        // `tracing::warn!("{message}")` records the message as
679                        // `format_args!`, so `Debug` is the rendered line.
680                        if field.name() == "message" && format!("{value:?}").contains(self.0) {
681                            self.1.fetch_add(1, Ordering::SeqCst);
682                        }
683                    }
684                }
685                event.record(&mut Marker(MARKER, &self.0));
686            }
687
688            fn enter(&self, _id: &tracing::span::Id) {}
689
690            fn exit(&self, _id: &tracing::span::Id) {}
691        }
692
693        fn seen() -> &'static Arc<AtomicUsize> {
694            static SEEN: OnceLock<Arc<AtomicUsize>> = OnceLock::new();
695            SEEN.get_or_init(|| {
696                let seen = Arc::new(AtomicUsize::new(0));
697                let _ = tracing::subscriber::set_global_default(Counter(Arc::clone(&seen)));
698                seen
699            })
700        }
701
702        /// 32 threads released from a barrier all read the same unset flag:
703        /// exactly one of them may log. Rounds rather than one shot, because the
704        /// gap is a window — a single pass can pass by luck.
705        #[test]
706        fn a_warn_once_flag_is_claimed_by_exactly_one_thread() {
707            let seen = seen();
708            for _ in 0..200 {
709                let flag = Arc::new(AtomicBool::new(false));
710                let gate = Arc::new(Barrier::new(32));
711                let threads: Vec<_> = (0..32)
712                    .map(|_| {
713                        let gate = Arc::clone(&gate);
714                        let flag = Arc::clone(&flag);
715                        std::thread::spawn(move || {
716                            gate.wait();
717                            warn_once(&flag, MARKER);
718                        })
719                    })
720                    .collect();
721                for thread in threads {
722                    thread.join().expect("thread");
723                }
724                let logged = seen.swap(0, Ordering::SeqCst);
725                assert_eq!(logged, 1, "{logged} threads logged a once-only warning");
726            }
727        }
728    }
729}