Skip to main content

toolkit/runtime/
host_runtime.rs

1//! Host Runtime - orchestrates the full `ToolKit` lifecycle
2//!
3//! This gear contains the `HostRuntime` type that owns and coordinates
4//! the execution of all lifecycle phases.
5//!
6//! High-level phase order:
7//! - `pre_init` (system gears only)
8//! - DB migrations (gears with DB capability)
9//! - `init` (all gears)
10//! - proxy-wiring (`#[toolkit::consumes]` clients; feature-gated)
11//! - `post_init` (system gears only; runs after *all* `init` complete)
12//! - REST wiring (gears with REST capability; requires a single REST host)
13//! - gRPC registration (gears with gRPC capability; requires a single gRPC hub)
14//! - start/stop (stateful gears)
15//! - `OoP` spawn / wait / stop (host-only orchestration)
16//!
17//! Both lifecycle paths — in-process (`run_phases_internal`) and `OoP`
18//! (`run_oop_serving`) — run these in the same relative order. Proxy-wiring in
19//! particular must stay before `start`: a gear resolving a consumed contract
20//! during its own `start` has to find the client in the `ClientHub` regardless
21//! of profile. The `OoP` path additionally has no REST-wiring, directory-register
22//! or spawn phases (`oop_serve` owns those concerns).
23
24use axum::Router;
25use std::collections::HashSet;
26use std::sync::Arc;
27
28use tokio_util::sync::CancellationToken;
29use uuid::Uuid;
30
31use crate::backends::OopSpawnConfig;
32use crate::client_hub::ClientHub;
33use crate::config::ConfigProvider;
34use crate::context::GearContextBuilder;
35use crate::registry::{
36    ApiGatewayCap, GearEntry, GearRegistry, GrpcHubCap, RegistryError, RestApiCap, RunnableCap,
37    SystemCap,
38};
39use crate::runtime::{GearManager, GrpcInstallerStore, OopSpawnOptions, SystemContext};
40
41#[cfg(feature = "db")]
42use crate::registry::DatabaseCap;
43
44/// How the runtime should provide DBs to gears.
45#[derive(Clone)]
46pub enum DbOptions {
47    /// No database integration. `GearCtx::db()` will be `None`, `db_required()` will error.
48    None,
49    /// Use a `DbManager` to handle database connections with Figment-based configuration.
50    #[cfg(feature = "db")]
51    Manager(Arc<toolkit_db::DbManager>),
52}
53
54/// Runtime execution mode that determines which phases to run.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum RunMode {
57    /// Run all phases and wait for shutdown signal (normal application mode).
58    Full,
59    /// Run only pre-init and DB migration phases, then exit (for cloud deployments).
60    MigrateOnly,
61}
62
63/// Environment variable name for passing directory endpoint to `OoP` gears.
64pub const TOOLKIT_DIRECTORY_ENDPOINT_ENV: &str = "TOOLKIT_DIRECTORY_ENDPOINT";
65
66/// Environment variable name for passing rendered gear config to `OoP` gears.
67pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
68
69/// Default shutdown deadline for graceful gear stop (35 seconds).
70///
71/// This is intentionally 5 seconds longer than `WithLifecycle::stop_timeout` (30s default)
72/// to ensure deterministic behavior: the lifecycle's internal timeout fires first,
73/// and the runtime deadline acts as a hard backstop.
74pub const DEFAULT_SHUTDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(35);
75
76/// Owns the lifecycle orchestration for `ToolKit`: encapsulates all runtime
77/// state and drives gears through the full lifecycle (see gear docs).
78pub struct HostRuntime {
79    registry: GearRegistry,
80    ctx_builder: GearContextBuilder,
81    instance_id: Uuid,
82    gear_manager: Arc<GearManager>,
83    grpc_installers: Arc<GrpcInstallerStore>,
84    client_hub: Arc<ClientHub>,
85    /// Per-gear config, retained for the proxy-wiring phase to read a consumer's
86    /// static-endpoint override (dev/test escape hatch, ADR-0004).
87    gears_cfg: Arc<dyn ConfigProvider>,
88    /// Process-level dependency-resolution + draining signal, published in
89    /// `client_hub` for the `/readyz` probe and updated by the proxy-wiring
90    /// readiness loop + draining watcher. In-process, an
91    /// [`ReadinessHealthcheck`](super::readiness::ReadinessHealthcheck) leaf
92    /// bridges it into the gateway's healthcheck registry.
93    dep_checker: Arc<super::readiness::DependencyChecker>,
94    /// Set once the in-process directory-register phase has advertised this
95    /// process's REST providers, so shutdown deregisters them exactly once — and
96    /// only in the in-process host path. In `OoP` serving, presence + deregister
97    /// is owned by `oop_serve`, so this stays `false` and avoids a double
98    /// deregister.
99    rest_providers_registered: std::sync::atomic::AtomicBool,
100    cancel: CancellationToken,
101    #[allow(dead_code)]
102    db_options: DbOptions,
103    /// `OoP` gear spawn configuration and backend
104    oop_options: Option<OopSpawnOptions>,
105    /// Maximum time allowed for graceful shutdown before hard-stop signal is sent.
106    shutdown_deadline: std::time::Duration,
107}
108
109impl HostRuntime {
110    /// Create a new `HostRuntime` instance.
111    ///
112    /// This prepares all runtime components but does not start any lifecycle phases.
113    pub fn new(
114        registry: GearRegistry,
115        gears_cfg: Arc<dyn ConfigProvider>,
116        db_options: DbOptions,
117        client_hub: Arc<ClientHub>,
118        cancel: CancellationToken,
119        instance_id: Uuid,
120        oop_options: Option<OopSpawnOptions>,
121    ) -> Self {
122        // Create runtime-owned components for system gears
123        let gear_manager = Arc::new(GearManager::new());
124        let grpc_installers = Arc::new(GrpcInstallerStore::new());
125
126        // Process-level dependency/draining signal, published so the gateway's
127        // /readyz handler can fetch it (concrete-type key). Created before any
128        // phase runs.
129        let dep_checker = Arc::new(super::readiness::DependencyChecker::new());
130        client_hub.register::<super::readiness::DependencyChecker>(dep_checker.clone());
131
132        // Build the context builder that will resolve per-gear DbHandles
133        let ctx_builder = GearContextBuilder::new(
134            instance_id,
135            gears_cfg.clone(),
136            client_hub.clone(),
137            cancel.clone(),
138        );
139        #[cfg(feature = "db")]
140        let ctx_builder = match &db_options {
141            DbOptions::Manager(mgr) => ctx_builder.with_db_manager(mgr.clone()),
142            DbOptions::None => ctx_builder,
143        };
144
145        Self {
146            registry,
147            ctx_builder,
148            instance_id,
149            gear_manager,
150            grpc_installers,
151            client_hub,
152            gears_cfg,
153            dep_checker,
154            rest_providers_registered: std::sync::atomic::AtomicBool::new(false),
155            cancel,
156            db_options,
157            oop_options,
158            shutdown_deadline: DEFAULT_SHUTDOWN_DEADLINE,
159        }
160    }
161
162    /// Set a custom shutdown deadline for graceful gear stop.
163    ///
164    /// This is the maximum time the runtime will wait for each gear to stop gracefully
165    /// before sending the hard-stop signal (cancelling the deadline token).
166    ///
167    /// # Relationship with `WithLifecycle::stop_timeout`
168    ///
169    /// When using `WithLifecycle`, its `stop_timeout` (default 30s) races against this
170    /// `shutdown_deadline` (default [`DEFAULT_SHUTDOWN_DEADLINE`], 35s). To ensure
171    /// deterministic behavior:
172    ///
173    /// - `WithLifecycle::stop_timeout` should be **less than** `shutdown_deadline`
174    /// - This allows the lifecycle's internal timeout to trigger first for graceful cleanup
175    /// - The runtime's `deadline_token` then acts as a hard backstop
176    ///
177    /// Example: `stop_timeout = 30s`, `shutdown_deadline = 35s`
178    #[must_use]
179    pub fn with_shutdown_deadline(mut self, deadline: std::time::Duration) -> Self {
180        self.shutdown_deadline = deadline;
181        self
182    }
183
184    /// Set the process-wide platform-plane credential source, applied to every
185    /// [`GearCtx`](crate::context::GearCtx) this runtime builds so
186    /// `#[toolkit::provides]`-generated clients attach `X-ToolKit-Internal-Token`
187    /// on platform-plane methods. `None` (Profile 1 / in-process, or no
188    /// credential configured) attaches nothing.
189    #[must_use]
190    pub fn with_internal_token_provider(
191        mut self,
192        provider: Option<toolkit_contract::runtime::config::InternalTokenProvider>,
193    ) -> Self {
194        self.ctx_builder = self.ctx_builder.with_internal_token_provider(provider);
195        self
196    }
197
198    /// `PRE_INIT` phase: wire runtime internals into system gears.
199    ///
200    /// This phase runs before init and only for gears with the "system" capability.
201    ///
202    /// # Errors
203    /// Returns `RegistryError` if system wiring fails.
204    pub fn run_pre_init_phase(&self) -> Result<(), RegistryError> {
205        tracing::info!("Phase: pre_init");
206
207        let sys_ctx = SystemContext::new(
208            self.instance_id,
209            Arc::clone(&self.gear_manager),
210            Arc::clone(&self.grpc_installers),
211        );
212
213        for entry in self.registry.gears() {
214            // Check for cancellation before processing each gear
215            if self.cancel.is_cancelled() {
216                tracing::warn!("Pre-init phase cancelled by signal");
217                return Err(RegistryError::Cancelled);
218            }
219
220            if let Some(sys_mod) = entry.caps.query::<SystemCap>() {
221                tracing::debug!(gear = entry.name, "Running system pre_init");
222                sys_mod
223                    .pre_init(&sys_ctx)
224                    .map_err(|e| RegistryError::PreInit {
225                        gear: entry.name,
226                        source: e,
227                    })?;
228            }
229        }
230
231        Ok(())
232    }
233
234    /// Helper: resolve context for a gear with error mapping.
235    #[cfg(feature = "db")]
236    async fn gear_context(
237        &self,
238        gear_name: &'static str,
239    ) -> Result<crate::context::GearCtx, RegistryError> {
240        self.ctx_builder
241            .for_gear(gear_name)
242            .await
243            .map_err(|e| RegistryError::DbMigrate {
244                gear: gear_name,
245                source: e,
246            })
247    }
248
249    /// Helper: extract DB handle and gear if both exist.
250    #[cfg(feature = "db")]
251    async fn db_migration_target(
252        &self,
253        gear_name: &'static str,
254        ctx: &crate::context::GearCtx,
255        db_gear: Option<Arc<dyn crate::contracts::DatabaseCapability>>,
256    ) -> Result<
257        Option<(
258            toolkit_db::Db,
259            Arc<dyn crate::contracts::DatabaseCapability>,
260        )>,
261        RegistryError,
262    > {
263        let Some(dbm) = db_gear else {
264            return Ok(None);
265        };
266
267        // Important: DB migrations require access to the underlying `Db`, not just `DBProvider`.
268        // `GearCtx` intentionally exposes only `DBProvider` for better DX and to reduce mistakes.
269        // So the runtime resolves the `Db` directly from its `DbManager`.
270        let db = match &self.db_options {
271            DbOptions::None => None,
272            #[cfg(feature = "db")]
273            DbOptions::Manager(mgr) => {
274                mgr.get(gear_name)
275                    .await
276                    .map_err(|e| RegistryError::DbMigrate {
277                        gear: gear_name,
278                        source: e.into(),
279                    })?
280            }
281        };
282
283        _ = ctx; // ctx is kept for parity/error context; DB is resolved from manager above.
284        Ok(db.map(|db| (db, dbm)))
285    }
286
287    /// Helper: run migrations for a single gear using the new migration runner.
288    ///
289    /// This collects migrations from the gear and executes them via the
290    /// runtime's privileged connection. Gears never see the raw connection.
291    #[cfg(feature = "db")]
292    async fn migrate_gear(
293        gear_name: &'static str,
294        db: &toolkit_db::Db,
295        db_gear: Arc<dyn crate::contracts::DatabaseCapability>,
296    ) -> Result<(), RegistryError> {
297        // Collect migrations from the gear
298        let migrations = db_gear.migrations();
299
300        if migrations.is_empty() {
301            tracing::debug!(gear = gear_name, "No migrations to run");
302            return Ok(());
303        }
304
305        tracing::debug!(
306            gear = gear_name,
307            count = migrations.len(),
308            "Running DB migrations"
309        );
310
311        // Execute migrations using the migration runner
312        let result =
313            toolkit_db::migration_runner::run_migrations_for_gear(db, gear_name, migrations)
314                .await
315                .map_err(|e| RegistryError::DbMigrate {
316                    gear: gear_name,
317                    source: anyhow::Error::new(e),
318                })?;
319
320        tracing::info!(
321            gear = gear_name,
322            applied = result.applied,
323            skipped = result.skipped,
324            "DB migrations completed"
325        );
326
327        Ok(())
328    }
329
330    /// DB MIGRATION phase: run migrations for all gears with DB capability.
331    ///
332    /// Runs before init, with system gears processed first.
333    ///
334    /// Gears provide migrations via `DatabaseCapability::migrations()`.
335    /// The runtime executes them with a privileged connection that gears
336    /// never receive directly. Each gear gets a separate migration history
337    /// table, preventing cross-gear interference.
338    #[cfg(feature = "db")]
339    async fn run_db_phase(&self) -> Result<(), RegistryError> {
340        tracing::info!("Phase: db (before init)");
341
342        for entry in self.registry.gears_by_system_priority() {
343            // Check for cancellation before processing each gear
344            if self.cancel.is_cancelled() {
345                tracing::warn!("DB migration phase cancelled by signal");
346                return Err(RegistryError::Cancelled);
347            }
348
349            let ctx = self.gear_context(entry.name).await?;
350            let db_gear = entry.caps.query::<DatabaseCap>();
351
352            match self
353                .db_migration_target(entry.name, &ctx, db_gear.clone())
354                .await?
355            {
356                Some((db, dbm)) => {
357                    Self::migrate_gear(entry.name, &db, dbm).await?;
358                }
359                None if db_gear.is_some() => {
360                    tracing::debug!(
361                        gear = entry.name,
362                        "Gear has DbGear trait but no DB handle (no config)"
363                    );
364                }
365                None => {}
366            }
367        }
368
369        Ok(())
370    }
371
372    /// INIT phase: initialize all gears in topological order.
373    ///
374    /// System gears initialize first, followed by user gears.
375    async fn run_init_phase(&self) -> Result<(), RegistryError> {
376        tracing::info!("Phase: init");
377
378        for entry in self.registry.gears_by_system_priority() {
379            let ctx =
380                self.ctx_builder
381                    .for_gear(entry.name)
382                    .await
383                    .map_err(|e| RegistryError::Init {
384                        gear: entry.name,
385                        source: e,
386                    })?;
387            tracing::info!(gear = entry.name, "Initializing a gear...");
388            entry
389                .core
390                .init(&ctx)
391                .await
392                .map_err(|e| RegistryError::Init {
393                    gear: entry.name,
394                    source: e,
395                })?;
396            tracing::info!(gear = entry.name, "Initialized a gear.");
397        }
398
399        Ok(())
400    }
401
402    /// `POST_INIT` phase: optional hook after ALL gears completed `init()`.
403    ///
404    /// Consumer proxy-wiring phase (eventual readiness).
405    ///
406    /// Runs after init (compile-time / local registrations) and before
407    /// post-init. Replays each `ConsumerRegistration` emitted by
408    /// `#[toolkit::consumes]`: if a compile-time impl is already in the
409    /// `ClientHub` it wins (the wiring closure short-circuits); otherwise a
410    /// directory-resolving REST client is registered under the contract trait.
411    ///
412    /// Non-blocking: endpoint discovery is lazy/per-call inside the resolving
413    /// client, so this phase never waits on provider availability (ADR-0007).
414    /// A no-op when no consumer is registered, preserving the phase-order
415    /// invariants relied on by existing tests.
416    #[allow(
417        clippy::unused_async,
418        reason = "kept async for symmetry with the other `run_*_phase` steps awaited in sequence by `run_gear_phases`; the awaited work runs in a spawned readiness-probe task"
419    )]
420    async fn run_proxy_wiring_phase(&self) -> Result<(), RegistryError> {
421        let regs: Vec<&crate::discovery::ConsumerRegistration> =
422            inventory::iter::<crate::discovery::ConsumerRegistration>
423                .into_iter()
424                .collect();
425        self.wire_consumer_registrations(&regs)
426    }
427
428    /// The body of the proxy-wiring phase, split from [`Self::run_proxy_wiring_phase`]
429    /// so tests can drive it with a locally-built registration instead of the
430    /// link-time-global `inventory` set (which would leak a dep onto other tests).
431    ///
432    /// Sync, not `async`: the readiness probe is `tokio::spawn`ed, not awaited.
433    fn wire_consumer_registrations(
434        &self,
435        regs: &[&crate::discovery::ConsumerRegistration],
436    ) -> Result<(), RegistryError> {
437        use crate::discovery::{DirectoryEndpointResolver, NullEndpointResolver};
438        use toolkit_contract::runtime::resolving::EndpointResolver;
439
440        if regs.is_empty() {
441            return Ok(());
442        }
443        tracing::info!(
444            count = regs.len(),
445            "Phase: proxy-wiring (consumer discovery)"
446        );
447
448        // Without a DirectoryClient we cannot resolve *remote* providers, but we
449        // must NOT silently skip wiring: co-located consumers still short-circuit
450        // to their local impl, and remote consumers must register as unresolved
451        // readiness gates so `/readyz` stays 503 (a misconfigured consumer must
452        // not report Ready). A null resolver makes every remote lookup `Ok(None)`
453        // so the loop below classifies deps correctly without a directory.
454        let (resolver, have_directory): (Arc<dyn EndpointResolver>, bool) =
455            if let Ok(dir) = self.client_hub.get::<dyn crate::DirectoryClient>() {
456                (Arc::new(DirectoryEndpointResolver::new(dir)), true)
457            } else {
458                tracing::error!(
459                    consumers = regs.len(),
460                    "proxy-wiring: no DirectoryClient in ClientHub; remote consumer \
461                     dependencies cannot be resolved and will gate /readyz (503). \
462                     Co-located (local) dependencies are unaffected."
463                );
464                (Arc::new(NullEndpointResolver), false)
465            };
466
467        // Wire each consumer contract. The outcome distinguishes a co-located
468        // local impl (hub short-circuit) from a directory-resolving REST client.
469        // Every dep is registered as a readiness gate; local ones are marked
470        // resolved immediately, and only remote ones gate readiness + get the
471        // background directory-resolve loop (ADR-0007: startup-gating + sticky).
472        // `owner_gear` is derived by `#[toolkit::consumes]` from the struct
473        // ident, while `#[toolkit::gear(name = ...)]` sets the registry name
474        // independently. When they diverge, wiring still works (the loop below
475        // does not filter on owner) but the static-override config key silently
476        // resolves to nothing. Say so rather than leaving the operator to wonder
477        // why `consumer_wiring` is ignored.
478        let known_gears: std::collections::HashSet<&str> =
479            self.registry.gears().iter().map(GearEntry::name).collect();
480        for reg in regs {
481            if !known_gears.contains(reg.owner_gear) {
482                tracing::warn!(
483                    owner = reg.owner_gear,
484                    dep = reg.dep_gear,
485                    "proxy-wiring: consumer's owner gear name does not match any registered gear; \
486                     the `gears.{}.config.consumer_wiring.{}` static override will never resolve. \
487                     Rename the gear to the kebab-case of its struct ident.",
488                    reg.owner_gear,
489                    reg.dep_gear,
490                );
491            }
492        }
493
494        let mut remote_deps: Vec<String> = Vec::new();
495        for reg in regs {
496            // Read the consumer's `consumer_wiring.<dep>` entry (if any) and
497            // split it into an optional static-endpoint override and the
498            // per-deployment `ClientTuning` (timeout/retry/pool/concurrency).
499            let (endpoint_override, mut tuning) =
500                match read_consumer_wiring(self.gears_cfg.as_ref(), reg.owner_gear, reg.dep_gear)
501                    .map_err(|source| RegistryError::ProxyWiring {
502                        gear: reg.owner_gear,
503                        source,
504                    })? {
505                    Some(wiring) => wiring.into_parts(),
506                    None => (None, toolkit_contract::wiring::ClientTuning::default()),
507                };
508
509            // Thread the process's platform-plane credential onto the tuning so
510            // the wired (directory-resolving) client's platform-plane methods
511            // attach `X-ToolKit-Internal-Token` (`cpt-cf-adr-two-plane-auth`).
512            // This is the genuine remote inter-gear path (Profile 2/3); a
513            // co-located local impl short-circuits before the credential is used.
514            tuning = tuning
515                .with_internal_token_provider(self.ctx_builder.internal_token_provider().cloned());
516
517            // ADR-0004 static-endpoint override (dev/test escape hatch): a
518            // configured `endpoint` wires the dep directly via a
519            // `StaticEndpointResolver`, bypassing discovery. It was validated as an
520            // absolute URI at parse time (invalid ones aborted above) and is
521            // resolvable by construction, so no probe loop is needed. Warned — must
522            // not be used in production.
523            let is_static = endpoint_override.is_some();
524            let reg_resolver: Arc<dyn EndpointResolver> = if let Some(endpoint) = &endpoint_override
525            {
526                tracing::warn!(
527                    owner = reg.owner_gear,
528                    dep = reg.dep_gear,
529                    endpoint = %endpoint,
530                    "proxy-wiring: STATIC endpoint override in use (ADR-0004 dev/test \
531                     escape hatch) - bypasses service discovery; MUST NOT be used in \
532                     production"
533                );
534                Arc::new(crate::discovery::StaticEndpointResolver::new(
535                    endpoint.clone(),
536                ))
537            } else {
538                Arc::clone(&resolver)
539            };
540
541            let outcome = (reg.wire)(&self.client_hub, reg_resolver, tuning).map_err(|source| {
542                RegistryError::ProxyWiring {
543                    gear: reg.owner_gear,
544                    source,
545                }
546            })?;
547            self.dep_checker.register_dep(reg.dep_gear.to_owned());
548            match outcome {
549                // Local impl won the hub short-circuit — resolved.
550                crate::discovery::WireOutcome::Local => {
551                    self.dep_checker.mark_resolved(reg.dep_gear);
552                }
553                // Static override → fixed endpoint, always resolvable — no probe.
554                crate::discovery::WireOutcome::Remote if is_static => {
555                    self.dep_checker.mark_resolved(reg.dep_gear);
556                }
557                // Directory-resolved remote → gate readiness + background probe.
558                crate::discovery::WireOutcome::Remote => remote_deps.push(reg.dep_gear.to_owned()),
559            }
560            tracing::debug!(
561                owner = reg.owner_gear,
562                dep = reg.dep_gear,
563                outcome = ?outcome,
564                static_override = is_static,
565                "wired consumer contract"
566            );
567        }
568
569        // Only remote deps need directory resolution; local wins are already
570        // resolved above. Without a directory the null resolver can never resolve
571        // them, so skip the probe entirely and leave those deps gating /readyz.
572        if !have_directory || remote_deps.is_empty() {
573            return Ok(());
574        }
575
576        let readiness = Arc::clone(&self.dep_checker);
577        let cancel = self.cancel.clone();
578        tokio::spawn(async move {
579            const BASE: std::time::Duration = std::time::Duration::from_millis(100);
580            const MAX: std::time::Duration = std::time::Duration::from_secs(30);
581            let mut pending = remote_deps;
582            let mut backoff = BASE;
583            while !pending.is_empty() {
584                let mut still_pending = Vec::new();
585                for dep in pending {
586                    match resolver.resolve_endpoint(&dep).await {
587                        Ok(Some(_)) => {
588                            readiness.mark_resolved(&dep);
589                            tracing::info!(dep = %dep, "readiness: dependency resolved");
590                        }
591                        // No live instance yet — expected during startup churn.
592                        Ok(None) => still_pending.push(dep),
593                        // Genuine directory-backend failure: surface it (a stuck
594                        // Starting / 503 pod otherwise has no diagnostic trail).
595                        Err(e) => {
596                            tracing::warn!(dep = %dep, error = %e, "readiness: directory lookup failed");
597                            still_pending.push(dep);
598                        }
599                    }
600                }
601                pending = still_pending;
602                if pending.is_empty() {
603                    break;
604                }
605                tokio::select! {
606                    () = cancel.cancelled() => break,
607                    () = tokio::time::sleep(backoff) => {}
608                }
609                backoff = (backoff * 2).min(MAX);
610            }
611        });
612
613        Ok(())
614    }
615
616    /// This provides a global barrier between initialization-time registration
617    /// and subsequent phases that may rely on a fully-populated runtime registry.
618    /// `init` -> proxy-wiring -> `post_init`, the segment both lifecycle paths
619    /// share.
620    ///
621    /// Extracted so the two paths cannot disagree on it. They did once: the
622    /// `OoP` path ran proxy-wiring *after* `start`, because the call was dropped
623    /// where a retired untyped `resolve_deps` stopgap used to sit rather than
624    /// chosen deliberately. A gear resolving a consumed contract during its own
625    /// `start` then found nothing in the `ClientHub` under Profile 2/3 while
626    /// working fine in Profile 1.
627    ///
628    /// Everything after this segment legitimately differs — the in-process path
629    /// composes a REST router, the `OoP` path leaves that to `oop_serve`.
630    ///
631    /// Keeping this a single private method is what enforces the invariant: if
632    /// a future change inlines the three calls back into both paths, this method
633    /// becomes dead code and the workspace's `-D warnings` build fails.
634    async fn run_init_wiring_post_init(&self) -> Result<(), RegistryError> {
635        self.run_init_phase().await?;
636
637        self.run_proxy_wiring_phase().await?;
638
639        self.run_post_init_phase().await
640    }
641
642    ///
643    /// System gears run first, followed by user gears, preserving topo order.
644    async fn run_post_init_phase(&self) -> Result<(), RegistryError> {
645        tracing::info!("Phase: post_init");
646
647        let sys_ctx = SystemContext::new(
648            self.instance_id,
649            Arc::clone(&self.gear_manager),
650            Arc::clone(&self.grpc_installers),
651        );
652
653        for entry in self.registry.gears_by_system_priority() {
654            if let Some(sys_mod) = entry.caps.query::<SystemCap>() {
655                sys_mod
656                    .post_init(&sys_ctx)
657                    .await
658                    .map_err(|e| RegistryError::PostInit {
659                        gear: entry.name,
660                        source: e,
661                    })?;
662            }
663        }
664
665        Ok(())
666    }
667
668    /// REST phase: compose the router against the REST host.
669    ///
670    /// This is a synchronous phase that builds the final Router by:
671    /// 1. Preparing the host gear
672    /// 2. Registering all REST providers
673    /// 3. Finalizing with `OpenAPI` endpoints
674    async fn run_rest_phase(&self) -> Result<Router, RegistryError> {
675        tracing::info!("Phase: rest (sync)");
676
677        let mut router = Router::new();
678
679        // Find host(s) and whether any rest gears exist
680        let host_count = self
681            .registry
682            .gears()
683            .iter()
684            .filter(|e| e.caps.has::<ApiGatewayCap>())
685            .count();
686
687        match host_count {
688            0 => {
689                return if self
690                    .registry
691                    .gears()
692                    .iter()
693                    .any(|e| e.caps.has::<RestApiCap>())
694                {
695                    Err(RegistryError::RestRequiresHost)
696                } else {
697                    Ok(router)
698                };
699            }
700            1 => { /* proceed */ }
701            _ => return Err(RegistryError::MultipleRestHosts),
702        }
703
704        // Resolve the single host entry and its gear context
705        let host_idx = self
706            .registry
707            .gears()
708            .iter()
709            .position(|e| e.caps.has::<ApiGatewayCap>())
710            .ok_or(RegistryError::RestHostNotFoundAfterValidation)?;
711        let host_entry = &self.registry.gears()[host_idx];
712        let Some(host) = host_entry.caps.query::<ApiGatewayCap>() else {
713            return Err(RegistryError::RestHostMissingFromEntry);
714        };
715        let host_ctx = self
716            .ctx_builder
717            .for_gear(host_entry.name)
718            .await
719            .map_err(|e| RegistryError::RestPrepare {
720                gear: host_entry.name,
721                source: e,
722            })?;
723
724        // use host as the registry
725        let registry: &dyn crate::contracts::OpenApiRegistry = host.as_registry();
726
727        // Healthcheck registry, passed explicitly to the REST host and providers below
728        // (not via ClientHub). Seeded with the host's shutdown token so in-flight checks
729        // are aborted on shutdown.
730        let hc_registry = Arc::new(
731            crate::healthcheck::RestHealthcheckRegistry::with_cancellation(
732                host_ctx.cancellation_token().clone(),
733            ),
734        );
735
736        // Bridge the process-level eventual-readiness state into the served
737        // probe: while any consumed dependency is unresolved (or the process is
738        // draining) this synthetic check reports Unhealthy, so the gateway's
739        // `/readyz` returns 503 until all `#[toolkit::consumes]` deps are wired.
740        // (`/healthz` is a static liveness handler and is unaffected.)
741        hc_registry.register(
742            "readiness",
743            Arc::new(super::readiness::ReadinessHealthcheck::new(
744                self.dep_checker.clone(),
745            )),
746        );
747
748        // 1) Host prepare: base Router / global middlewares / basic OAS meta
749        router = host
750            .rest_prepare(&host_ctx, router, hc_registry.clone())
751            .map_err(|source| RegistryError::RestPrepare {
752                gear: host_entry.name,
753                source,
754            })?;
755
756        // 2) Register all REST providers (in the current discovery order)
757        for e in self.registry.gears() {
758            if let Some(rest) = e.caps.query::<RestApiCap>() {
759                let ctx = self.ctx_builder.for_gear(e.name).await.map_err(|err| {
760                    RegistryError::RestRegister {
761                        gear: e.name,
762                        source: err,
763                    }
764                })?;
765
766                router = rest
767                    .register_rest(&ctx, router, registry)
768                    .map_err(|source| RegistryError::RestRegister {
769                        gear: e.name,
770                        source,
771                    })?;
772
773                // Register the gear's readiness healthcheck after successful route registration.
774                if let Some(hc) = rest.healthcheck(&ctx) {
775                    hc_registry.register(e.name, hc);
776                }
777            }
778        }
779
780        // 3) Host finalize: attach /openapi.json and /docs, persist Router if needed (no server start)
781        router = host
782            .rest_finalize(&host_ctx, router, hc_registry)
783            .map_err(|source| RegistryError::RestFinalize {
784                gear: host_entry.name,
785                source,
786            })?;
787
788        Ok(router)
789    }
790
791    /// gRPC registration phase: collect services from all grpc gears.
792    ///
793    /// Services are stored in the installer store for the `grpc-hub` to consume during start.
794    async fn run_grpc_phase(&self) -> Result<(), RegistryError> {
795        tracing::info!("Phase: grpc (registration)");
796
797        // If no grpc_hub and no grpc_services, skip the phase
798        if self.registry.grpc_hub.is_none() && self.registry.grpc_services.is_empty() {
799            return Ok(());
800        }
801
802        // If there are grpc_services but no hub, that's an error
803        if self.registry.grpc_hub.is_none() && !self.registry.grpc_services.is_empty() {
804            return Err(RegistryError::GrpcRequiresHub);
805        }
806
807        // If there's a hub, collect all services grouped by gear and hand them off to the installer store
808        if let Some(hub_name) = &self.registry.grpc_hub {
809            let mut gears_data = Vec::new();
810            let mut seen = HashSet::new();
811            // Compiled-in service_name -> owning-gear map, installed as
812            // authoritative ownership below (see
813            // `GearManager::merge_authoritative_grpc_service_owners`).
814            let mut owners: std::collections::HashMap<String, String> =
815                std::collections::HashMap::new();
816
817            // Collect services from all grpc gears
818            for (gear_name, service_gear) in &self.registry.grpc_services {
819                let ctx = self.ctx_builder.for_gear(gear_name).await.map_err(|err| {
820                    RegistryError::GrpcRegister {
821                        gear: gear_name.clone(),
822                        source: err,
823                    }
824                })?;
825
826                let installers = service_gear
827                    .get_grpc_services(&ctx)
828                    .await
829                    .map_err(|source| RegistryError::GrpcRegister {
830                        gear: gear_name.clone(),
831                        source,
832                    })?;
833
834                for reg in &installers {
835                    if !seen.insert(reg.service_name) {
836                        return Err(RegistryError::GrpcRegister {
837                            gear: gear_name.clone(),
838                            source: anyhow::anyhow!(
839                                "Duplicate gRPC service name: {}",
840                                reg.service_name
841                            ),
842                        });
843                    }
844                    owners.insert(reg.service_name.to_owned(), gear_name.clone());
845                }
846
847                gears_data.push(crate::runtime::GearInstallers {
848                    gear_name: gear_name.clone(),
849                    installers,
850                });
851            }
852
853            // Ordering matters: this runs after service-discovery's init seeded
854            // operator config and before the directory-register / OoP-spawn
855            // phases, so ownership is pinned before the first self-registration.
856            self.gear_manager
857                .merge_authoritative_grpc_service_owners(owners);
858
859            self.grpc_installers
860                .set(crate::runtime::GrpcInstallerData { gears: gears_data })
861                .map_err(|source| RegistryError::GrpcRegister {
862                    gear: hub_name.clone(),
863                    source,
864                })?;
865        }
866
867        Ok(())
868    }
869
870    /// START phase: start all stateful gears.
871    ///
872    /// System gears start first, followed by user gears.
873    async fn run_start_phase(&self) -> Result<(), RegistryError> {
874        tracing::info!("Phase: start");
875
876        for e in self.registry.gears_by_system_priority() {
877            if let Some(s) = e.caps.query::<RunnableCap>() {
878                tracing::debug!(
879                    gear = e.name,
880                    is_system = e.caps.has::<SystemCap>(),
881                    "Starting stateful gear"
882                );
883                s.start(self.cancel.clone())
884                    .await
885                    .map_err(|source| RegistryError::Start {
886                        gear: e.name,
887                        source,
888                    })?;
889                tracing::info!(gear = e.name, "Started gear");
890            }
891        }
892
893        Ok(())
894    }
895
896    /// Stop a single gear, logging errors but continuing execution.
897    async fn stop_one_gear(entry: &GearEntry, cancel: CancellationToken) {
898        if let Some(s) = entry.caps.query::<RunnableCap>() {
899            match s.stop(cancel).await {
900                Err(err) => {
901                    tracing::warn!(gear =  entry.name, error = %err, "Failed to stop gear");
902                }
903                _ => {
904                    tracing::info!(gear = entry.name, "Stopped gear");
905                }
906            }
907        }
908    }
909
910    /// STOP phase: stop all stateful gears in reverse order.
911    ///
912    /// # Two-Phase Shutdown Contract
913    ///
914    /// This phase implements a proper two-phase shutdown for **each gear**:
915    ///
916    /// 1. **Graceful stop request**: Each gear's `stop(deadline_token)` is called with a
917    ///    *fresh* cancellation token (not the already-cancelled root token). Gears should
918    ///    interpret this as "please stop gracefully".
919    ///
920    /// 2. **Hard-stop deadline**: After `shutdown_deadline` expires **for that gear**,
921    ///    its `deadline_token` is cancelled. Gears should interpret this as "abort immediately".
922    ///
923    /// Each gear gets its own independent deadline — if gear A takes 25s to stop,
924    /// gear B still gets the full `shutdown_deadline` for its graceful shutdown.
925    ///
926    /// This allows gears to implement real graceful shutdown:
927    /// - Request cooperative shutdown of child tasks
928    /// - Wait for them to finish gracefully
929    /// - If `deadline_token` fires, switch to hard-abort mode
930    ///
931    /// Errors are logged but do not fail the shutdown process.
932    /// Note: `OoP` gears are stopped automatically by the backend when the
933    /// cancellation token is triggered.
934    async fn run_stop_phase(&self) -> Result<(), RegistryError> {
935        tracing::info!("Phase: stop");
936
937        // Drop our REST providers from the directory first, so consumers stop
938        // resolving an endpoint that is about to disappear.
939        self.deregister_rest_providers().await;
940
941        let deadline = self.shutdown_deadline;
942
943        // Stop all gears in reverse order, each with its own independent deadline
944        for e in self.registry.gears().iter().rev() {
945            let gear_name = e.name;
946
947            // Create a fresh deadline token for THIS gear
948            // Each gear gets the full shutdown_deadline independently
949            let deadline_token = CancellationToken::new();
950            let deadline_token_for_timeout = deadline_token.clone();
951
952            // Spawn a task to cancel this gear's deadline token after shutdown_deadline
953            let deadline_task = tokio::spawn(async move {
954                tokio::time::sleep(deadline).await;
955                tracing::warn!(
956                    gear = gear_name,
957                    deadline_secs = deadline.as_secs(),
958                    "Gear shutdown deadline reached, sending hard-stop signal"
959                );
960                deadline_token_for_timeout.cancel();
961            });
962
963            // Stop this gear with its own deadline token
964            // The gear can observe the token transition from uncancelled→cancelled
965            Self::stop_one_gear(e, deadline_token).await;
966
967            // Cancel the deadline task and await it to ensure full cleanup
968            deadline_task.abort();
969            #[allow(clippy::let_underscore_must_use)]
970            let _ = deadline_task.await;
971        }
972
973        Ok(())
974    }
975
976    /// Run the stop phase with a watchdog that force-exits the process if the
977    /// stop phase hangs on a blocking syscall. The watchdog is disarmed whether
978    /// the stop phase succeeds or fails, so a failing stop phase does not leave
979    /// the watchdog active and eventually force-exit the process.
980    async fn run_stop_phase_guarded(&self) -> Result<(), RegistryError> {
981        let gear_count = u32::try_from(self.registry.gears().len().max(1)).unwrap_or(1);
982        let stop_timeout = self
983            .shutdown_deadline
984            .checked_mul(gear_count)
985            .and_then(|d| d.checked_add(std::time::Duration::from_secs(5)))
986            .unwrap_or(self.shutdown_deadline);
987
988        // Use a channel to arm/disarm the watchdog. If the lifecycle future is
989        // dropped before the stop phase finishes (e.g. an outer timeout), the
990        // sender is dropped and the watchdog exits without killing the process.
991        let (disarm_tx, disarm_rx) = std::sync::mpsc::channel::<()>();
992        std::thread::spawn(move || {
993            match disarm_rx.recv_timeout(stop_timeout) {
994                Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
995                    // Stop phase completed, or the lifecycle future was cancelled.
996                }
997                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
998                    tracing::warn!(
999                        timeout_secs = stop_timeout.as_secs(),
1000                        "shutdown: stop phase timed out, force exiting"
1001                    );
1002                    std::process::exit(1);
1003                }
1004            }
1005        });
1006
1007        let stop_result = self.run_stop_phase().await;
1008        // Disarm the watchdog before propagating the stop-phase result. This runs
1009        // for both success and failure so a failing stop phase does not leave the
1010        // watchdog armed and eventually force-exit the process.
1011        let _ = disarm_tx.send(()).ok();
1012
1013        stop_result
1014    }
1015
1016    /// `OoP` SPAWN phase: spawn out-of-process gears after start phase.
1017    ///
1018    /// This phase runs after `grpc-hub` is already listening, so we can pass
1019    /// the real directory endpoint to `OoP` gears.
1020    async fn run_oop_spawn_phase(&self) -> Result<(), RegistryError> {
1021        let oop_opts = match &self.oop_options {
1022            Some(opts) if !opts.gears.is_empty() => opts,
1023            _ => return Ok(()),
1024        };
1025
1026        tracing::info!("Phase: oop_spawn");
1027
1028        // Wait for grpc_hub to publish its endpoint (it runs async in start phase)
1029        let directory_endpoint = self.wait_for_grpc_hub_endpoint().await;
1030
1031        for gear_cfg in &oop_opts.gears {
1032            // Build environment with directory endpoint and rendered config
1033            // Note: User controls --config via execution.args in master config
1034            let mut env = gear_cfg.env.clone();
1035            env.insert(
1036                TOOLKIT_MODULE_CONFIG_ENV.to_owned(),
1037                gear_cfg.rendered_config_json.clone(),
1038            );
1039            if let Some(ref endpoint) = directory_endpoint {
1040                env.insert(TOOLKIT_DIRECTORY_ENDPOINT_ENV.to_owned(), endpoint.clone());
1041            }
1042
1043            // Use args from execution config as-is (user controls --config via args)
1044            let args = gear_cfg.args.clone();
1045
1046            let spawn_config = OopSpawnConfig {
1047                gear_name: gear_cfg.gear_name.clone(),
1048                binary: gear_cfg.binary.clone(),
1049                args,
1050                env,
1051                working_directory: gear_cfg.working_directory.clone(),
1052            };
1053
1054            oop_opts
1055                .backend
1056                .spawn(spawn_config)
1057                .await
1058                .map_err(|e| RegistryError::OopSpawn {
1059                    gear: gear_cfg.gear_name.clone(),
1060                    source: e,
1061                })?;
1062
1063            tracing::info!(
1064                gear =  %gear_cfg.gear_name,
1065                directory_endpoint = ?directory_endpoint,
1066                "Spawned OoP gear via backend"
1067            );
1068        }
1069
1070        Ok(())
1071    }
1072
1073    /// Wait for `grpc-hub` to publish its bound endpoint.
1074    ///
1075    /// Polls the `GrpcHubGear::bound_endpoint()` with a short interval until available or timeout.
1076    /// Returns None if no `grpc-hub` is running or if it times out.
1077    async fn wait_for_grpc_hub_endpoint(&self) -> Option<String> {
1078        const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1079        const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
1080
1081        // Find grpc_hub in registry
1082        let grpc_hub = self
1083            .registry
1084            .gears()
1085            .iter()
1086            .find_map(|e| e.caps.query::<GrpcHubCap>());
1087
1088        let Some(hub) = grpc_hub else {
1089            return None; // No grpc_hub registered
1090        };
1091
1092        let start = std::time::Instant::now();
1093
1094        loop {
1095            if let Some(endpoint) = hub.bound_endpoint() {
1096                tracing::debug!(
1097                    endpoint = %endpoint,
1098                    elapsed_ms = start.elapsed().as_millis(),
1099                    "gRPC hub endpoint available"
1100                );
1101                return Some(endpoint);
1102            }
1103
1104            if hub.serves_no_endpoint() {
1105                tracing::debug!("gRPC hub binds no endpoint");
1106                return None;
1107            }
1108            if start.elapsed() > MAX_WAIT {
1109                tracing::warn!("Timed out waiting for gRPC hub to bind");
1110                return None;
1111            }
1112
1113            tokio::time::sleep(POLL_INTERVAL).await;
1114        }
1115    }
1116
1117    /// Wait for the REST host gateway to publish its bound endpoint.
1118    ///
1119    /// The gateway binds its listener asynchronously in the start phase, so the
1120    /// bound endpoint may not be set the instant the start phase returns; poll
1121    /// with a short interval until available or timeout.
1122    async fn wait_for_rest_endpoint(
1123        &self,
1124        host: &Arc<dyn crate::contracts::ApiGatewayCapability>,
1125    ) -> Option<String> {
1126        const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1127        const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
1128
1129        let start = std::time::Instant::now();
1130        loop {
1131            if let Some(endpoint) = host.bound_endpoint() {
1132                return Some(endpoint);
1133            }
1134            if start.elapsed() > MAX_WAIT {
1135                tracing::warn!("Timed out waiting for REST host to bind");
1136                return None;
1137            }
1138            tokio::time::sleep(POLL_INTERVAL).await;
1139        }
1140    }
1141
1142    /// Directory-register phase (eventual readiness, provider side).
1143    ///
1144    /// After the REST server has bound, advertise every in-process REST provider
1145    /// gear in the service directory under its own gear name, pointing at the
1146    /// shared gateway endpoint. Consumers resolving a provider gear name then
1147    /// receive this endpoint and the gateway routes to the provider's handlers.
1148    ///
1149    /// No-op when there is no REST host or no REST provider gears, so non-REST
1150    /// deployments and existing tests are unaffected. Registers through the
1151    /// `DirectoryClient` in the `ClientHub`, which uniformly targets the
1152    /// in-process directory (`LocalDirectoryClient`) or the central directory
1153    /// (`DirectoryGrpcClient` for `OoP`) depending on what the host wired.
1154    async fn run_directory_register_phase(&self) -> Result<(), RegistryError> {
1155        let rest_gears = self.rest_provider_gears();
1156        if rest_gears.is_empty() {
1157            return Ok(());
1158        }
1159
1160        let Some(host) = self
1161            .registry
1162            .gears()
1163            .iter()
1164            .find_map(|e| e.caps.query::<ApiGatewayCap>())
1165        else {
1166            return Ok(()); // no REST host serving the routes
1167        };
1168
1169        let Some(endpoint) = self.wait_for_rest_endpoint(&host).await else {
1170            tracing::warn!(
1171                "directory-register: REST host endpoint unavailable; skipping REST provider registration"
1172            );
1173            return Ok(());
1174        };
1175
1176        let Ok(dir) = self.client_hub.get::<dyn crate::DirectoryClient>() else {
1177            tracing::debug!(
1178                "directory-register: no DirectoryClient in ClientHub; skipping REST provider registration"
1179            );
1180            return Ok(());
1181        };
1182
1183        let instance_id = self.instance_id.to_string();
1184        for gear in rest_gears {
1185            // The directory keys instances by (gear, instance_id). grpc-hub
1186            // registers this same (gear, instance_id) with its gRPC services, in
1187            // the start phase or later on a retry; the store keeps them when this
1188            // registration carries none (`GearInstance::with_metadata_of`).
1189            // Carrying them forward also carries the version.
1190            //
1191            // Labels are deliberately NOT read-and-rewritten here. Carrying them
1192            // through would make this a cross-process read-modify-write with no
1193            // compare-and-set: any label change committed between the read and
1194            // the write would be silently reverted. Instead we register with an
1195            // empty label set, which `GearInstance::with_metadata_of` treats as
1196            // "preserve the stored labels" — an atomic no-op on labels.
1197            let (grpc_services, version) = match dir.list_instances(gear).await {
1198                Ok(insts) => insts
1199                    .into_iter()
1200                    .find(|i| i.instance_id == instance_id)
1201                    .map(|i| (i.grpc_services, i.version))
1202                    .unwrap_or_default(),
1203                Err(e) => {
1204                    // A failed directory read must not silently drop the
1205                    // carried-forward metadata: log it, then fall back to an
1206                    // empty augmentation so REST registration still proceeds.
1207                    tracing::warn!(
1208                        gear,
1209                        error = %e,
1210                        "directory-register: failed to read existing registration; \
1211                         re-registering with empty grpc_services/version"
1212                    );
1213                    (Vec::new(), None)
1214                }
1215            };
1216            // OpenAPI spec is published separately (grpc-hub start phase); the
1217            // REST-augmentation registration does not carry it. Labels are
1218            // omitted so the store preserves the stored set (see above).
1219            let mut info = crate::RegisterInstanceInfo::new(gear.to_owned(), instance_id.clone())
1220                .with_grpc_services(grpc_services)
1221                .with_rest_endpoint(crate::ServiceEndpoint::new(endpoint.clone()));
1222            if let Some(version) = version {
1223                info = info.with_version(version);
1224            }
1225            match dir.register_instance(info).await {
1226                Ok(()) => {
1227                    tracing::info!(gear, endpoint = %endpoint, "registered REST provider in directory");
1228                }
1229                Err(e) => {
1230                    tracing::warn!(gear, error = %e, "directory-register: failed to register REST provider");
1231                }
1232            }
1233        }
1234        // Mark that this (in-process host) process advertised its REST providers,
1235        // so the stop phase deregisters them exactly once. `OoP` serving never
1236        // runs this phase, so its deregister is owned solely by `oop_serve`.
1237        self.rest_providers_registered
1238            .store(true, std::sync::atomic::Ordering::SeqCst);
1239        Ok(())
1240    }
1241
1242    /// Names of all gears that provide a REST API (have `RestApiCap`), excluding
1243    /// the REST host gateway itself (`ApiGatewayCap`) — the gateway is the
1244    /// transport, not a contract provider, so it must not be advertised in the
1245    /// directory under its own gear name.
1246    fn rest_provider_gears(&self) -> Vec<&'static str> {
1247        self.registry
1248            .gears()
1249            .iter()
1250            .filter(|e| e.caps.has::<RestApiCap>() && !e.caps.has::<ApiGatewayCap>())
1251            .map(|e| e.name)
1252            .collect()
1253    }
1254
1255    /// Deregister this process's REST providers from the directory on shutdown,
1256    /// so consumers stop resolving an endpoint that is going away. Best-effort.
1257    async fn deregister_rest_providers(&self) {
1258        // Only the in-process host path registers REST providers in the directory
1259        // (via `run_directory_register_phase`). In `OoP` serving, `oop_serve`
1260        // owns presence + deregister, so skip here to avoid a double deregister.
1261        if !self
1262            .rest_providers_registered
1263            .load(std::sync::atomic::Ordering::SeqCst)
1264        {
1265            return;
1266        }
1267        let rest_gears = self.rest_provider_gears();
1268        if rest_gears.is_empty() {
1269            return;
1270        }
1271        let Ok(dir) = self.client_hub.get::<dyn crate::DirectoryClient>() else {
1272            return;
1273        };
1274        let instance_id = self.instance_id.to_string();
1275        for gear in rest_gears {
1276            if let Err(e) = dir.deregister_instance(gear, &instance_id).await {
1277                tracing::warn!(gear, error = %e, "directory-deregister: failed to deregister REST provider");
1278            }
1279        }
1280    }
1281
1282    /// Run the full gear lifecycle (all phases).
1283    ///
1284    /// This is the standard entry point for normal application execution.
1285    /// It runs all phases from pre-init through shutdown.
1286    ///
1287    /// # Errors
1288    ///
1289    /// Returns an error if any gear phase fails during execution.
1290    pub async fn run_gear_phases(self) -> anyhow::Result<()> {
1291        self.run_phases_internal(RunMode::Full).await
1292    }
1293
1294    /// Run only the migration phases (pre-init + DB migration).
1295    ///
1296    /// This is designed for cloud deployment workflows where database migrations
1297    /// need to run as a separate step before starting the application.
1298    /// The process exits after migrations complete.
1299    ///
1300    /// # Errors
1301    ///
1302    /// Returns an error if pre-init or migration phases fail.
1303    pub async fn run_migration_phases(self) -> anyhow::Result<()> {
1304        self.run_phases_internal(RunMode::MigrateOnly).await
1305    }
1306
1307    /// Internal implementation that runs gear phases based on the mode.
1308    ///
1309    /// This private method contains the actual phase execution logic and is called
1310    /// by both `run_gear_phases()` and `run_migration_phases()`.
1311    ///
1312    /// # Modes
1313    ///
1314    /// - `RunMode::Full`: Executes all phases and waits for shutdown signal
1315    /// - `RunMode::MigrateOnly`: Executes only pre-init and DB migration phases, then exits
1316    ///
1317    /// # Phases (Full Mode)
1318    ///
1319    /// 1. Pre-init (system gears only)
1320    /// 2. DB migration (all gears with database capability)
1321    /// 3. Init (all gears)
1322    /// 4. Post-init (system gears only)
1323    /// 5. REST (gears with REST capability)
1324    /// 6. gRPC (gears with gRPC capability)
1325    /// 7. Start (runnable gears)
1326    /// 8. `OoP` spawn (out-of-process gears)
1327    /// 9. Wait for cancellation
1328    /// 10. Stop (runnable gears in reverse order)
1329    async fn run_phases_internal(self, mode: RunMode) -> anyhow::Result<()> {
1330        // Log execution mode
1331        match mode {
1332            RunMode::Full => {
1333                tracing::info!("Running full lifecycle (all phases)");
1334            }
1335            RunMode::MigrateOnly => {
1336                tracing::info!("Running in migration mode (pre-init + db phases only)");
1337            }
1338        }
1339
1340        // 1. Pre-init phase (before init, only for system gears)
1341        self.run_pre_init_phase()?;
1342
1343        // 2. DB migration phase (system gears first)
1344        #[cfg(feature = "db")]
1345        {
1346            self.run_db_phase().await?;
1347        }
1348        #[cfg(not(feature = "db"))]
1349        {
1350            // No DB integration in this build.
1351        }
1352
1353        // Exit early if running in migration-only mode
1354        if mode == RunMode::MigrateOnly {
1355            tracing::info!("Migration phases completed successfully");
1356            return Ok(());
1357        }
1358
1359        // 3. Init -> proxy-wiring -> post-init (shared with the OoP path)
1360        self.run_init_wiring_post_init().await?;
1361
1362        // 5. REST phase (synchronous router composition)
1363        let _router = self.run_rest_phase().await?;
1364
1365        // 6. gRPC registration phase
1366        self.run_grpc_phase().await?;
1367
1368        // 7. Start phase
1369        self.run_start_phase().await?;
1370
1371        // Draining watcher: flip readiness to Draining the moment shutdown
1372        // begins so /readyz reports 503 and the orchestrator drains the pod out
1373        // of the load balancer before the stop phase tears gears down.
1374        {
1375            let readiness = Arc::clone(&self.dep_checker);
1376            let cancel = self.cancel.clone();
1377            tokio::spawn(async move {
1378                cancel.cancelled().await;
1379                readiness.set_draining(true);
1380            });
1381        }
1382
1383        // 7b. Directory-register phase: advertise in-process REST providers in
1384        //     the directory once the gateway has bound its listener.
1385        self.run_directory_register_phase().await?;
1386
1387        // 8. OoP spawn phase (after grpc_hub is running)
1388        self.run_oop_spawn_phase().await?;
1389
1390        // 9. Wait for cancellation
1391        self.cancel.cancelled().await;
1392
1393        // 10. Stop phase with hard timeout.
1394        //     Blocking stop implementations are guarded by a watchdog thread so
1395        //     a hang cannot block shutdown, whether in the in-process or OoP path.
1396        self.run_stop_phase_guarded().await?;
1397        Ok(())
1398    }
1399}
1400
1401/// Composed `OoP` router, serialized `OpenAPI`, and auth policy from the same specs.
1402#[cfg(feature = "bootstrap")]
1403type ComposedOopRouter = (Router, String, toolkit_http_middleware::RouteAuthPolicy);
1404
1405/// Out-of-process HTTP serving lifecycle (`cpt-cf-component-oop-bootstrap`).
1406#[cfg(feature = "bootstrap")]
1407impl HostRuntime {
1408    /// Compose a **host-less** REST router from all `RestApiCap` gears, plus the
1409    /// gear's generated `OpenAPI` document (serialized JSON).
1410    ///
1411    /// Unlike [`run_rest_phase`](Self::run_rest_phase), this does not require an
1412    /// `ApiGatewayCap` host: `OoP` gears serve their own routes directly.
1413    async fn compose_oop_router(
1414        &self,
1415        options: &crate::runtime::OopServeOptions,
1416        hc_registry: &Arc<crate::healthcheck::RestHealthcheckRegistry>,
1417    ) -> anyhow::Result<ComposedOopRouter> {
1418        use crate::api::{OpenApiInfo, OpenApiRegistryImpl};
1419        use anyhow::Context as _;
1420
1421        let registry = OpenApiRegistryImpl::new();
1422        let mut router = Router::new();
1423
1424        for entry in self.registry.gears() {
1425            if let Some(rest) = entry.caps.query::<RestApiCap>() {
1426                let ctx = self
1427                    .ctx_builder
1428                    .for_gear(entry.name)
1429                    .await
1430                    .with_context(|| format!("OoP router: build context for '{}'", entry.name))?;
1431                router = rest
1432                    .register_rest(&ctx, router, &registry)
1433                    .with_context(|| format!("OoP router: register_rest for '{}'", entry.name))?;
1434
1435                // Register the gear's readiness healthcheck (the same mechanism
1436                // the api-gateway host uses), so /readyz reflects it identically
1437                // whether the gear runs in-process or OoP.
1438                if let Some(hc) = rest.healthcheck(&ctx) {
1439                    hc_registry.register(entry.name, hc);
1440                }
1441            }
1442        }
1443
1444        let info = OpenApiInfo {
1445            title: options.gear_name.clone(),
1446            version: options
1447                .version
1448                .clone()
1449                .unwrap_or_else(|| "0.0.0".to_owned()),
1450            description: None,
1451            servers: vec![],
1452        };
1453        let openapi = registry
1454            .build_openapi(&info)
1455            .context("OoP router: build OpenAPI document")?;
1456        let json = serde_json::to_string(&openapi).context("OoP router: serialize OpenAPI")?;
1457        let route_auth = super::oop_serve::route_auth_policy(registry.operation_specs.iter());
1458
1459        Ok((router, json, route_auth))
1460    }
1461
1462    /// Run the full `OoP` gear lifecycle: phases (`pre_init` … `start`), then
1463    /// serve the composed router with framework probes, background
1464    /// self-registration, dependency resolution, and graceful drain, then the
1465    /// `stop` phase.
1466    ///
1467    /// # Errors
1468    /// Returns an error if any lifecycle phase or the HTTP server fails.
1469    pub async fn run_oop_serving(
1470        self,
1471        options: crate::runtime::OopServeOptions,
1472    ) -> anyhow::Result<()> {
1473        use crate::runtime::ReadinessState;
1474
1475        tracing::info!("Running OoP serving lifecycle");
1476
1477        // Make the directory client reachable to the proxy-wiring phase, which
1478        // resolves remote `#[toolkit::consumes]` providers through the
1479        // `ClientHub`. The `OoP` presence loop uses `options.directory`
1480        // directly; consumer wiring reads it here.
1481        if self.client_hub.get::<dyn crate::DirectoryClient>().is_err() {
1482            self.client_hub
1483                .register::<dyn crate::DirectoryClient>(Arc::clone(&options.directory));
1484        }
1485
1486        // Shared gear healthcheck registry (same mechanism as the api-gateway
1487        // host path). Seeded with the root cancellation token so in-flight
1488        // checks are aborted on shutdown. Populated during router composition.
1489        let hc_registry = Arc::new(
1490            crate::healthcheck::RestHealthcheckRegistry::with_cancellation(self.cancel.clone()),
1491        );
1492
1493        // Readiness gates on `#[toolkit::consumes]` dependencies only — the same
1494        // policy as the in-process host path — via the shared `DependencyChecker`
1495        // fed by the proxy-wiring phase below. `deps = [...]`-only declarations
1496        // remain for topo-sort ordering but do NOT gate `/readyz`. The
1497        // healthcheck registry supplies the per-gear readiness dimension; both
1498        // feed the `/readyz` aggregate.
1499        let readiness = ReadinessState::from_checker(
1500            Arc::clone(&self.dep_checker),
1501            Arc::clone(&hc_registry),
1502            options.healthcheck_timeout,
1503        );
1504
1505        // Bind the HTTP server and serve probes BEFORE the (possibly slow)
1506        // lifecycle phases, so the kubelet's liveness probe (`/healthz`) passes
1507        // immediately instead of getting connection-refused during `start()`.
1508        // Gear routes reply `503 starting` until attached below.
1509        let mut server = super::oop_serve::OopHttpServer::start(
1510            Arc::clone(&readiness),
1511            options,
1512            self.cancel.clone(),
1513        )
1514        .await?;
1515
1516        // Lifecycle phases up to start, then wire consumers (typed
1517        // directory-resolving clients feed the shared `DependencyChecker`),
1518        // then compose the host-less REST router + OpenAPI spec (collecting each
1519        // gear's healthcheck into the shared registry). Grouped so a failure
1520        // tears the probe server down cleanly.
1521        let mut started = false;
1522        let composed: anyhow::Result<ComposedOopRouter> = async {
1523            self.run_pre_init_phase()?;
1524            #[cfg(feature = "db")]
1525            self.run_db_phase().await?;
1526            // Init -> proxy-wiring -> post-init, shared with the in-process path
1527            // so a gear resolving a consumed contract during `start` finds the
1528            // client in the hub under both profiles.
1529            self.run_init_wiring_post_init().await?;
1530            self.run_grpc_phase().await?;
1531            self.run_start_phase().await?;
1532            started = true;
1533            // The gear lifecycle has populated the ClientHub. If an in-process
1534            // authn stack (e.g. a linked authn-resolver gear) registered a
1535            // DynBearerAuthenticator bridge, install the tenant plane now —
1536            // before `attach` layers security_context_middleware. (The platform
1537            // plane is built eagerly at bootstrap from `oop_http.internal_auth`.)
1538            server.resolve_bearer_authenticator(&self.client_hub);
1539            self.compose_oop_router(server.options(), &hc_registry)
1540                .await
1541        }
1542        .await;
1543
1544        let serve_result = match composed {
1545            Ok((gear_router, openapi_json, route_auth)) => {
1546                // Publish gear routes (they go live) + start directory presence.
1547                // Dependency resolution already ran in the proxy-wiring phase.
1548                server.attach(gear_router, openapi_json, route_auth);
1549                // Serve until cancelled, drain, then deregister.
1550                server.join().await
1551            }
1552            Err(e) => {
1553                tracing::error!(error = %e, "OoP startup failed before serving gear routes");
1554                // Tear down the probe server that is already bound.
1555                self.cancel.cancel();
1556                if let Err(join_err) = server.join().await {
1557                    tracing::warn!(error = %join_err, "OoP probe server teardown after startup failure errored");
1558                }
1559                Err(e)
1560            }
1561        };
1562
1563        // Stop phase runs only if start completed successfully. Errors are logged
1564        // but do not fail the shutdown process (same contract as run_stop_phase).
1565        if started && let Err(e) = self.run_stop_phase_guarded().await {
1566            tracing::warn!(error = %e, "OoP stop phase reported an error");
1567        }
1568
1569        serve_result
1570    }
1571}
1572
1573/// Read `gears.<owner>.config.consumer_wiring.<dep>` as a [`ConsumerWiring`].
1574///
1575/// `Ok(None)` when absent (the dep keeps discovery). A *present* entry that cannot
1576/// be deserialized is an `Err` that aborts proxy-wiring — the same fail-at-boot
1577/// contract the provider side uses for `client_wiring` ([`crate::wiring::read_wiring`]).
1578fn read_consumer_wiring(
1579    cfg: &dyn ConfigProvider,
1580    owner_gear: &str,
1581    dep_gear: &str,
1582) -> Result<Option<toolkit_contract::wiring::ConsumerWiring>, anyhow::Error> {
1583    let Some(value) = cfg
1584        .get_gear_config(owner_gear)
1585        .and_then(|c| c.get("config"))
1586        .and_then(|c| c.get("consumer_wiring"))
1587        .and_then(|c| c.get(dep_gear))
1588    else {
1589        return Ok(None);
1590    };
1591    // A bare string is the legacy static-endpoint shape; the raw serde error
1592    // ("invalid type: string") would not hint at the fix, so name the object form.
1593    if let Some(s) = value.as_str() {
1594        anyhow::bail!(
1595            "gear `{owner_gear}`: `consumer_wiring.{dep_gear}` is a bare string; the \
1596             static-endpoint override now lives on the object form — write it as \
1597             `{{ \"endpoint\": \"{s}\" }}`"
1598        );
1599    }
1600    serde_json::from_value::<toolkit_contract::wiring::ConsumerWiring>(value.clone())
1601        .map(Some)
1602        .map_err(|e| {
1603            anyhow::anyhow!("gear `{owner_gear}`: invalid consumer_wiring.{dep_gear}: {e}")
1604        })
1605}
1606
1607#[cfg(test)]
1608#[cfg(feature = "bootstrap")]
1609#[cfg_attr(coverage_nightly, coverage(off))]
1610#[path = "host_runtime_oop_tests.rs"]
1611mod host_runtime_oop_tests;
1612
1613#[cfg(test)]
1614#[cfg_attr(coverage_nightly, coverage(off))]
1615mod tests {
1616    use super::*;
1617    use crate::context::GearCtx;
1618    use crate::contracts::{Gear, RunnableCapability, SystemCapability};
1619    use crate::registry::RegistryBuilder;
1620    use std::sync::Arc;
1621    use std::sync::atomic::{AtomicUsize, Ordering};
1622    use tokio::sync::Mutex;
1623
1624    #[derive(Default)]
1625    #[allow(dead_code)]
1626    struct DummyCore;
1627    #[async_trait::async_trait]
1628    impl Gear for DummyCore {
1629        async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
1630            Ok(())
1631        }
1632    }
1633
1634    struct StopOrderTracker {
1635        my_order: usize,
1636        stop_order: Arc<AtomicUsize>,
1637    }
1638
1639    impl StopOrderTracker {
1640        fn new(counter: &Arc<AtomicUsize>, stop_order: Arc<AtomicUsize>) -> Self {
1641            let my_order = counter.fetch_add(1, Ordering::SeqCst);
1642            Self {
1643                my_order,
1644                stop_order,
1645            }
1646        }
1647    }
1648
1649    #[async_trait::async_trait]
1650    impl Gear for StopOrderTracker {
1651        async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
1652            Ok(())
1653        }
1654    }
1655
1656    #[async_trait::async_trait]
1657    impl RunnableCapability for StopOrderTracker {
1658        async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1659            Ok(())
1660        }
1661        async fn stop(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1662            let order = self.stop_order.fetch_add(1, Ordering::SeqCst);
1663            tracing::info!(my_order = self.my_order, stop_order = order, "Gear stopped");
1664            Ok(())
1665        }
1666    }
1667
1668    #[tokio::test]
1669    async fn test_stop_phase_reverse_order() {
1670        let counter = Arc::new(AtomicUsize::new(0));
1671        let stop_order = Arc::new(AtomicUsize::new(0));
1672
1673        let gear_a = Arc::new(StopOrderTracker::new(&counter, stop_order.clone()));
1674        let gear_b = Arc::new(StopOrderTracker::new(&counter, stop_order.clone()));
1675        let gear_c = Arc::new(StopOrderTracker::new(&counter, stop_order.clone()));
1676
1677        let mut builder = RegistryBuilder::default();
1678        builder.register_core_with_meta("a", &[], gear_a.clone() as Arc<dyn Gear>);
1679        builder.register_core_with_meta("b", &["a"], gear_b.clone() as Arc<dyn Gear>);
1680        builder.register_core_with_meta("c", &["b"], gear_c.clone() as Arc<dyn Gear>);
1681
1682        builder.register_stateful_with_meta("a", gear_a.clone() as Arc<dyn RunnableCapability>);
1683        builder.register_stateful_with_meta("b", gear_b.clone() as Arc<dyn RunnableCapability>);
1684        builder.register_stateful_with_meta("c", gear_c.clone() as Arc<dyn RunnableCapability>);
1685
1686        let registry = builder.build_topo_sorted().unwrap();
1687
1688        // Verify gear order is a -> b -> c
1689        let gear_names: Vec<_> = registry.gears().iter().map(|m| m.name).collect();
1690        assert_eq!(gear_names, vec!["a", "b", "c"]);
1691
1692        let client_hub = Arc::new(ClientHub::new());
1693        let cancel = CancellationToken::new();
1694        let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
1695
1696        let runtime = HostRuntime::new(
1697            registry,
1698            config_provider,
1699            DbOptions::None,
1700            client_hub,
1701            cancel.clone(),
1702            Uuid::new_v4(),
1703            None,
1704        );
1705
1706        // Run stop phase
1707        runtime.run_stop_phase().await.unwrap();
1708
1709        // Verify gears stopped in reverse order: c (stop_order=0), b (stop_order=1), a (stop_order=2)
1710        // Gear order is: a=0, b=1, c=2
1711        // Stop order should be: c=0, b=1, a=2
1712        assert_eq!(stop_order.load(Ordering::SeqCst), 3);
1713    }
1714
1715    #[tokio::test]
1716    async fn test_stop_phase_continues_on_error() {
1717        struct FailingGear {
1718            should_fail: bool,
1719            stopped: Arc<AtomicUsize>,
1720        }
1721
1722        #[async_trait::async_trait]
1723        impl Gear for FailingGear {
1724            async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
1725                Ok(())
1726            }
1727        }
1728
1729        #[async_trait::async_trait]
1730        impl RunnableCapability for FailingGear {
1731            async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1732                Ok(())
1733            }
1734            async fn stop(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1735                self.stopped.fetch_add(1, Ordering::SeqCst);
1736                if self.should_fail {
1737                    anyhow::bail!("Intentional failure")
1738                }
1739                Ok(())
1740            }
1741        }
1742
1743        let stopped = Arc::new(AtomicUsize::new(0));
1744        let gear_a = Arc::new(FailingGear {
1745            should_fail: false,
1746            stopped: stopped.clone(),
1747        });
1748        let gear_b = Arc::new(FailingGear {
1749            should_fail: true,
1750            stopped: stopped.clone(),
1751        });
1752        let gear_c = Arc::new(FailingGear {
1753            should_fail: false,
1754            stopped: stopped.clone(),
1755        });
1756
1757        let mut builder = RegistryBuilder::default();
1758        builder.register_core_with_meta("a", &[], gear_a.clone() as Arc<dyn Gear>);
1759        builder.register_core_with_meta("b", &["a"], gear_b.clone() as Arc<dyn Gear>);
1760        builder.register_core_with_meta("c", &["b"], gear_c.clone() as Arc<dyn Gear>);
1761
1762        builder.register_stateful_with_meta("a", gear_a.clone() as Arc<dyn RunnableCapability>);
1763        builder.register_stateful_with_meta("b", gear_b.clone() as Arc<dyn RunnableCapability>);
1764        builder.register_stateful_with_meta("c", gear_c.clone() as Arc<dyn RunnableCapability>);
1765
1766        let registry = builder.build_topo_sorted().unwrap();
1767
1768        let client_hub = Arc::new(ClientHub::new());
1769        let cancel = CancellationToken::new();
1770        let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
1771
1772        let runtime = HostRuntime::new(
1773            registry,
1774            config_provider,
1775            DbOptions::None,
1776            client_hub,
1777            cancel.clone(),
1778            Uuid::new_v4(),
1779            None,
1780        );
1781
1782        // Run stop phase - should not fail even though gear_b fails
1783        runtime.run_stop_phase().await.unwrap();
1784
1785        // All gears should have attempted to stop
1786        assert_eq!(stopped.load(Ordering::SeqCst), 3);
1787    }
1788
1789    struct EmptyConfigProvider;
1790    impl ConfigProvider for EmptyConfigProvider {
1791        fn get_gear_config(&self, _gear_name: &str) -> Option<&serde_json::Value> {
1792            None
1793        }
1794    }
1795
1796    /// Serves one config blob for owner gear `orders` (the owner every
1797    /// `read_consumer_wiring` test uses) and nothing for anything else.
1798    struct OrdersCfg(serde_json::Value);
1799    impl ConfigProvider for OrdersCfg {
1800        fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> {
1801            (gear == "orders").then_some(&self.0)
1802        }
1803    }
1804
1805    /// Serves config blobs by owner-gear name (for tests wiring several gears).
1806    struct MapCfg(std::collections::HashMap<String, serde_json::Value>);
1807    impl ConfigProvider for MapCfg {
1808        fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> {
1809            self.0.get(gear)
1810        }
1811    }
1812
1813    /// Extract just the static-endpoint override from a `consumer_wiring` entry.
1814    fn endpoint_override(cfg: &dyn ConfigProvider, owner: &str, dep: &str) -> Option<String> {
1815        super::read_consumer_wiring(cfg, owner, dep)
1816            .expect("consumer_wiring must not error for a valid/absent endpoint")
1817            .and_then(|w| w.into_parts().0)
1818    }
1819
1820    #[test]
1821    fn static_endpoint_override_reads_nested_consumer_wiring_key() {
1822        let mut map = std::collections::HashMap::new();
1823        map.insert(
1824            "orders".to_owned(),
1825            serde_json::json!({
1826                "config": {
1827                    "consumer_wiring": { "billing": { "endpoint": "http://localhost:8081" } }
1828                }
1829            }),
1830        );
1831        let cfg = MapCfg(map);
1832
1833        // Present override is read from `config.consumer_wiring.<dep>.endpoint`.
1834        assert_eq!(
1835            endpoint_override(&cfg, "orders", "billing").as_deref(),
1836            Some("http://localhost:8081")
1837        );
1838        // Absent dep / owner → None (falls through to directory resolution).
1839        assert_eq!(endpoint_override(&cfg, "orders", "inventory"), None);
1840        assert_eq!(endpoint_override(&cfg, "warehouse", "billing"), None);
1841        assert_eq!(
1842            endpoint_override(&EmptyConfigProvider, "orders", "billing"),
1843            None
1844        );
1845    }
1846
1847    /// The object form yields both the `endpoint` override and the flattened
1848    /// `ClientTuning`.
1849    #[test]
1850    fn consumer_wiring_object_form_yields_endpoint_and_tuning() {
1851        let cfg = OrdersCfg(serde_json::json!({
1852            "config": {
1853                "consumer_wiring": {
1854                    "billing": {
1855                        "endpoint": "http://billing:8080",
1856                        "timeout": "5s",
1857                        "max_concurrent_requests": 256,
1858                        "pool_max_idle_per_host": 256
1859                    }
1860                }
1861            }
1862        }));
1863
1864        let (endpoint, tuning) = super::read_consumer_wiring(&cfg, "orders", "billing")
1865            .expect("wiring must parse")
1866            .expect("wiring present")
1867            .into_parts();
1868        assert_eq!(endpoint.as_deref(), Some("http://billing:8080"));
1869        assert_eq!(tuning.timeout, Some(std::time::Duration::from_secs(5)));
1870        assert_eq!(tuning.max_concurrent_requests, Some(256));
1871        assert_eq!(tuning.pool_max_idle_per_host, Some(256));
1872    }
1873
1874    /// Omitting `endpoint` keeps discovery while still applying the tuning.
1875    #[test]
1876    fn consumer_wiring_object_without_endpoint_keeps_discovery() {
1877        let cfg = OrdersCfg(serde_json::json!({
1878            "config": {
1879                "consumer_wiring": { "billing": { "max_concurrent_requests": 1 } }
1880            }
1881        }));
1882
1883        let (endpoint, tuning) = super::read_consumer_wiring(&cfg, "orders", "billing")
1884            .expect("wiring must parse")
1885            .expect("wiring present")
1886            .into_parts();
1887        assert_eq!(endpoint, None, "omitted endpoint must keep discovery");
1888        assert_eq!(tuning.max_concurrent_requests, Some(1));
1889    }
1890
1891    /// A present-but-unparseable entry (wrong shape or wrong-typed tuning) is a
1892    /// boot error, matching the provider side's `client_wiring` — not a silent
1893    /// downgrade to discovery.
1894    #[test]
1895    fn consumer_wiring_malformed_entry_is_an_error() {
1896        let malformed = [
1897            // Not a ConsumerWiring object at all.
1898            serde_json::json!([1, 2, 3]),
1899            // A wrong-typed tuning value: a string where a count is expected, and
1900            // a bare number where a humantime duration string is expected.
1901            serde_json::json!({ "max_concurrent_requests": "lots" }),
1902            serde_json::json!({ "timeout": 5 }),
1903            // The same, even alongside an otherwise-valid endpoint override: the
1904            // whole entry fails, so the override is not silently honoured untuned.
1905            serde_json::json!({ "endpoint": "http://billing:8080", "timeout": 5 }),
1906        ];
1907        for entry in malformed {
1908            let cfg = OrdersCfg(serde_json::json!({
1909                "config": { "consumer_wiring": { "billing": entry.clone() } }
1910            }));
1911            assert!(
1912                super::read_consumer_wiring(&cfg, "orders", "billing").is_err(),
1913                "a malformed entry (`{entry}`) must be a boot error, not silently dropped"
1914            );
1915        }
1916    }
1917
1918    /// A legacy bare-string value is a hard error whose message names the object
1919    /// form (a migration hint, not an opaque serde "invalid type" error).
1920    #[test]
1921    fn consumer_wiring_legacy_bare_string_is_an_error_naming_the_object_form() {
1922        let cfg = OrdersCfg(serde_json::json!({
1923            "config": { "consumer_wiring": { "billing": "http://localhost:8081" } }
1924        }));
1925        let err = super::read_consumer_wiring(&cfg, "orders", "billing")
1926            .expect_err("a bare-string value is the legacy shape -> hard error")
1927            .to_string();
1928        assert!(
1929            err.contains("endpoint"),
1930            "the error must point the operator at the `{{ endpoint: ... }}` object form, got: {err}"
1931        );
1932    }
1933
1934    /// A present-but-invalid `endpoint` is surfaced as an error, not downgraded to
1935    /// discovery — the operator asked for a specific endpoint that can't be honoured.
1936    #[test]
1937    fn consumer_wiring_invalid_endpoint_is_an_error() {
1938        for bad in ["", "billing", "/path/only"] {
1939            let cfg = OrdersCfg(serde_json::json!({
1940                "config": { "consumer_wiring": { "billing": { "endpoint": bad } } }
1941            }));
1942            assert!(
1943                super::read_consumer_wiring(&cfg, "orders", "billing").is_err(),
1944                "a present-but-invalid endpoint (`{bad}`) must be a hard error, not survived"
1945            );
1946        }
1947    }
1948
1949    /// One record of what a test wired the phase with. The phase tests share this
1950    /// collector and run in parallel, so each finds its own entry by a unique
1951    /// tuning value.
1952    #[derive(Clone)]
1953    struct WiredCall {
1954        resolver: Arc<dyn crate::discovery::EndpointResolver>,
1955        max_concurrent_requests: Option<usize>,
1956        /// A dropped credential is otherwise invisible — the only runtime symptom
1957        /// is a missing `X-ToolKit-Internal-Token` on every Profile 2/3 call.
1958        has_credential: bool,
1959    }
1960    static WIRED: std::sync::Mutex<Vec<WiredCall>> = std::sync::Mutex::new(Vec::new());
1961
1962    /// Stands in for the `#[toolkit::consumes]`-generated wire fn: records the
1963    /// resolver and tuning it was handed, then reports a remote binding.
1964    #[allow(
1965        clippy::unnecessary_wraps,
1966        clippy::needless_pass_by_value,
1967        reason = "must match the ConsumerRegistration::wire signature the macro emits"
1968    )]
1969    fn record_wire(
1970        _hub: &ClientHub,
1971        resolver: Arc<dyn crate::discovery::EndpointResolver>,
1972        tuning: toolkit_contract::wiring::ClientTuning,
1973    ) -> anyhow::Result<crate::discovery::WireOutcome> {
1974        WIRED.lock().unwrap().push(WiredCall {
1975            resolver,
1976            max_concurrent_requests: tuning.max_concurrent_requests,
1977            has_credential: tuning.internal_token_provider.is_some(),
1978        });
1979        Ok(crate::discovery::WireOutcome::Remote)
1980    }
1981
1982    /// A consumer registration for the phase tests, wired directly so it never
1983    /// enters the link-time-global `inventory` set (which would leak onto other
1984    /// tests in this binary).
1985    fn test_registration() -> crate::discovery::ConsumerRegistration {
1986        crate::discovery::ConsumerRegistration {
1987            owner_gear: "orders",
1988            dep_gear: "billing",
1989            wire: record_wire,
1990        }
1991    }
1992
1993    /// Static-endpoint arm: an `endpoint` wires a `StaticEndpointResolver` pinned
1994    /// to it, threads the entry's tuning through, and marks the dep resolved
1995    /// immediately (no probe loop).
1996    #[tokio::test]
1997    async fn proxy_wiring_static_override_uses_static_resolver_and_marks_ready() {
1998        let mut map = std::collections::HashMap::new();
1999        map.insert(
2000            "orders".to_owned(),
2001            serde_json::json!({
2002                "config": {
2003                    "consumer_wiring": {
2004                        "billing": {
2005                            "endpoint": "http://localhost:8081",
2006                            "max_concurrent_requests": 7
2007                        }
2008                    }
2009                }
2010            }),
2011        );
2012
2013        let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2014        let runtime = HostRuntime::new(
2015            registry,
2016            Arc::new(MapCfg(map)),
2017            DbOptions::None,
2018            Arc::new(ClientHub::new()),
2019            CancellationToken::new(),
2020            Uuid::new_v4(),
2021            None,
2022        );
2023
2024        let reg = test_registration();
2025        runtime.wire_consumer_registrations(&[&reg]).unwrap();
2026
2027        // Find *the* entry whose resolver yields the configured endpoint and
2028        // assert that same entry's tuning — OR-ing two flags independently across
2029        // every WIRED entry would pass even if the endpoint and the tuning had
2030        // arrived on two different wire calls.
2031        let calls: Vec<WiredCall> = WIRED.lock().unwrap().clone();
2032        let mut matched = None;
2033        for call in &calls {
2034            if let Ok(Some(ep)) = call.resolver.resolve_endpoint("billing").await
2035                && ep == "http://localhost:8081"
2036            {
2037                matched = Some(call.clone());
2038                break;
2039            }
2040        }
2041        let matched = matched
2042            .expect("static override must wire a resolver pinned to the configured endpoint");
2043        assert_eq!(
2044            matched.max_concurrent_requests,
2045            Some(7),
2046            "the tuning on the same wire call as the static endpoint must be the configured one"
2047        );
2048
2049        // A static endpoint is resolvable by construction: the dep must be
2050        // resolved immediately rather than left gating /readyz.
2051        assert!(runtime.dep_checker.all_resolved());
2052        assert!(runtime.dep_checker.unresolved_deps().is_empty());
2053    }
2054
2055    /// Tuning-only entry (no `endpoint`, the production shape): keeps the shared
2056    /// discovery resolver and leaves the dep gating readiness — a remote dep with
2057    /// no directory stays unresolved.
2058    #[tokio::test]
2059    async fn proxy_wiring_tuning_only_entry_keeps_discovery_and_gates_readiness() {
2060        let mut map = std::collections::HashMap::new();
2061        // A unique tuning value so this test can find its own WIRED entry among
2062        // the link-time-global registrations.
2063        map.insert(
2064            "orders".to_owned(),
2065            serde_json::json!({
2066                "config": {
2067                    "consumer_wiring": { "billing": { "max_concurrent_requests": 4343 } }
2068                }
2069            }),
2070        );
2071
2072        let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2073        let runtime = HostRuntime::new(
2074            registry,
2075            Arc::new(MapCfg(map)),
2076            DbOptions::None,
2077            Arc::new(ClientHub::new()),
2078            CancellationToken::new(),
2079            Uuid::new_v4(),
2080            None,
2081        );
2082
2083        let reg = test_registration();
2084        runtime.wire_consumer_registrations(&[&reg]).unwrap();
2085
2086        let calls: Vec<WiredCall> = WIRED.lock().unwrap().clone();
2087        let ours = calls
2088            .iter()
2089            .find(|c| c.max_concurrent_requests == Some(4343))
2090            .expect("our tuning-only wire call must have been recorded");
2091        assert!(
2092            matches!(ours.resolver.resolve_endpoint("billing").await, Ok(None)),
2093            "a tuning-only entry must keep discovery -- the resolver must not be pinned to a \
2094             static endpoint"
2095        );
2096
2097        // No directory + a remote dep → it must stay unresolved and gate /readyz.
2098        assert!(!runtime.dep_checker.all_resolved());
2099        assert!(
2100            runtime
2101                .dep_checker
2102                .unresolved_deps()
2103                .contains(&"billing".to_owned()),
2104            "a discovery-wired dep with no directory must gate readiness"
2105        );
2106    }
2107
2108    /// The process's platform-plane credential must ride on the tuning handed to
2109    /// the wire fn (Profile 2/3), else every inter-gear call ships without
2110    /// `X-ToolKit-Internal-Token`.
2111    #[tokio::test]
2112    async fn proxy_wiring_threads_platform_credential_onto_tuning() {
2113        use toolkit_contract::runtime::config::{CredentialState, InternalTokenProvider};
2114
2115        let mut map = std::collections::HashMap::new();
2116        map.insert(
2117            "orders".to_owned(),
2118            serde_json::json!({
2119                "config": {
2120                    "consumer_wiring": { "billing": { "max_concurrent_requests": 4242 } }
2121                }
2122            }),
2123        );
2124
2125        let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2126        let provider = InternalTokenProvider::new(|| CredentialState::NotConfigured);
2127        let runtime = HostRuntime::new(
2128            registry,
2129            Arc::new(MapCfg(map)),
2130            DbOptions::None,
2131            Arc::new(ClientHub::new()),
2132            CancellationToken::new(),
2133            Uuid::new_v4(),
2134            None,
2135        )
2136        .with_internal_token_provider(Some(provider));
2137
2138        let reg = test_registration();
2139        runtime.wire_consumer_registrations(&[&reg]).unwrap();
2140
2141        let calls: Vec<WiredCall> = WIRED.lock().unwrap().clone();
2142        let ours = calls
2143            .iter()
2144            .find(|c| c.max_concurrent_requests == Some(4242))
2145            .expect("our wire call must have been recorded");
2146        assert!(
2147            ours.has_credential,
2148            "the process credential must be threaded onto the tuning handed to the wire fn"
2149        );
2150    }
2151
2152    /// Phase-level: a non-absolute `endpoint` aborts proxy-wiring with
2153    /// `RegistryError::ProxyWiring` rather than silently downgrading to discovery
2154    /// (which would mark the dep resolved while every call fails).
2155    #[tokio::test]
2156    async fn proxy_wiring_invalid_endpoint_is_a_hard_error() {
2157        let mut map = std::collections::HashMap::new();
2158        map.insert(
2159            "orders".to_owned(),
2160            serde_json::json!({
2161                "config": {
2162                    "consumer_wiring": {
2163                        "billing": { "endpoint": "", "max_concurrent_requests": 4444 }
2164                    }
2165                }
2166            }),
2167        );
2168
2169        let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2170        let runtime = HostRuntime::new(
2171            registry,
2172            Arc::new(MapCfg(map)),
2173            DbOptions::None,
2174            Arc::new(ClientHub::new()),
2175            CancellationToken::new(),
2176            Uuid::new_v4(),
2177            None,
2178        );
2179
2180        let reg = test_registration();
2181        let err = runtime
2182            .wire_consumer_registrations(&[&reg])
2183            .expect_err("an invalid static endpoint must abort proxy-wiring");
2184        assert!(
2185            matches!(err, RegistryError::ProxyWiring { gear, .. } if gear == "orders"),
2186            "expected a ProxyWiring error for the owning gear, got {err:?}"
2187        );
2188    }
2189
2190    /// The override is keyed by the kebab *gear name* (`owner_gear`), not the
2191    /// struct ident. Regression guard: the macro once emitted
2192    /// `stringify!(StructIdent)`, so the lookup could never fire.
2193    #[test]
2194    fn static_endpoint_override_is_keyed_by_kebab_gear_name() {
2195        let mut map = std::collections::HashMap::new();
2196        map.insert(
2197            "api-contracts-consumer".to_owned(),
2198            serde_json::json!({
2199                "config": {
2200                    "consumer_wiring": {
2201                        "api-contracts": { "endpoint": "http://localhost:9099" }
2202                    }
2203                }
2204            }),
2205        );
2206        let cfg = MapCfg(map);
2207
2208        assert_eq!(
2209            endpoint_override(&cfg, "api-contracts-consumer", "api-contracts").as_deref(),
2210            Some("http://localhost:9099"),
2211        );
2212        // The pre-fix value — the Rust struct ident — must NOT resolve.
2213        assert_eq!(
2214            endpoint_override(&cfg, "ApiContractsConsumer", "api-contracts"),
2215            None,
2216        );
2217    }
2218
2219    #[tokio::test]
2220    async fn test_post_init_runs_after_all_init_and_system_first() {
2221        #[derive(Clone)]
2222        struct TrackHooks {
2223            name: &'static str,
2224            events: Arc<Mutex<Vec<String>>>,
2225        }
2226
2227        #[async_trait::async_trait]
2228        impl Gear for TrackHooks {
2229            async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2230                self.events.lock().await.push(format!("init:{}", self.name));
2231                Ok(())
2232            }
2233        }
2234
2235        #[async_trait::async_trait]
2236        impl SystemCapability for TrackHooks {
2237            fn pre_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2238                Ok(())
2239            }
2240
2241            async fn post_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2242                self.events
2243                    .lock()
2244                    .await
2245                    .push(format!("post_init:{}", self.name));
2246                Ok(())
2247            }
2248        }
2249
2250        let events = Arc::new(Mutex::new(Vec::<String>::new()));
2251        let sys_a = Arc::new(TrackHooks {
2252            name: "sys_a",
2253            events: events.clone(),
2254        });
2255        let user_b = Arc::new(TrackHooks {
2256            name: "user_b",
2257            events: events.clone(),
2258        });
2259        let user_c = Arc::new(TrackHooks {
2260            name: "user_c",
2261            events: events.clone(),
2262        });
2263
2264        let mut builder = RegistryBuilder::default();
2265        builder.register_core_with_meta("sys_a", &[], sys_a.clone() as Arc<dyn Gear>);
2266        builder.register_core_with_meta("user_b", &["sys_a"], user_b.clone() as Arc<dyn Gear>);
2267        builder.register_core_with_meta("user_c", &["user_b"], user_c.clone() as Arc<dyn Gear>);
2268        builder.register_system_with_meta("sys_a", sys_a.clone() as Arc<dyn SystemCapability>);
2269
2270        let registry = builder.build_topo_sorted().unwrap();
2271
2272        let client_hub = Arc::new(ClientHub::new());
2273        let cancel = CancellationToken::new();
2274        let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2275
2276        let runtime = HostRuntime::new(
2277            registry,
2278            config_provider,
2279            DbOptions::None,
2280            client_hub,
2281            cancel,
2282            Uuid::new_v4(),
2283            None,
2284        );
2285
2286        // Run init phase for all gears, then post_init as a separate barrier phase.
2287        runtime.run_init_phase().await.unwrap();
2288        runtime.run_post_init_phase().await.unwrap();
2289
2290        let events = events.lock().await.clone();
2291        let first_post_init = events
2292            .iter()
2293            .position(|e| e.starts_with("post_init:"))
2294            .expect("expected post_init events");
2295        assert!(
2296            events[..first_post_init]
2297                .iter()
2298                .all(|e| e.starts_with("init:")),
2299            "expected all init events before post_init, got: {events:?}"
2300        );
2301
2302        // system-first order within each phase
2303        assert_eq!(
2304            events,
2305            vec![
2306                "init:sys_a",
2307                "init:user_b",
2308                "init:user_c",
2309                "post_init:sys_a",
2310            ]
2311        );
2312    }
2313
2314    /// The in-process and `OoP` paths must agree on where proxy-wiring sits.
2315    ///
2316    /// They did not: the `OoP` path ran it *after* `start`, so a gear resolving
2317    /// a consumed contract during its own `start` found nothing in the
2318    /// `ClientHub` under Profile 2/3 while working under Profile 1. Both paths
2319    /// now go through `run_init_wiring_post_init`, which pins the order.
2320    ///
2321    /// This test covers the two observable endpoints of that segment. The
2322    /// wiring step between them is a no-op here (no `#[toolkit::consumes]`
2323    /// registration is linked into this test binary), so what actually stops
2324    /// the paths diverging again is the shared method itself — inlining it back
2325    /// into both call sites makes it dead code and fails the `-D warnings`
2326    /// build.
2327    #[tokio::test]
2328    async fn init_wiring_post_init_runs_as_one_ordered_segment() {
2329        #[derive(Clone)]
2330        struct TrackHooks {
2331            events: Arc<Mutex<Vec<String>>>,
2332        }
2333
2334        #[async_trait::async_trait]
2335        impl Gear for TrackHooks {
2336            async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2337                self.events.lock().await.push("init".to_owned());
2338                Ok(())
2339            }
2340        }
2341
2342        #[async_trait::async_trait]
2343        impl SystemCapability for TrackHooks {
2344            fn pre_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2345                Ok(())
2346            }
2347
2348            async fn post_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2349                self.events.lock().await.push("post_init".to_owned());
2350                Ok(())
2351            }
2352        }
2353
2354        let events = Arc::new(Mutex::new(Vec::<String>::new()));
2355        let gear = Arc::new(TrackHooks {
2356            events: events.clone(),
2357        });
2358
2359        let mut builder = RegistryBuilder::default();
2360        builder.register_core_with_meta("sys", &[], gear.clone() as Arc<dyn Gear>);
2361        builder.register_system_with_meta("sys", gear.clone() as Arc<dyn SystemCapability>);
2362        let registry = builder.build_topo_sorted().unwrap();
2363
2364        let runtime = HostRuntime::new(
2365            registry,
2366            Arc::new(EmptyConfigProvider) as Arc<dyn ConfigProvider>,
2367            DbOptions::None,
2368            Arc::new(ClientHub::new()),
2369            CancellationToken::new(),
2370            Uuid::new_v4(),
2371            None,
2372        );
2373
2374        runtime.run_init_wiring_post_init().await.unwrap();
2375
2376        assert_eq!(events.lock().await.clone(), vec!["init", "post_init"]);
2377    }
2378
2379    #[tokio::test]
2380    async fn test_stop_phase_provides_fresh_deadline_token() {
2381        use std::sync::atomic::AtomicBool;
2382
2383        struct TokenCheckGear {
2384            stop_was_called: AtomicBool,
2385            token_was_cancelled_on_entry: AtomicBool,
2386        }
2387
2388        #[async_trait::async_trait]
2389        impl Gear for TokenCheckGear {
2390            async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2391                Ok(())
2392            }
2393        }
2394
2395        #[async_trait::async_trait]
2396        impl RunnableCapability for TokenCheckGear {
2397            async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
2398                Ok(())
2399            }
2400            async fn stop(&self, deadline_token: CancellationToken) -> anyhow::Result<()> {
2401                // Record that stop() was called
2402                self.stop_was_called.store(true, Ordering::SeqCst);
2403                // Record whether the token was already cancelled when stop() was called
2404                self.token_was_cancelled_on_entry
2405                    .store(deadline_token.is_cancelled(), Ordering::SeqCst);
2406                Ok(())
2407            }
2408        }
2409
2410        let gear = Arc::new(TokenCheckGear {
2411            stop_was_called: AtomicBool::new(false),
2412            // Default to true to detect if stop() was never called
2413            token_was_cancelled_on_entry: AtomicBool::new(true),
2414        });
2415
2416        let mut builder = RegistryBuilder::default();
2417        builder.register_core_with_meta("test", &[], gear.clone() as Arc<dyn Gear>);
2418        builder.register_stateful_with_meta("test", gear.clone() as Arc<dyn RunnableCapability>);
2419
2420        let registry = builder.build_topo_sorted().unwrap();
2421        let client_hub = Arc::new(ClientHub::new());
2422        let cancel = CancellationToken::new();
2423        let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2424
2425        let runtime = HostRuntime::new(
2426            registry,
2427            config_provider,
2428            DbOptions::None,
2429            client_hub,
2430            cancel.clone(),
2431            Uuid::new_v4(),
2432            None,
2433        );
2434
2435        // Run stop phase - the deadline token should NOT be cancelled
2436        runtime.run_stop_phase().await.unwrap();
2437
2438        // First, verify stop() was actually called (guards against silent registration failures)
2439        assert!(
2440            gear.stop_was_called.load(Ordering::SeqCst),
2441            "stop() was never called - gear may not have been registered correctly"
2442        );
2443
2444        // The token should NOT have been cancelled when stop() was called
2445        // This is the key fix: gears get a fresh token, not the already-cancelled root token
2446        assert!(
2447            !gear.token_was_cancelled_on_entry.load(Ordering::SeqCst),
2448            "deadline_token should NOT be cancelled when stop() is called - this enables graceful shutdown"
2449        );
2450    }
2451
2452    #[tokio::test]
2453    async fn test_stop_phase_graceful_shutdown_completes_before_deadline() {
2454        use std::sync::atomic::AtomicBool;
2455        use std::time::Duration;
2456
2457        struct GracefulGear {
2458            graceful_completed: AtomicBool,
2459            deadline_fired: AtomicBool,
2460        }
2461
2462        #[async_trait::async_trait]
2463        impl Gear for GracefulGear {
2464            async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2465                Ok(())
2466            }
2467        }
2468
2469        #[async_trait::async_trait]
2470        impl RunnableCapability for GracefulGear {
2471            async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
2472                Ok(())
2473            }
2474            async fn stop(&self, deadline_token: CancellationToken) -> anyhow::Result<()> {
2475                // Simulate graceful shutdown that completes quickly (10ms)
2476                tokio::select! {
2477                    () = tokio::time::sleep(Duration::from_millis(10)) => {
2478                        self.graceful_completed.store(true, Ordering::SeqCst);
2479                    }
2480                    () = deadline_token.cancelled() => {
2481                        self.deadline_fired.store(true, Ordering::SeqCst);
2482                    }
2483                }
2484                Ok(())
2485            }
2486        }
2487
2488        let gear = Arc::new(GracefulGear {
2489            graceful_completed: AtomicBool::new(false),
2490            deadline_fired: AtomicBool::new(false),
2491        });
2492
2493        let mut builder = RegistryBuilder::default();
2494        builder.register_core_with_meta("test", &[], gear.clone() as Arc<dyn Gear>);
2495        builder.register_stateful_with_meta("test", gear.clone() as Arc<dyn RunnableCapability>);
2496
2497        let registry = builder.build_topo_sorted().unwrap();
2498        let client_hub = Arc::new(ClientHub::new());
2499        let cancel = CancellationToken::new();
2500        let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2501
2502        // Use a long deadline (5s) - gear should complete gracefully before this
2503        let runtime = HostRuntime::new(
2504            registry,
2505            config_provider,
2506            DbOptions::None,
2507            client_hub,
2508            cancel.clone(),
2509            Uuid::new_v4(),
2510            None,
2511        )
2512        .with_shutdown_deadline(Duration::from_secs(5));
2513
2514        runtime.run_stop_phase().await.unwrap();
2515
2516        // Graceful shutdown should have completed
2517        assert!(
2518            gear.graceful_completed.load(Ordering::SeqCst),
2519            "graceful shutdown should complete"
2520        );
2521        // Deadline should NOT have fired (gear finished before deadline)
2522        assert!(
2523            !gear.deadline_fired.load(Ordering::SeqCst),
2524            "deadline should not fire when graceful shutdown completes quickly"
2525        );
2526    }
2527
2528    #[tokio::test]
2529    async fn test_stop_phase_deadline_fires_for_slow_gear() {
2530        use std::sync::atomic::AtomicBool;
2531        use std::time::Duration;
2532
2533        struct SlowGear {
2534            graceful_completed: AtomicBool,
2535            deadline_fired: AtomicBool,
2536        }
2537
2538        #[async_trait::async_trait]
2539        impl Gear for SlowGear {
2540            async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2541                Ok(())
2542            }
2543        }
2544
2545        #[async_trait::async_trait]
2546        impl RunnableCapability for SlowGear {
2547            async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
2548                Ok(())
2549            }
2550            async fn stop(&self, deadline_token: CancellationToken) -> anyhow::Result<()> {
2551                // Simulate slow graceful shutdown (would take 10s, but deadline is 100ms)
2552                tokio::select! {
2553                    () = tokio::time::sleep(Duration::from_secs(10)) => {
2554                        self.graceful_completed.store(true, Ordering::SeqCst);
2555                    }
2556                    () = deadline_token.cancelled() => {
2557                        self.deadline_fired.store(true, Ordering::SeqCst);
2558                    }
2559                }
2560                Ok(())
2561            }
2562        }
2563
2564        let gear = Arc::new(SlowGear {
2565            graceful_completed: AtomicBool::new(false),
2566            deadline_fired: AtomicBool::new(false),
2567        });
2568
2569        let mut builder = RegistryBuilder::default();
2570        builder.register_core_with_meta("test", &[], gear.clone() as Arc<dyn Gear>);
2571        builder.register_stateful_with_meta("test", gear.clone() as Arc<dyn RunnableCapability>);
2572
2573        let registry = builder.build_topo_sorted().unwrap();
2574        let client_hub = Arc::new(ClientHub::new());
2575        let cancel = CancellationToken::new();
2576        let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2577
2578        // Use a short deadline (100ms) - gear should be interrupted by deadline
2579        let runtime = HostRuntime::new(
2580            registry,
2581            config_provider,
2582            DbOptions::None,
2583            client_hub,
2584            cancel.clone(),
2585            Uuid::new_v4(),
2586            None,
2587        )
2588        .with_shutdown_deadline(Duration::from_millis(100));
2589
2590        runtime.run_stop_phase().await.unwrap();
2591
2592        // Graceful shutdown should NOT have completed (deadline fired first)
2593        assert!(
2594            !gear.graceful_completed.load(Ordering::SeqCst),
2595            "graceful shutdown should not complete when deadline fires first"
2596        );
2597        // Deadline should have fired
2598        assert!(
2599            gear.deadline_fired.load(Ordering::SeqCst),
2600            "deadline should fire for slow gears"
2601        );
2602    }
2603}