From b7b03f405fa74f417eef80f42b08cf416bc27052 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 6 Aug 2026 08:41:32 +0000 Subject: [PATCH 1/2] feat(supervisor): bound provider install by the dispatch deadline AI Assistance: Claude Fable 5 used for implementation and tests --- crates/nexum-runtime/src/host/extension.rs | 2 + crates/nexum-runtime/src/supervisor/load.rs | 11 +- .../src/supervisor/tests/lifecycle.rs | 109 ++++++++++++++++-- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index fce70db..9409ac9 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -171,6 +171,8 @@ pub trait ProviderKind: Send + Sync + 'static { /// Instantiate and install one provider; [`Installed::Dead`] is a failed /// guest `init`, `Err` a boot error. + /// Runs under the dispatch deadline and must be cancel-safe: a timeout + /// drops the future mid-flight, before any registration completes. async fn install( &self, instance: ProviderInstance<'_, T>, diff --git a/crates/nexum-runtime/src/supervisor/load.rs b/crates/nexum-runtime/src/supervisor/load.rs index a1dccdf..024e850 100644 --- a/crates/nexum-runtime/src/supervisor/load.rs +++ b/crates/nexum-runtime/src/supervisor/load.rs @@ -183,6 +183,9 @@ pub(super) async fn instantiate_module( /// Builds the kind's linker and installs on the given store; a `Dead` /// verdict carries no error, its meaning stays with the caller. +/// `event_deadline` bounds the whole install (instantiation, guest `init`, +/// extension wiring), wider than a module's `init`-only bound, so a hung +/// install cannot park boot or the sweep. pub(super) async fn install_provider( shared: &Shared, row: &ProviderRow, @@ -193,8 +196,12 @@ pub(super) async fn install_provider( ) -> Result { let (kind, service) = row; let linker = build_provider_linker::(&shared.engine, kind.as_ref())?; - kind.install(seed.instance(&linker, sections, store, liveness), service) - .await + with_dispatch_deadline( + seed.event_deadline, + kind.install(seed.instance(&linker, sections, store, liveness), service), + ) + .await + .map_err(Error::from)? } /// A failed `init` loads the module dead; the dispatcher skips it. diff --git a/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs b/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs index 68d97e3..c4ab2c1 100644 --- a/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs +++ b/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs @@ -539,12 +539,13 @@ impl Extension for ScriptedExtension { } } -/// The shared wiring the sweep reinstalls through, with the scripted kind -/// registered. The `TempDir` keeps the local store alive. -fn scripted_shared(live: &Arc) -> (tempfile::TempDir, Shared) { +/// The shared wiring the sweep reinstalls through, with the given +/// extension's kind registered. The `TempDir` keeps the local store alive. +fn kind_shared( + extension: Arc>, +) -> (tempfile::TempDir, Shared) { let (dir, store) = temp_local_store(); - let extensions: Vec>> = - vec![Arc::new(ScriptedExtension(live.clone()))]; + let extensions = vec![extension]; let services = HostServices::from_extensions(&extensions).expect("services"); let kinds = crate::supervisor::admission::provider_kinds(&extensions, &services).expect("kinds"); @@ -560,13 +561,16 @@ fn scripted_shared(live: &Arc) -> (tempfile::TempDir, Shared crate::supervisor::load::LoadedProvider { +/// test kinds never instantiate it. +fn provider_at_run_zero( + engine: &wasmtime::Engine, + kind: &'static str, +) -> crate::supervisor::load::LoadedProvider { const EMPTY_COMPONENT: &[u8] = b"(component)"; let limits = ModuleLimits::default(); crate::supervisor::load::LoadedProvider { name: "scripted".into(), - kind: "scripted-adapter", + kind, sections: manifest::ExtensionSections::default(), seed: crate::supervisor::load::Seed { artifact: crate::supervisor::load::CachedArtifact { @@ -597,8 +601,8 @@ fn scripted_provider(engine: &wasmtime::Engine) -> crate::supervisor::load::Load #[tokio::test] async fn a_dead_provider_reinstall_defers_without_committing_a_run() { let live = Arc::new(AtomicBool::new(false)); - let (_dir, shared) = scripted_shared(&live); - let mut provider = scripted_provider(&shared.engine); + let (_dir, shared) = kind_shared(Arc::new(ScriptedExtension(live.clone()))); + let mut provider = provider_at_run_zero(&shared.engine, "scripted-adapter"); let policy = crate::runtime::poison_policy::PoisonPolicy::new(9, Duration::from_secs(600)); // The actor trapped: the sweep discovers the death through the shared @@ -635,3 +639,88 @@ async fn a_dead_provider_reinstall_defers_without_committing_a_run() { "a reinstall is a fresh instance, so the curve resets", ); } + +/// A provider kind whose `install` never returns, for the deadline gate. +struct HangingKind; + +#[async_trait::async_trait] +impl ProviderKind for HangingKind { + fn kind(&self) -> &'static str { + "hanging-adapter" + } + + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + + async fn install( + &self, + _instance: ProviderInstance<'_, CoreRuntime>, + _service: &Arc, + ) -> anyhow::Result { + std::future::pending().await + } +} + +struct HangingExtension; + +impl Extension for HangingExtension { + fn namespace(&self) -> &'static str { + "hanging" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:hanging/", + ifaces: &[], + } + } + + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::new(ScriptedService)) + } + + fn provider(&self) -> Option>> { + Some(Box::new(HangingKind)) + } +} + +/// A hung `install` fails by the dispatch deadline and defers, instead of +/// parking the sweep (and with it all dispatch) forever. +#[tokio::test(start_paused = true)] +async fn a_hanging_provider_install_fails_by_deadline() { + let (_dir, shared) = kind_shared(Arc::new(HangingExtension)); + let mut provider = provider_at_run_zero(&shared.engine, "hanging-adapter"); + let policy = crate::runtime::poison_policy::PoisonPolicy::new(9, Duration::from_secs(600)); + + provider.liveness.mark_dead(); + let died_at = provider.liveness.dead_since().expect("marked dead"); + let now = died_at + Duration::from_secs(5); + // Paused time auto-advances only through timers; an unwrapped hung + // install would ride this outer timeout instead of its own deadline. + tokio::time::timeout( + Duration::from_secs(3_600), + sweep(&shared, std::slice::from_mut(&mut provider), policy, now), + ) + .await + .expect("sweep completed: the install deadline bounded the hung install"); + + assert_eq!(provider.run.seq, 0, "a timed-out install commits no run"); + assert!( + !provider.liveness.is_alive(), + "a timed-out install leaves the liveness dead", + ); + assert_eq!( + provider.health.failure_count(), + 2, + "one recorded trap plus one deferred restart", + ); + assert!( + provider.health.due_restart(now + Duration::from_secs(60)), + "a deadline hit defers rather than killing the provider permanently", + ); +} From 9bb6b2af6cb30265b411117fd016dc74d228ddc2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 6 Aug 2026 08:59:19 +0000 Subject: [PATCH 2/2] fix(supervisor): name the install timeout and cover the boot call site AI Assistance: Claude Opus 5 used for the adversarial review and these fixes. --- crates/nexum-runtime/src/host/extension.rs | 4 +- crates/nexum-runtime/src/supervisor/load.rs | 6 +-- .../src/supervisor/tests/lifecycle.rs | 49 ++++++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 9409ac9..1eb9c98 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -171,8 +171,8 @@ pub trait ProviderKind: Send + Sync + 'static { /// Instantiate and install one provider; [`Installed::Dead`] is a failed /// guest `init`, `Err` a boot error. - /// Runs under the dispatch deadline and must be cancel-safe: a timeout - /// drops the future mid-flight, before any registration completes. + /// The host bounds this call by the dispatch deadline and drops the future + /// at its next await, so publish into `service` only after the last await. async fn install( &self, instance: ProviderInstance<'_, T>, diff --git a/crates/nexum-runtime/src/supervisor/load.rs b/crates/nexum-runtime/src/supervisor/load.rs index 024e850..f91eaa5 100644 --- a/crates/nexum-runtime/src/supervisor/load.rs +++ b/crates/nexum-runtime/src/supervisor/load.rs @@ -184,8 +184,7 @@ pub(super) async fn instantiate_module( /// Builds the kind's linker and installs on the given store; a `Dead` /// verdict carries no error, its meaning stays with the caller. /// `event_deadline` bounds the whole install (instantiation, guest `init`, -/// extension wiring), wider than a module's `init`-only bound, so a hung -/// install cannot park boot or the sweep. +/// extension wiring), not only the guest call a module's bound covers. pub(super) async fn install_provider( shared: &Shared, row: &ProviderRow, @@ -201,7 +200,8 @@ pub(super) async fn install_provider( kind.install(seed.instance(&linker, sections, store, liveness), service), ) .await - .map_err(Error::from)? + .map_err(Error::from) + .with_context(|| format!("provider kind {} did not install in time", kind.kind()))? } /// A failed `init` loads the module dead; the dispatcher skips it. diff --git a/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs b/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs index c4ab2c1..eae6eb1 100644 --- a/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs +++ b/crates/nexum-runtime/src/supervisor/tests/lifecycle.rs @@ -640,6 +640,14 @@ async fn a_dead_provider_reinstall_defers_without_committing_a_run() { ); } +/// Distinct from every default, so an assertion on elapsed paused time pins +/// which duration bounded the install. +const INSTALL_DEADLINE: Duration = Duration::from_secs(7); + +/// Long enough that a wrapped install always wins the race; a hung unwrapped +/// one rides this instead and fails the test. +const OUTER_TIMEOUT: Duration = Duration::from_secs(3_600); + /// A provider kind whose `install` never returns, for the deadline gate. struct HangingKind; @@ -695,20 +703,27 @@ impl Extension for HangingExtension { async fn a_hanging_provider_install_fails_by_deadline() { let (_dir, shared) = kind_shared(Arc::new(HangingExtension)); let mut provider = provider_at_run_zero(&shared.engine, "hanging-adapter"); + provider.seed.event_deadline = INSTALL_DEADLINE; let policy = crate::runtime::poison_policy::PoisonPolicy::new(9, Duration::from_secs(600)); provider.liveness.mark_dead(); let died_at = provider.liveness.dead_since().expect("marked dead"); let now = died_at + Duration::from_secs(5); + let started = tokio::time::Instant::now(); // Paused time auto-advances only through timers; an unwrapped hung // install would ride this outer timeout instead of its own deadline. tokio::time::timeout( - Duration::from_secs(3_600), + OUTER_TIMEOUT, sweep(&shared, std::slice::from_mut(&mut provider), policy, now), ) .await .expect("sweep completed: the install deadline bounded the hung install"); + assert_eq!( + started.elapsed(), + INSTALL_DEADLINE, + "the install rode the seed's deadline, not another timer", + ); assert_eq!(provider.run.seq, 0, "a timed-out install commits no run"); assert!( !provider.liveness.is_alive(), @@ -724,3 +739,35 @@ async fn a_hanging_provider_install_fails_by_deadline() { "a deadline hit defers rather than killing the provider permanently", ); } + +/// The boot call site carries the same bound: a hung `install` refuses the +/// boot instead of parking the launch forever. +#[tokio::test(start_paused = true)] +async fn a_hanging_provider_install_refuses_the_boot_by_deadline() { + let extension: Arc> = Arc::new(HangingExtension); + let scenario = BootScenario::new() + .extensions([extension]) + .limits(ModuleLimits { + event_deadline_secs: Some(INSTALL_DEADLINE.as_secs()), + ..Default::default() + }); + let wasm = scenario.dir().join("hanging.wasm"); + std::fs::write(&wasm, b"(component)").expect("write component"); + + let started = tokio::time::Instant::now(); + let refusal = tokio::time::timeout( + OUTER_TIMEOUT, + scenario + .adapter(Entry::new(TestManifest::new("hanging").kind("hanging-adapter")).wasm(wasm)) + .expect_refusal(), + ) + .await + .expect("boot returned: the install deadline bounded the hung install"); + + assert_eq!( + started.elapsed(), + INSTALL_DEADLINE, + "the install rode the configured deadline, not another timer", + ); + refusal.names("did not install in time"); +}