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