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}