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}