1use 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#[derive(Clone)]
68pub enum DbOptions {
69 None,
71 #[cfg(feature = "db")]
73 Manager(Arc<toolkit_db::DbManager>),
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum RunMode {
79 Full,
81 MigrateOnly,
83}
84
85pub const TOOLKIT_DIRECTORY_ENDPOINT_ENV: &str = "TOOLKIT_DIRECTORY_ENDPOINT";
87
88pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
90
91pub const DEFAULT_SHUTDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(35);
97
98pub 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 gears_cfg: Arc<dyn ConfigProvider>,
110 dep_checker: Arc<super::readiness::DependencyChecker>,
116 rest_providers_registered: std::sync::atomic::AtomicBool,
122 cancel: CancellationToken,
123 #[allow(dead_code)]
124 db_options: DbOptions,
125 oop_options: Option<OopSpawnOptions>,
127 shutdown_deadline: std::time::Duration,
129}
130
131impl HostRuntime {
132 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 let gear_manager = Arc::new(GearManager::new());
146 let grpc_installers = Arc::new(GrpcInstallerStore::new());
147
148 let dep_checker = Arc::new(super::readiness::DependencyChecker::new());
152 client_hub.register::<super::readiness::DependencyChecker>(dep_checker.clone());
153
154 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 #[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 #[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 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 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 #[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 #[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 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; Ok(db.map(|db| (db, dbm)))
307 }
308
309 #[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 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 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 #[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 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 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 #[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(®s)
448 }
449
450 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 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 let known_gears: std::collections::HashSet<&str> =
502 self.registry.gears().iter().map(GearEntry::name).collect();
503 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 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 tuning = tuning
549 .with_internal_token_provider(self.ctx_builder.internal_token_provider().cloned());
550
551 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 crate::discovery::WireOutcome::Local => {
585 self.dep_checker.mark_resolved(reg.dep_gear);
586 }
587 crate::discovery::WireOutcome::Remote if is_static => {
589 self.dep_checker.mark_resolved(reg.dep_gear);
590 }
591 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 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 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 Ok(None) => still_pending.push(dep),
648 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 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 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 async fn run_rest_phase(&self) -> Result<Router, RegistryError> {
730 tracing::info!("Phase: rest (sync)");
731
732 let mut router = Router::new();
733
734 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 => { }
756 _ => return Err(RegistryError::MultipleRestHosts),
757 }
758
759 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 let registry: &dyn crate::contracts::OpenApiRegistry = host.as_registry();
781
782 let hc_registry = Arc::new(
786 crate::healthcheck::RestHealthcheckRegistry::with_cancellation(
787 host_ctx.cancellation_token().clone(),
788 ),
789 );
790
791 hc_registry.register(
797 "readiness",
798 Arc::new(super::readiness::ReadinessHealthcheck::new(
799 self.dep_checker.clone(),
800 )),
801 );
802
803 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 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 if let Some(hc) = rest.healthcheck(&ctx) {
830 hc_registry.register(e.name, hc);
831 }
832 }
833 }
834
835 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 async fn run_grpc_phase(&self) -> Result<(), RegistryError> {
850 tracing::info!("Phase: grpc (registration)");
851
852 if self.registry.grpc_hub.is_none() && self.registry.grpc_services.is_empty() {
854 return Ok(());
855 }
856
857 if self.registry.grpc_hub.is_none() && !self.registry.grpc_services.is_empty() {
859 return Err(RegistryError::GrpcRequiresHub);
860 }
861
862 if let Some(hub_name) = &self.registry.grpc_hub {
864 let mut gears_data = Vec::new();
865 let mut seen = HashSet::new();
866 let mut owners: std::collections::HashMap<String, String> =
870 std::collections::HashMap::new();
871
872 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 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 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 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 async fn run_stop_phase(&self) -> Result<(), RegistryError> {
990 tracing::info!("Phase: stop");
991
992 self.deregister_rest_providers().await;
995
996 let deadline = self.shutdown_deadline;
997
998 for e in self.registry.gears().iter().rev() {
1000 let gear_name = e.name;
1001
1002 let deadline_token = CancellationToken::new();
1005 let deadline_token_for_timeout = deadline_token.clone();
1006
1007 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 Self::stop_one_gear(e, deadline_token).await;
1021
1022 deadline_task.abort();
1024 #[allow(clippy::let_underscore_must_use)]
1025 let _ = deadline_task.await;
1026 }
1027
1028 Ok(())
1029 }
1030
1031 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 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 }
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 let _ = disarm_tx.send(()).ok();
1067
1068 stop_result
1069 }
1070
1071 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 let directory_endpoint = self.wait_for_grpc_hub_endpoint().await;
1085
1086 for gear_cfg in &oop_opts.gears {
1087 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 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 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 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; };
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 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 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(()); };
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 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 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 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 self.rest_providers_registered
1293 .store(true, std::sync::atomic::Ordering::SeqCst);
1294 Ok(())
1295 }
1296
1297 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 async fn deregister_rest_providers(&self) {
1313 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 pub async fn run_gear_phases(self) -> anyhow::Result<()> {
1346 self.run_phases_internal(RunMode::Full).await
1347 }
1348
1349 pub async fn run_migration_phases(self) -> anyhow::Result<()> {
1359 self.run_phases_internal(RunMode::MigrateOnly).await
1360 }
1361
1362 async fn run_phases_internal(self, mode: RunMode) -> anyhow::Result<()> {
1385 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 self.run_pre_init_phase()?;
1397
1398 #[cfg(feature = "db")]
1400 {
1401 self.run_db_phase().await?;
1402 }
1403 #[cfg(not(feature = "db"))]
1404 {
1405 }
1407
1408 if mode == RunMode::MigrateOnly {
1410 tracing::info!("Migration phases completed successfully");
1411 return Ok(());
1412 }
1413
1414 self.run_init_wiring_post_init().await?;
1416
1417 let _router = self.run_rest_phase().await?;
1419
1420 self.run_grpc_phase().await?;
1422
1423 self.run_start_phase().await?;
1425
1426 {
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 self.run_directory_register_phase().await?;
1441
1442 self.run_oop_spawn_phase().await?;
1444
1445 self.cancel.cancelled().await;
1447
1448 self.run_stop_phase_guarded().await?;
1452 Ok(())
1453 }
1454}
1455
1456#[cfg(feature = "bootstrap")]
1458type ComposedOopRouter = (Router, String, toolkit_http_middleware::RouteAuthPolicy);
1459
1460#[cfg(feature = "bootstrap")]
1462impl HostRuntime {
1463 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, ®istry)
1488 .with_context(|| format!("OoP router: register_rest for '{}'", entry.name))?;
1489
1490 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 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 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 let hc_registry = Arc::new(
1545 crate::healthcheck::RestHealthcheckRegistry::with_cancellation(self.cancel.clone()),
1546 );
1547
1548 let readiness = ReadinessState::from_checker(
1555 Arc::clone(&self.dep_checker),
1556 Arc::clone(&hc_registry),
1557 options.healthcheck_timeout,
1558 );
1559
1560 let mut server = super::oop_serve::OopHttpServer::start(
1565 Arc::clone(&readiness),
1566 options,
1567 self.cancel.clone(),
1568 )
1569 .await?;
1570
1571 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 self.run_init_wiring_post_init().await?;
1585 self.run_grpc_phase().await?;
1586 self.run_start_phase().await?;
1587 started = true;
1588 server.resolve_bearer_authenticator(&self.client_hub);
1594 self.compose_oop_router(server.options(), &hc_registry)
1595 .await
1596 }
1597 .await;
1598
1599 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 Ok(()) => server.join().await,
1608 Err(e) => {
1609 tracing::error!(error = %e, "OoP startup failed before serving gear routes");
1610 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 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
1629fn 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 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 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 runtime.run_stop_phase().await.unwrap();
1764
1765 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 runtime.run_stop_phase().await.unwrap();
1840
1841 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 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 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 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 assert_eq!(
1891 endpoint_override(&cfg, "orders", "billing").as_deref(),
1892 Some("http://localhost:8081")
1893 );
1894 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 #[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 #[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 #[test]
1951 fn consumer_wiring_malformed_entry_is_an_error() {
1952 let malformed = [
1953 serde_json::json!([1, 2, 3]),
1955 serde_json::json!({ "max_concurrent_requests": "lots" }),
1958 serde_json::json!({ "timeout": 5 }),
1959 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 #[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 #[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 #[derive(Clone)]
2009 struct WiredCall {
2010 resolver: Arc<dyn crate::discovery::EndpointResolver>,
2011 max_concurrent_requests: Option<usize>,
2012 has_credential: bool,
2015 }
2016 static WIRED: std::sync::Mutex<Vec<WiredCall>> = std::sync::Mutex::new(Vec::new());
2017
2018 #[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 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 #[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(&[®]).unwrap();
2082
2083 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 assert!(runtime.dep_checker.all_resolved());
2108 assert!(runtime.dep_checker.unresolved_deps().is_empty());
2109 }
2110
2111 #[tokio::test]
2116 async fn proxy_wiring_in_process_provider_wired_remote_is_a_hard_error() {
2117 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 let reg = test_registration();
2137 let err = runtime
2138 .wire_consumer_registrations(&[®])
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 assert!(
2148 msg.contains("orders") && msg.contains("billing"),
2149 "displayed error must name both consumer and provider, got: {msg}"
2150 );
2151 }
2152
2153 #[tokio::test]
2157 async fn proxy_wiring_tuning_only_entry_keeps_discovery_and_gates_readiness() {
2158 let mut map = std::collections::HashMap::new();
2159 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(&[®]).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 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 #[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(&[®]).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 #[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(&[®])
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 #[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 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 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 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 #[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 self.stop_was_called.store(true, Ordering::SeqCst);
2501 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 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 runtime.run_stop_phase().await.unwrap();
2535
2536 assert!(
2538 gear.stop_was_called.load(Ordering::SeqCst),
2539 "stop() was never called - gear may not have been registered correctly"
2540 );
2541
2542 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 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 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 assert!(
2616 gear.graceful_completed.load(Ordering::SeqCst),
2617 "graceful shutdown should complete"
2618 );
2619 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 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 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 assert!(
2692 !gear.graceful_completed.load(Ordering::SeqCst),
2693 "graceful shutdown should not complete when deadline fires first"
2694 );
2695 assert!(
2697 gear.deadline_fired.load(Ordering::SeqCst),
2698 "deadline should fire for slow gears"
2699 );
2700 }
2701}