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