Skip to main content

lattice_plugin_host/
wake.rs

1//! OC.2 — the periodic wake seam: `wake-every` / `cancel-wake` / `on-wake`.
2//!
3//! ## Why a plugin needs one
4//!
5//! A plugin whose *display* changes without the buffer changing has no way to
6//! say so. Org's running clock is the motivating case: the file it wrote is
7//! already correct and nothing edits it again, but the modeline segment
8//! (`◷ 0:14 …`) has to re-render once a minute. Nothing in the editor fires on
9//! "a minute passed", so before this the segment could only advance on the next
10//! keystroke — which is the "it works, but only after I hit something" failure
11//! the boot-composition rules exist to design out.
12//!
13//! The host could have owned an `elapsed-since(T)` element and ticked it with
14//! zero WASM calls. That was rejected (design D7): it moves duration semantics
15//! into the host, and the general wake is a primitive `design.md` Appendix B
16//! already wants for idle hooks. One typed call per minute against a <500 ns p99
17//! budget is negligible on magnitude.
18//!
19//! ## Where the time comes from, and why it is injected
20//!
21//! `lattice-plugin-host` owns no runtime — `tokio` is a dev-dependency and
22//! `futures` was chosen over `tokio::sync` specifically to keep it that way, so
23//! the lib stays executor-agnostic and the caller spawns every actor. A timer is
24//! the first thing that would have broken that, so the timer is injected: the
25//! host holds a [`SleeperHandle`], the loader supplies one backed by
26//! `tokio::time::sleep`, and a harness that supplies none leaves `wake-every`
27//! answering `0` — the same honest degradation every other unwired context here
28//! uses.
29//!
30//! ## Where a wake fires
31//!
32//! On the plugin's **own actor task**, in the same `select` as `on-event`
33//! (`event_task.rs`). That is the whole reason this shape was chosen over a
34//! host-owned scheduler thread: the wake inherits the actor's budget, its
35//! quarantine, and its teardown for free, and there is no cross-thread hop
36//! between the timer and the guest call. Aborting the actor task — what the
37//! loader does on unload — drops the pending sleeps with it, so "cancelled en
38//! masse on deactivate" is structural rather than a step someone must remember.
39//!
40//! The `events` interface is **not** on the sync grammar linker, so a wake is
41//! unreachable from the keystroke path by construction (paramount #4).
42
43use std::collections::HashMap;
44use std::sync::Arc;
45use std::time::Duration;
46
47use futures::future::BoxFuture;
48
49/// The timer the wake seam sleeps on, injected so this crate needs no runtime.
50///
51/// One method, deliberately: a wake needs to know *that* an interval passed, not
52/// what time it is. Anything richer (deadlines, cancellation tokens, an interval
53/// stream) would be an executor's API smuggled back in through the trait.
54pub trait Sleeper: Send + Sync + 'static {
55    /// A future that completes no sooner than `dur` from now. Being late is
56    /// allowed and expected; being early is not.
57    fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()>;
58}
59
60/// The shared handle a [`PluginHost`](crate::PluginHost) is given once at boot.
61pub type SleeperHandle = Arc<dyn Sleeper>;
62
63/// The shortest interval a guest can arm, in milliseconds.
64///
65/// A wake is a full guest call, so `wake-every(0)` is a request to re-enter the
66/// guest as fast as the executor will let it — a busy loop wearing a timer's
67/// clothes, and one that would starve the plugin's own event deliveries since
68/// they share the actor. Clamping rather than refusing keeps a slightly-too-eager
69/// plugin working instead of silently never waking; the clamp is logged and
70/// documented in `events.wit` so it is not a surprise.
71pub(crate) const MIN_WAKE_MS: u32 = 50;
72
73/// Per-plugin wake bookkeeping, held on the plugin's `PluginState` and therefore
74/// owned by its actor.
75///
76/// No lock: the store has exactly one owner at a time. The guest arms and cancels
77/// from inside a guest call (the actor is blocked on it); the actor reads the
78/// registry only between calls. The two never overlap, and saying so here is
79/// cheaper than a mutex that would exist purely to restate it.
80pub(crate) struct WakeCtx {
81    sleeper: SleeperHandle,
82    /// The next id to hand out. Monotonic and never reused — a cancelled id that
83    /// came round again would let a stale in-flight sleep re-arm a wake the guest
84    /// had already disowned.
85    next: u32,
86    /// Armed wakes, id → period. `cancel-wake` removes the entry; a sleep that
87    /// completes for an id no longer here is dropped instead of delivered, which
88    /// is how cancellation reaches a timer already in flight.
89    live: HashMap<u32, Duration>,
90    /// Ids armed since the actor last looked. The actor drains this after every
91    /// guest call and turns each into a pending sleep.
92    newly_armed: Vec<u32>,
93}
94
95impl WakeCtx {
96    pub(crate) fn new(sleeper: SleeperHandle) -> Self {
97        Self {
98            sleeper,
99            next: 1, // `0` is the refusal value; never hand it out.
100            live: HashMap::new(),
101            newly_armed: Vec::new(),
102        }
103    }
104
105    /// Arm a periodic wake; returns its host-allocated id.
106    pub(crate) fn arm(&mut self, plugin: u32, ms: u32) -> u32 {
107        let ms = if ms < MIN_WAKE_MS {
108            tracing::warn!(
109                plugin,
110                requested_ms = ms,
111                clamped_ms = MIN_WAKE_MS,
112                "wake-every interval below the floor; clamped"
113            );
114            MIN_WAKE_MS
115        } else {
116            ms
117        };
118        let id = self.next;
119        self.next = self.next.wrapping_add(1).max(1);
120        self.live.insert(id, Duration::from_millis(u64::from(ms)));
121        self.newly_armed.push(id);
122        tracing::debug!(plugin, wake = id, ms, "wake armed");
123        id
124    }
125
126    /// Disarm a wake. Idempotent — an unknown or already-cancelled id is a no-op,
127    /// so a guest never has to mirror host state to avoid a trap.
128    pub(crate) fn cancel(&mut self, plugin: u32, id: u32) {
129        if self.live.remove(&id).is_some() {
130            tracing::debug!(plugin, wake = id, "wake cancelled");
131        }
132    }
133
134    /// Disarm everything. Called when the plugin is quarantined: its store is
135    /// dead, so re-entering it once a minute forever would be pure waste.
136    pub(crate) fn cancel_all(&mut self) {
137        self.live.clear();
138        self.newly_armed.clear();
139    }
140
141    /// The period of a still-armed wake, or `None` if it was cancelled while its
142    /// sleep was in flight.
143    pub(crate) fn period(&self, id: u32) -> Option<Duration> {
144        self.live.get(&id).copied()
145    }
146
147    /// Take the ids armed since the last call, for the actor to turn into sleeps.
148    pub(crate) fn take_newly_armed(&mut self) -> Vec<u32> {
149        std::mem::take(&mut self.newly_armed)
150    }
151
152    /// A sleep of `dur` that resolves to `id` — the shape the actor's
153    /// `FuturesUnordered` wants.
154    pub(crate) fn sleep_for(&self, id: u32, dur: Duration) -> BoxFuture<'static, u32> {
155        let fut = self.sleeper.sleep(dur);
156        Box::pin(async move {
157            fut.await;
158            id
159        })
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    #![allow(clippy::unwrap_used, clippy::panic)]
166
167    use super::*;
168
169    struct NeverSleeper;
170    impl Sleeper for NeverSleeper {
171        fn sleep(&self, _dur: Duration) -> BoxFuture<'static, ()> {
172            Box::pin(std::future::pending())
173        }
174    }
175
176    fn ctx() -> WakeCtx {
177        WakeCtx::new(Arc::new(NeverSleeper))
178    }
179
180    #[test]
181    fn ids_start_at_one_so_zero_stays_the_refusal_value() {
182        let mut c = ctx();
183        assert_eq!(c.arm(0, 1000), 1);
184        assert_eq!(c.arm(0, 1000), 2);
185    }
186
187    #[test]
188    fn an_interval_below_the_floor_is_clamped_not_refused() {
189        let mut c = ctx();
190        let id = c.arm(0, 0);
191        assert_eq!(
192            c.period(id),
193            Some(Duration::from_millis(u64::from(MIN_WAKE_MS))),
194            "a zero interval arms at the floor rather than spinning the actor"
195        );
196    }
197
198    #[test]
199    fn cancel_is_idempotent_and_drops_the_period() {
200        let mut c = ctx();
201        let id = c.arm(0, 1000);
202        c.cancel(0, id);
203        assert_eq!(
204            c.period(id),
205            None,
206            "a cancelled wake has no period to re-arm"
207        );
208        c.cancel(0, id); // second cancel: no panic, no effect
209        c.cancel(0, 9999); // never-issued id: same
210    }
211
212    #[test]
213    fn a_cancelled_id_is_never_reissued() {
214        let mut c = ctx();
215        let first = c.arm(0, 1000);
216        c.cancel(0, first);
217        let second = c.arm(0, 1000);
218        assert_ne!(
219            first, second,
220            "reuse would let an in-flight sleep re-arm a disowned wake"
221        );
222    }
223
224    #[test]
225    fn newly_armed_drains_once() {
226        let mut c = ctx();
227        c.arm(0, 1000);
228        c.arm(0, 2000);
229        assert_eq!(c.take_newly_armed().len(), 2);
230        assert!(
231            c.take_newly_armed().is_empty(),
232            "a second drain must not re-arm the same sleeps"
233        );
234    }
235
236    #[test]
237    fn cancel_all_clears_pending_arms_too() {
238        let mut c = ctx();
239        let id = c.arm(0, 1000);
240        c.cancel_all();
241        assert_eq!(c.period(id), None);
242        assert!(
243            c.take_newly_armed().is_empty(),
244            "a quarantined plugin must not have a sleep armed after the fact"
245        );
246    }
247}