From f6bff0419827195d1bb9e60c85f842dab02aa9e7 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 21 Aug 2026 17:10:40 +0800 Subject: [PATCH 1/3] fix(hu): stop charging host I/O waits to the guest's epoch budget Every guest dispatch runs under a ~3 s wall-clock epoch deadline, and the ticker measures wall clock. Time a host call spends waiting on the network is therefore charged to the guest even though the guest is not running, so a host call that blocks longer than the remaining budget traps the guest the moment it returns. Four host calls already block for seconds: schema discovery on the publish path, service schema discovery, and both service reply waits. `HostBlockGuard` suspends the ticker for the duration of the wait. It counts its references and carries a unit test, because nothing else would catch its removal. The suspension is process-wide, so a different runaway guest is not preempted while a blocking call is in flight. That window is bounded by the host call's own timeout, and trapping a well-behaved guest for waiting on the network is strictly worse. --- .../hiroz-union/src/plugin/wasm/host/ros.rs | 54 +++++++++---- crates/hiroz-union/src/plugin/wasm/mod.rs | 81 ++++++++++++++++++- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/crates/hiroz-union/src/plugin/wasm/host/ros.rs b/crates/hiroz-union/src/plugin/wasm/host/ros.rs index ccaf17936..ea9c187a2 100644 --- a/crates/hiroz-union/src/plugin/wasm/host/ros.rs +++ b/crates/hiroz-union/src/plugin/wasm/host/ros.rs @@ -190,11 +190,19 @@ impl hu::plugin::ros::Host for PluginState { // Budget discovery independently; a slow/failed round-trip // shouldn't look like a generic encode failure. const DISCOVERY_TIMEOUT: Duration = Duration::from_millis(2000); - let discovered = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on( - node.discover_topic_schema_including_subscribers(&topic, DISCOVERY_TIMEOUT), - ) - }) + let discovered = { + // See `subscribe`: wall-clock waits are charged to the + // guest's epoch budget unless the ticker is suspended. + let _epoch = super::super::HostBlockGuard::enter(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + node.discover_topic_schema_including_subscribers( + &topic, + DISCOVERY_TIMEOUT, + ), + ) + }) + } .map_err(|_| PluginError::NotFound)?; if discovered.schema.type_name != type_name { return Err(PluginError::Invalid(format!( @@ -277,14 +285,18 @@ impl hu::plugin::ros::HostServiceClient for PluginState { // slow/failed discovery round-trip (two get_type_description queries) // can't consume the entire per-call timeout budget. const DISCOVERY_TIMEOUT: Duration = Duration::from_millis(2000); - let (req_schema, resp_schema) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(node.discover_service_schema( - &service_name, - &req_type, - &resp_type, - DISCOVERY_TIMEOUT, - )) - }) + let (req_schema, resp_schema) = { + // See `subscribe`: suspend the epoch ticker across the wait. + let _epoch = super::super::HostBlockGuard::enter(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(node.discover_service_schema( + &service_name, + &req_type, + &resp_type, + DISCOVERY_TIMEOUT, + )) + }) + } .map_err(|_| PluginError::NotFound)?; let req_value = parse_yaml_or_json(&request_json).map_err(PluginError::Invalid)?; @@ -309,7 +321,13 @@ impl hu::plugin::ros::HostServiceClient for PluginState { .wait() .map_err(|e| e.to_string())?; - let reply = replies.recv().map_err(|_| PluginError::Timeout)?; + let reply = { + // The caller's own --timeout can exceed the guest's epoch budget, so + // suspend the ticker across the wait (see `subscribe`). + let _epoch = super::super::HostBlockGuard::enter(); + replies.recv() + } + .map_err(|_| PluginError::Timeout)?; let sample = reply.result().map_err(|e| e.to_string())?; let resp_cdr = sample.payload().to_bytes().into_owned(); @@ -356,7 +374,13 @@ impl hu::plugin::ros::HostServiceClient for PluginState { .wait() .map_err(|e| e.to_string())?; - let reply = replies.recv().map_err(|_| PluginError::Timeout)?; + let reply = { + // The caller's own --timeout can exceed the guest's epoch budget, so + // suspend the ticker across the wait (see `subscribe`). + let _epoch = super::super::HostBlockGuard::enter(); + replies.recv() + } + .map_err(|_| PluginError::Timeout)?; let sample = reply.result().map_err(|e| e.to_string())?; Ok(sample.payload().to_bytes().into_owned()) } diff --git a/crates/hiroz-union/src/plugin/wasm/mod.rs b/crates/hiroz-union/src/plugin/wasm/mod.rs index 4672f1976..868fbf60f 100644 --- a/crates/hiroz-union/src/plugin/wasm/mod.rs +++ b/crates/hiroz-union/src/plugin/wasm/mod.rs @@ -15,6 +15,7 @@ pub use host::web_bindgen::hu::plugin::web_types::{HttpRequest, HttpResponse}; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Duration; @@ -200,6 +201,51 @@ fn configured_wasm_engine() -> Result { Engine::new(&engine_config).context("creating WASM engine") } +// ─── Epoch budget vs. blocking host calls ──────────────────────────────────── + +/// Number of host calls currently blocked on I/O on behalf of a guest. +/// +/// Every guest dispatch runs under `set_epoch_deadline(30)` and the ticker below +/// increments the epoch every 100 ms, so a guest gets ~3 s of *wall clock* — and +/// wall clock is what the ticker measures, so time a host call spends waiting on +/// the network counts against the guest's budget even though the guest is not +/// running. A host call that blocks longer than the remaining budget therefore +/// traps the guest the moment it returns, and the guest never runs the error +/// branch it was just handed. That is not a theoretical bound: schema discovery +/// waits for a live publisher and then queries it, which legitimately takes +/// seconds on a cold graph. +static HOST_BLOCKING_CALLS: AtomicUsize = AtomicUsize::new(0); + +/// Suspends the epoch ticker for as long as it is alive. Hold one around any +/// host call that blocks on I/O, so the wait is not charged to the guest's +/// compute budget. +/// +/// The suspension is process-wide (there is one engine and one ticker), so a +/// *different* runaway guest is not preempted while a blocking call is in +/// flight. That window is bounded by the host call's own timeout, and the +/// alternative — trapping a well-behaved guest for waiting on the network — is +/// strictly worse. +pub(crate) struct HostBlockGuard(()); + +impl HostBlockGuard { + pub(crate) fn enter() -> Self { + HOST_BLOCKING_CALLS.fetch_add(1, Ordering::SeqCst); + Self(()) + } +} + +impl Drop for HostBlockGuard { + fn drop(&mut self) { + HOST_BLOCKING_CALLS.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Whether the epoch ticker should advance right now. Split out so the rule is +/// testable without an engine. +fn epoch_should_tick() -> bool { + HOST_BLOCKING_CALLS.load(Ordering::SeqCst) == 0 +} + /// Process-wide shared WASM engine (and its single epoch-ticker task). Building /// a fresh engine per `load_plugins` call would spawn a new ticker each time — /// the TUI's `reload_plugins` loops, so tickers (each holding an engine clone) @@ -220,7 +266,9 @@ fn shared_wasm_engine() -> Result { handle.spawn(async move { loop { tokio::time::sleep(Duration::from_millis(100)).await; - ticker_engine.increment_epoch(); + if epoch_should_tick() { + ticker_engine.increment_epoch(); + } } }); } else { @@ -232,7 +280,9 @@ fn shared_wasm_engine() -> Result { .spawn(move || { loop { std::thread::sleep(Duration::from_millis(100)); - ticker_engine.increment_epoch(); + if epoch_should_tick() { + ticker_engine.increment_epoch(); + } } }) { @@ -595,3 +645,30 @@ fn plugin_search_dirs() -> Vec { } dirs } + +#[cfg(test)] +mod tests { + use super::{HostBlockGuard, epoch_should_tick}; + + // The ticker's only guard against charging network waits to the guest's + // compute budget is this counter, and it is one `fetch_add` away from being + // silently dropped by a later edit. (Serial by construction: nothing else in + // this crate's unit tests takes the guard, since host calls need a store.) + #[test] + fn host_block_guard_suspends_the_epoch_ticker() { + assert!(epoch_should_tick(), "ticker suspended before any guard"); + { + let _outer = HostBlockGuard::enter(); + assert!(!epoch_should_tick(), "guard did not suspend the ticker"); + { + let _inner = HostBlockGuard::enter(); + assert!(!epoch_should_tick(), "nested guard un-suspended it"); + } + assert!( + !epoch_should_tick(), + "dropping the inner guard resumed ticking while the outer is held" + ); + } + assert!(epoch_should_tick(), "ticker never resumed"); + } +} From aa6809717dad7cb5a506404941e8d4e59cb1ccdb Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 21 Aug 2026 20:13:17 +0800 Subject: [PATCH 2/3] fix(hu): clamp the guest-supplied service timeout The host holds a HostBlockGuard across the service reply wait, and that wait is bounded by `timeout-ms`, which crosses the WIT boundary as a u32 the guest picks. Unclamped, a plugin could ask for ~49 days and so decide how long the host stops preempting plugins -- including itself. The epoch watchdog exists to preempt a guest that will not yield; it must not be a guest-operated switch. Cap it at 60 s, far above any real service call, and warn when the cap actually bites so a plugin author is not left wondering. Also corrects what the trade-off comment claims. The process-wide scope is acceptable because dispatch is serialised -- one plugin at a time in the CLI, the TUI and behind hu web's mutex -- not because the waits are short. The comment now says that, names the per-store alternative (Store::epoch_deadline_callback plus a flag on PluginState), and says what would make the gap real. --- .../hiroz-union/src/plugin/wasm/host/ros.rs | 58 ++++++++++++++++++- crates/hiroz-union/src/plugin/wasm/mod.rs | 29 ++++++++-- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/hiroz-union/src/plugin/wasm/host/ros.rs b/crates/hiroz-union/src/plugin/wasm/host/ros.rs index ea9c187a2..7de8871e3 100644 --- a/crates/hiroz-union/src/plugin/wasm/host/ros.rs +++ b/crates/hiroz-union/src/plugin/wasm/host/ros.rs @@ -16,6 +16,32 @@ use super::super::state::{PluginState, ServiceClientData, SubscriptionData}; use super::hu; use hu::plugin::types::PluginError; +/// Longest a guest-supplied service timeout may suspend the epoch ticker. +/// +/// `timeout-ms` crosses the WIT boundary as a `u32` chosen by the guest, and +/// the host holds a `HostBlockGuard` across the reply wait -- so without a clamp +/// a plugin picks how long the host stops preempting plugins, up to ~49 days. +/// The watchdog exists to preempt a guest that will not yield; it must not be a +/// guest-operated switch. 60 s is far above any real service call and far below +/// "the ticker is off". +const MAX_GUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Clamp a guest-supplied timeout, warning once it is actually reduced so a +/// plugin author is not left wondering why their timeout did not take. +fn clamp_guest_timeout(timeout_ms: u32) -> Duration { + let requested = Duration::from_millis(timeout_ms as u64); + if requested > MAX_GUEST_TIMEOUT { + tracing::warn!( + "plugin asked for a {}ms service timeout; clamping to {}ms", + timeout_ms, + MAX_GUEST_TIMEOUT.as_millis() + ); + MAX_GUEST_TIMEOUT + } else { + requested + } +} + impl PluginState { /// The message type advertised by a live publisher or subscriber on `topic`, /// if any. Used both to build the concrete publish key (`resolve_topic_ke`) @@ -312,7 +338,7 @@ impl hu::plugin::ros::HostServiceClient for PluginState { let sn = self.alloc_rep() as i64; let attachment = hiroz::attachment::Attachment::new(sn, gid); - let timeout = Duration::from_millis(timeout_ms as u64); + let timeout = clamp_guest_timeout(timeout_ms); let replies = session .get(&ke) .payload(zenoh::bytes::ZBytes::from(req_cdr)) @@ -365,7 +391,7 @@ impl hu::plugin::ros::HostServiceClient for PluginState { let sn = self.alloc_rep() as i64; let attachment = hiroz::attachment::Attachment::new(sn, gid); - let timeout = Duration::from_millis(timeout_ms as u64); + let timeout = clamp_guest_timeout(timeout_ms); let replies = session .get(&ke) .payload(zenoh::bytes::ZBytes::from(payload)) @@ -556,3 +582,31 @@ mod service_type_name_tests { ); } } + +#[cfg(test)] +mod guest_timeout_tests { + use super::{MAX_GUEST_TIMEOUT, clamp_guest_timeout}; + use std::time::Duration; + + // The clamp is what stops a guest-supplied `timeout-ms` from deciding how + // long the host suspends the epoch ticker for every plugin in the process. + // It is one `min` away from being dropped by a later edit. + #[test] + fn a_reasonable_timeout_passes_through() { + assert_eq!(clamp_guest_timeout(2_000), Duration::from_millis(2_000)); + } + + #[test] + fn the_maximum_itself_is_not_clamped() { + assert_eq!( + clamp_guest_timeout(MAX_GUEST_TIMEOUT.as_millis() as u32), + MAX_GUEST_TIMEOUT + ); + } + + #[test] + fn an_absurd_timeout_is_clamped() { + // ~49 days, the worst a u32 can ask for. + assert_eq!(clamp_guest_timeout(u32::MAX), MAX_GUEST_TIMEOUT); + } +} diff --git a/crates/hiroz-union/src/plugin/wasm/mod.rs b/crates/hiroz-union/src/plugin/wasm/mod.rs index 868fbf60f..54ab21b98 100644 --- a/crates/hiroz-union/src/plugin/wasm/mod.rs +++ b/crates/hiroz-union/src/plugin/wasm/mod.rs @@ -220,11 +220,30 @@ static HOST_BLOCKING_CALLS: AtomicUsize = AtomicUsize::new(0); /// host call that blocks on I/O, so the wait is not charged to the guest's /// compute budget. /// -/// The suspension is process-wide (there is one engine and one ticker), so a -/// *different* runaway guest is not preempted while a blocking call is in -/// flight. That window is bounded by the host call's own timeout, and the -/// alternative — trapping a well-behaved guest for waiting on the network — is -/// strictly worse. +/// The suspension is process-wide: there is one engine and one ticker, so while +/// a blocking call is in flight no guest is preempted, not only the one that +/// made the call. +/// +/// What makes that acceptable today is that **dispatch is serialised** — the CLI +/// runs one plugin in a sequential loop, the TUI iterates plugins in order, and +/// `hu web` holds a single mutex over the whole plugin vector — so two guests +/// cannot run at once and there is no other guest to preempt. It is the +/// serialisation, not the length of the wait, that bounds the cost. Give web +/// mode per-plugin locks or a plugin pool and this becomes a real gap; the +/// per-store shape below is the fix at that point. +/// +/// The waits themselves are bounded, but check where each bound comes from: the +/// two discovery calls use a fixed 2 s constant, while the service reply waits +/// use `timeout-ms`, which the guest chooses. `clamp_guest_timeout` in +/// `host/ros.rs` caps that, because an unclamped `u32` would let a plugin decide +/// how long the host stops preempting plugins. +/// +/// The per-store alternative, for whoever needs it: wasmtime exposes +/// `Store::epoch_deadline_callback`, and host functions already hold +/// `&mut PluginState`, which is the store data. A flag there plus a callback +/// returning `UpdateDeadline::Continue` would extend only the blocking guest's +/// deadline and leave every other guest preemptible. That is strictly better and +/// strictly more work; this counter is equivalent while dispatch is serialised. pub(crate) struct HostBlockGuard(()); impl HostBlockGuard { From b0732109039500e449449ac733be73a755eddcfe Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 21 Aug 2026 21:21:02 +0800 Subject: [PATCH 3/3] docs(hu): rewrite the epoch-budget comments in simplified technical english Applies the ASD-STE100 sentence rules to the comments this branch adds: one idea per sentence, active voice with a named actor, present tense for what the code does. Measured on the changed comment lines: longest sentence 16 to 14 words, passive constructions 5 to 1, sentences joining two independent clauses 1 to 0. The remaining passive, "The waits themselves are bounded", hides no actor -- the next two sentences name every bound. The three "See `subscribe`" call-site comments are deliberately untouched. They carry the wording of the branch this slice was cut from, and changing them here would break that correspondence. --- .../hiroz-union/src/plugin/wasm/host/ros.rs | 27 +++--- crates/hiroz-union/src/plugin/wasm/mod.rs | 84 ++++++++++--------- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/crates/hiroz-union/src/plugin/wasm/host/ros.rs b/crates/hiroz-union/src/plugin/wasm/host/ros.rs index 7de8871e3..18a77d36d 100644 --- a/crates/hiroz-union/src/plugin/wasm/host/ros.rs +++ b/crates/hiroz-union/src/plugin/wasm/host/ros.rs @@ -16,18 +16,21 @@ use super::super::state::{PluginState, ServiceClientData, SubscriptionData}; use super::hu; use hu::plugin::types::PluginError; -/// Longest a guest-supplied service timeout may suspend the epoch ticker. +/// The longest time that a guest-supplied service timeout may suspend the +/// epoch ticker. /// -/// `timeout-ms` crosses the WIT boundary as a `u32` chosen by the guest, and -/// the host holds a `HostBlockGuard` across the reply wait -- so without a clamp -/// a plugin picks how long the host stops preempting plugins, up to ~49 days. -/// The watchdog exists to preempt a guest that will not yield; it must not be a -/// guest-operated switch. 60 s is far above any real service call and far below -/// "the ticker is off". +/// `timeout-ms` crosses the WIT boundary as a `u32`, and the guest chooses its +/// value. The host holds a `HostBlockGuard` across the reply wait. Without a +/// clamp, a plugin picks how long the host stops preempting plugins. An +/// unclamped `u32` allows about 49 days. +/// +/// The watchdog exists to preempt a guest that does not yield. The guest must +/// not control it. 60 s is far above any real service call. It is also far +/// below a disabled ticker. const MAX_GUEST_TIMEOUT: Duration = Duration::from_secs(60); -/// Clamp a guest-supplied timeout, warning once it is actually reduced so a -/// plugin author is not left wondering why their timeout did not take. +/// Caps a guest-supplied timeout. The host writes a warning when it reduces the +/// value, so that the plugin author sees why the timeout changed. fn clamp_guest_timeout(timeout_ms: u32) -> Duration { let requested = Duration::from_millis(timeout_ms as u64); if requested > MAX_GUEST_TIMEOUT { @@ -588,9 +591,9 @@ mod guest_timeout_tests { use super::{MAX_GUEST_TIMEOUT, clamp_guest_timeout}; use std::time::Duration; - // The clamp is what stops a guest-supplied `timeout-ms` from deciding how - // long the host suspends the epoch ticker for every plugin in the process. - // It is one `min` away from being dropped by a later edit. + // This clamp stops a guest-supplied `timeout-ms` from deciding how long the + // host suspends the epoch ticker. That suspension covers every plugin in + // the process. A later edit can delete the clamp in one line. #[test] fn a_reasonable_timeout_passes_through() { assert_eq!(clamp_guest_timeout(2_000), Duration::from_millis(2_000)); diff --git a/crates/hiroz-union/src/plugin/wasm/mod.rs b/crates/hiroz-union/src/plugin/wasm/mod.rs index 54ab21b98..50ed46897 100644 --- a/crates/hiroz-union/src/plugin/wasm/mod.rs +++ b/crates/hiroz-union/src/plugin/wasm/mod.rs @@ -203,47 +203,51 @@ fn configured_wasm_engine() -> Result { // ─── Epoch budget vs. blocking host calls ──────────────────────────────────── -/// Number of host calls currently blocked on I/O on behalf of a guest. +/// Number of host calls that block on I/O for a guest at this moment. /// -/// Every guest dispatch runs under `set_epoch_deadline(30)` and the ticker below -/// increments the epoch every 100 ms, so a guest gets ~3 s of *wall clock* — and -/// wall clock is what the ticker measures, so time a host call spends waiting on -/// the network counts against the guest's budget even though the guest is not -/// running. A host call that blocks longer than the remaining budget therefore -/// traps the guest the moment it returns, and the guest never runs the error -/// branch it was just handed. That is not a theoretical bound: schema discovery -/// waits for a live publisher and then queries it, which legitimately takes -/// seconds on a cold graph. +/// Every guest dispatch calls `set_epoch_deadline(30)`. The ticker below +/// increments the epoch every 100 ms. A guest therefore gets about 3 s of +/// *wall clock*. +/// +/// The ticker measures wall clock. It does not measure guest execution. A host +/// call that waits on the network spends the guest's budget while the guest +/// does not run. wasmtime then traps the guest as soon as that call returns. +/// The guest never runs the code that the call gave it. +/// +/// This bound is not theoretical. Schema discovery waits for a live publisher +/// and then queries it. On a cold graph that takes seconds. static HOST_BLOCKING_CALLS: AtomicUsize = AtomicUsize::new(0); -/// Suspends the epoch ticker for as long as it is alive. Hold one around any -/// host call that blocks on I/O, so the wait is not charged to the guest's -/// compute budget. +/// Suspends the epoch ticker while this guard is alive. Hold one around any +/// host call that blocks on I/O. The host then does not charge the wait to the +/// guest's compute budget. +/// +/// The host runs one engine and one ticker. The suspension therefore applies to +/// every guest. It does not apply only to the guest that called the host. /// -/// The suspension is process-wide: there is one engine and one ticker, so while -/// a blocking call is in flight no guest is preempted, not only the one that -/// made the call. +/// Serialised dispatch makes this acceptable today. The CLI runs one plugin in +/// a sequential loop. The TUI iterates the plugins in order. `hu web` holds one +/// mutex over the plugin vector. Two guests cannot run at once, so no second +/// guest exists to preempt. /// -/// What makes that acceptable today is that **dispatch is serialised** — the CLI -/// runs one plugin in a sequential loop, the TUI iterates plugins in order, and -/// `hu web` holds a single mutex over the whole plugin vector — so two guests -/// cannot run at once and there is no other guest to preempt. It is the -/// serialisation, not the length of the wait, that bounds the cost. Give web -/// mode per-plugin locks or a plugin pool and this becomes a real gap; the -/// per-store shape below is the fix at that point. +/// The serialisation bounds the cost. The length of the wait does not bound it. +/// A reader can easily confuse these two reasons, and only the first one holds. +/// Per-plugin locks or a plugin pool would remove the serialisation. The gap +/// then becomes real, and the per-store shape below is the fix. /// -/// The waits themselves are bounded, but check where each bound comes from: the -/// two discovery calls use a fixed 2 s constant, while the service reply waits -/// use `timeout-ms`, which the guest chooses. `clamp_guest_timeout` in -/// `host/ros.rs` caps that, because an unclamped `u32` would let a plugin decide -/// how long the host stops preempting plugins. +/// The waits themselves are bounded. Check where each bound comes from. The two +/// discovery calls use a fixed 2 s constant. The service reply waits use +/// `timeout-ms`, and the guest chooses that value. `clamp_guest_timeout` in +/// `host/ros.rs` caps it. An unclamped `u32` would let a plugin choose how long +/// the host stops preempting plugins. /// -/// The per-store alternative, for whoever needs it: wasmtime exposes +/// The per-store alternative, for whoever needs it: wasmtime supplies /// `Store::epoch_deadline_callback`, and host functions already hold -/// `&mut PluginState`, which is the store data. A flag there plus a callback -/// returning `UpdateDeadline::Continue` would extend only the blocking guest's -/// deadline and leave every other guest preemptible. That is strictly better and -/// strictly more work; this counter is equivalent while dispatch is serialised. +/// `&mut PluginState`, which is the store data. A flag there and a callback that +/// returns `UpdateDeadline::Continue` extend the deadline of the blocked guest +/// alone. Every other guest stays preemptible. That design is better, and it +/// costs more work. This counter behaves the same way while dispatch stays +/// serialised. pub(crate) struct HostBlockGuard(()); impl HostBlockGuard { @@ -259,8 +263,8 @@ impl Drop for HostBlockGuard { } } -/// Whether the epoch ticker should advance right now. Split out so the rule is -/// testable without an engine. +/// Reports whether the epoch ticker may advance now. This function is separate +/// so that a test can check the rule without an engine. fn epoch_should_tick() -> bool { HOST_BLOCKING_CALLS.load(Ordering::SeqCst) == 0 } @@ -669,10 +673,12 @@ fn plugin_search_dirs() -> Vec { mod tests { use super::{HostBlockGuard, epoch_should_tick}; - // The ticker's only guard against charging network waits to the guest's - // compute budget is this counter, and it is one `fetch_add` away from being - // silently dropped by a later edit. (Serial by construction: nothing else in - // this crate's unit tests takes the guard, since host calls need a store.) + // This counter is the only thing that stops the ticker from charging + // network waits to the guest's compute budget. A later edit can delete one + // `fetch_add` and remove that protection. No other test finds it. + // + // This test is serial by construction. No other unit test in this crate + // takes the guard, because a host call needs a store. #[test] fn host_block_guard_suspends_the_epoch_ticker() { assert!(epoch_should_tick(), "ticker suspended before any guard");