Repository navigation
fix(module): wait for setup before reporting a module Ready #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
20f8ed0
da58a10
0ca3de2
a14d88f
b12f71a
38ef2fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,12 @@ | |
| //! search path, then the release cache (verified against the host's pinned | ||
| //! digest, downloaded on a miss when allowed), then pruning of stale versions. | ||
| //! | ||
| //! A module counts as serving only once its setup has finished: every rung ends | ||
| //! by waiting for the module to signal ready, and a fault or failure during | ||
| //! setup fails the load with the module's reason. A cached `Ready` is re-checked | ||
| //! against the host on each call, so a module that faults later is reported as | ||
| //! failed rather than answering every call with a bare `Unavailable`. | ||
| //! | ||
| //! Each module has one slot in [`super::resolution`]. The first caller runs the | ||
| //! resolution as a process-lifetime task and every other caller waits on its | ||
| //! outcome; two modules never queue behind each other. | ||
|
|
@@ -67,6 +73,35 @@ fn is_live_match(info: &ModuleInfo, record: &ReleaseRecord) -> bool { | |
| ) | ||
| } | ||
|
|
||
| /// How long a resolution waits for a loaded module to finish setup before it | ||
| /// gives up on it. Setup (starting a server, opening a socket) runs after the | ||
| /// module's `init` returns, so loading alone says nothing about it. | ||
| const SETUP_DEADLINE: Duration = Duration::from_secs(30); | ||
|
|
||
| /// Why `info` is not serving, or `Ok` when it is serving or has nothing running | ||
| /// whose outcome could be awaited (a lazy module that has not been called yet). | ||
| fn setup_outcome(info: &ModuleInfo, id: &str) -> Result<(), String> { | ||
| match &info.state { | ||
| ModuleState::Faulted { reason } | ModuleState::Failed { reason } => { | ||
| Err(format!("module '{id}' faulted during setup: {reason}")) | ||
| } | ||
| ModuleState::Rejected { reason } | ModuleState::Unresolved { reason } => { | ||
| Err(format!("module '{id}' was refused: {reason}")) | ||
| } | ||
| ModuleState::Stopped | ModuleState::Disabled => { | ||
| Err(format!("module '{id}' is not running")) | ||
| } | ||
| ModuleState::Initializing => Err(format!( | ||
| "module '{id}' did not finish setup within {} seconds", | ||
| SETUP_DEADLINE.as_secs() | ||
| )), | ||
| ModuleState::Discovered | ||
| | ModuleState::Resolved | ||
| | ModuleState::Ready | ||
| | ModuleState::Serving => Ok(()), | ||
| } | ||
| } | ||
|
|
||
| /// Result indexes refer to the input manifest slice. | ||
| pub(crate) struct LoadOrder { | ||
| pub(crate) order: Vec<usize>, | ||
|
|
@@ -516,7 +551,7 @@ impl Resolver { | |
| "module '{id}' is already loaded without this callback binding" | ||
| ))) | ||
| } else { | ||
| Ok(()) | ||
| self.still_serving(record).await | ||
| }; | ||
| } | ||
| Claim::Done(Resolution::Failed(reason)) => { | ||
|
|
@@ -542,6 +577,7 @@ impl Resolver { | |
| }; | ||
| match resolution::global().wait(id, receiver, within).await { | ||
| Waited::Ready => { | ||
| self.still_serving(record).await?; | ||
| if let Some((binding, _)) = callbacks.as_ref() { | ||
| let runtime = self.runtime().await.map_err(|_| { | ||
| LoadError::Failed(format!( | ||
|
|
@@ -564,6 +600,32 @@ impl Resolver { | |
| } | ||
| } | ||
|
|
||
| /// Re-check a cached `Ready` against the host. A module can fault after it | ||
| /// was reported ready; the host then drops its name, so serving the cached | ||
| /// outcome would turn every later call into a bare `Unavailable`. The slot | ||
| /// settles as failed instead and the caller gets the module's reason. | ||
| async fn still_serving(&self, record: &'static ReleaseRecord) -> Result<(), LoadError> { | ||
| let Ok(runtime) = self.runtime().await else { | ||
| return Ok(()); | ||
| }; | ||
| let faulted = runtime.host().list().into_iter().find_map(|info| { | ||
| (info.manifest.bus_name.as_str() == record.bus_name | ||
| && matches!( | ||
| info.state, | ||
| ModuleState::Faulted { .. } | ModuleState::Failed { .. } | ||
|
Comment on lines
+611
to
+615
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a healthy loaded module shares its bus name with a rejected artifact whose eager initialization produced Useful? React with 👍 / 👎. |
||
| )) | ||
| .then(|| setup_outcome(&info, record.id).err()) | ||
| .flatten() | ||
| }); | ||
| let Some(reason) = faulted else { | ||
| return Ok(()); | ||
| }; | ||
| let reason = self.mark_terminal(reason); | ||
| resolution::global().mark_faulted(record.id, reason.clone()); | ||
| self.report(record, Reason::ResolutionFailed); | ||
| Err(LoadError::Failed(reason)) | ||
| } | ||
|
|
||
| /// The state of `id` as the resolution table reports it, without touching it. | ||
| #[must_use] | ||
| pub fn state_of(&self, id: &str) -> Availability { | ||
|
|
@@ -669,11 +731,34 @@ impl Resolver { | |
| } | ||
| } | ||
|
|
||
| /// Get `record` serving: loaded, override, search path, release cache, prune. | ||
| /// Get `record` serving: load it, then wait for its setup to finish. | ||
| /// | ||
| /// Every rung (file, linked, search path, release cache, already loaded) | ||
| /// ends here, so none of them can report `Ready` for a module whose setup is | ||
| /// still running or has faulted. | ||
| async fn ladder( | ||
| &self, | ||
| record: &'static ReleaseRecord, | ||
| callbacks: Option<(Arc<HostCallbackBinding>, serde_json::Value)>, | ||
| ) -> Result<(), String> { | ||
| self.load_rungs(record, callbacks).await?; | ||
| let runtime = self.runtime().await.map_err(|_| { | ||
| format!( | ||
| "module '{}' is unavailable: the module bus could not start", | ||
| record.id | ||
| ) | ||
| })?; | ||
| match runtime.host().settle(record.bus_name, SETUP_DEADLINE).await { | ||
| Some(info) => setup_outcome(&info, record.id), | ||
| None => Ok(()), | ||
| } | ||
| } | ||
|
|
||
| /// Load: loaded, override, search path, release cache, prune. | ||
| async fn load_rungs( | ||
| &self, | ||
| record: &'static ReleaseRecord, | ||
| callbacks: Option<(Arc<HostCallbackBinding>, serde_json::Value)>, | ||
| ) -> Result<(), String> { | ||
| let runtime = self.runtime().await.map_err(|_| { | ||
| format!( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the first call to a lazy module starts initialization between these two
snapshot()calls, the condition observesResolvedbut the returned snapshot isInitializing;ladderthen treats that immediate result as if the 30-second deadline elapsed and permanently caches the healthy module asFailed. Capture one snapshot and return that same value (and cover this interleaving) so this branch has a consistent lazy-state decision.AGENTS.md reference: AGENTS.md:L59-L61
Useful? React with 👍 / 👎.