perspective_viewer/utils/debounce.rs
1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::cell::Cell;
14use std::future::Future;
15use std::rc::Rc;
16
17use async_lock::Mutex;
18use perspective_js::utils::ApiResult;
19
20use super::pubsub::PubSub;
21
22/// Proof that the bearer is executing inside a [`DebounceMutex`]-locked task.
23///
24/// Every plugin-dispatching function (`draw_view`, `activate_plugin`, the
25/// restyle/resize/export wrappers) and the snapshot pipeline
26/// (`bind_snapshot`) require `&RenderGuard`, so an unlocked plugin call or
27/// out-of-pipeline render is a missing witness — a compile error, not a
28/// review rule. Constructed ONLY by [`DebounceMutex::lock_with`] /
29/// [`DebounceMutex::debounce_with`], after the lock is acquired; it is not
30/// `Clone`, so it cannot be stashed for use outside the task that received
31/// it.
32pub struct RenderGuard {
33 _private: (),
34}
35
36/// The per-slot debounce bookkeeping: one running + one parked evaluation,
37/// with coalesced callers awaiting the parked runner's settle. Kept
38/// per-[`DebounceSlot`] so coalescing is only expressible WITHIN a kind —
39/// a caller can never be absorbed by a parked runner of a different kind
40/// whose work does not subsume it (the resize-eaten-by-parked-update bug).
41#[derive(Default)]
42struct SlotState {
43 id: Cell<u64>,
44 last: Cell<u64>,
45 parked: Cell<bool>,
46 settled_id: Cell<u64>,
47 on_settle: PubSub<()>,
48}
49
50#[derive(Default)]
51struct DebounceMutexData {
52 held: Cell<bool>,
53 mutex: Mutex<()>,
54 default_slot: Rc<SlotState>,
55}
56
57/// Clears the `held` flag on drop, so cancellation of a locked task (its
58/// future dropped mid-await) can't leave [`DebounceMutex::is_held`] stuck
59/// `true`.
60struct HeldFlag<'a>(&'a Cell<bool>);
61
62impl<'a> HeldFlag<'a> {
63 fn set(cell: &'a Cell<bool>) -> Self {
64 cell.set(true);
65 Self(cell)
66 }
67}
68
69impl Drop for HeldFlag<'_> {
70 fn drop(&mut self) {
71 self.0.set(false);
72 }
73}
74
75/// RAII for the parked debounce runner: clears `parked` and broadcasts the
76/// runner's settle on drop. Running through `Drop` (not a happy-path call)
77/// makes the release unconditional — a runner cancelled mid-park or
78/// mid-run still frees the parked slot and releases its coalesced waiters,
79/// so they can never be stranded awaiting a settle that no task will emit.
80struct SettleGuard<'a> {
81 state: &'a SlotState,
82 next: u64,
83}
84
85impl<'a> SettleGuard<'a> {
86 fn park(state: &'a SlotState, next: u64) -> Self {
87 state.parked.set(true);
88 Self { state, next }
89 }
90}
91
92impl Drop for SettleGuard<'_> {
93 fn drop(&mut self) {
94 self.state.parked.set(false);
95 if self.state.settled_id.get() < self.next {
96 self.state.settled_id.set(self.next);
97 }
98
99 self.state.on_settle.emit(());
100 }
101}
102
103/// An async `Mutex` type specialized for Perspective's rendering, which
104/// debounces calls in addition to providing exclusivity. Calling
105/// [`Self::debounce`] resolves only after at least one complete evaluation
106/// of a call that began no earlier than this one — either by running the
107/// caller's own future, or by coalescing onto the single parked trailing
108/// runner. At most TWO debounce evaluations are ever outstanding (one
109/// running, one parked); every additional concurrent caller resolves
110/// without queueing on the lock.
111#[derive(Clone, Default)]
112pub struct DebounceMutex(Rc<DebounceMutexData>);
113
114impl DebounceMutex {
115 /// `true` while a locked task is executing. Used by lock-acquiring
116 /// public API methods to emit a debug-build warning when they are about
117 /// to queue behind an in-flight run — legitimate for app callers, a
118 /// guaranteed deadlock when reached synchronously from a plugin's render
119 /// (the render-callable contract on `js::plugin` forbids it).
120 pub fn is_held(&self) -> bool {
121 self.0.held.get()
122 }
123
124 /// Lock like a normal `Mutex`.
125 pub async fn lock<T>(&self, f: impl Future<Output = T>) -> T {
126 self.lock_with(|_| f).await
127 }
128
129 /// Lock, passing a [`RenderGuard`] witness into the task builder. The
130 /// task future is CONSTRUCTED after the lock is acquired — a guard can
131 /// never exist outside a locked section.
132 pub async fn lock_with<T, F, Fut>(&self, f: F) -> T
133 where
134 F: FnOnce(RenderGuard) -> Fut,
135 Fut: Future<Output = T>,
136 {
137 let guard = self.0.mutex.lock().await;
138 let held = HeldFlag::set(&self.0.held);
139 let result = f(RenderGuard { _private: () }).await;
140 drop(held);
141 drop(guard);
142 result
143 }
144
145 /// Lock and also debounce `f`, which should be cancellable.
146 pub async fn debounce(&self, f: impl Future<Output = ApiResult<()>>) -> ApiResult<()> {
147 self.debounce_with(|_| f).await
148 }
149
150 /// [`Self::debounce`] with a [`RenderGuard`] witness (see
151 pub async fn debounce_with<T, F, Fut>(&self, f: F) -> ApiResult<T>
152 where
153 T: Default,
154 F: FnOnce(RenderGuard) -> Fut,
155 Fut: Future<Output = ApiResult<T>>,
156 {
157 DebounceSlot {
158 mutex: self.clone(),
159 state: self.0.default_slot.clone(),
160 }
161 .debounce_with(f)
162 .await
163 }
164
165 /// A NEW debounce slot over this mutex: its tasks serialize against
166 /// every other locked task, but coalesce only among themselves.
167 pub fn slot(&self) -> DebounceSlot {
168 DebounceSlot {
169 mutex: self.clone(),
170 state: Default::default(),
171 }
172 }
173}
174
175/// One debounce KIND over a shared [`DebounceMutex`]. Execution is
176/// exclusive across the whole mutex; coalescing (a caller resolving via a
177/// parked runner instead of queueing) is scoped to the slot. A coalesced
178/// caller's work must be subsumable by any same-slot runner that runs no
179/// earlier than the call — which is why parameterized tasks sharing a slot
180/// must read their parameters at RUN time (e.g. the [`Renderer`] geometry
181/// command cell), never capture them at call time.
182#[derive(Clone)]
183pub struct DebounceSlot {
184 mutex: DebounceMutex,
185 state: Rc<SlotState>,
186}
187
188impl DebounceSlot {
189 pub async fn debounce_with<T, F, Fut>(&self, f: F) -> ApiResult<T>
190 where
191 T: Default,
192 F: FnOnce(RenderGuard) -> Fut,
193 Fut: Future<Output = ApiResult<T>>,
194 {
195 let state = &self.state;
196 let next = state.id.get() + 1;
197 if state.parked.get() {
198 self.await_settled(next).await;
199 return Ok(T::default());
200 }
201
202 let settle = SettleGuard::park(state, next);
203 let guard = self.mutex.0.mutex.lock().await;
204 state.parked.set(false);
205 let result = if state.last.get() < next {
206 let next = state.id.get() + 1;
207 state.id.set(next);
208 let held = HeldFlag::set(&self.mutex.0.held);
209 let result = f(RenderGuard { _private: () }).await;
210 drop(held);
211 if result.is_ok() {
212 state.last.set(next);
213 }
214
215 result
216 } else {
217 Ok(T::default())
218 };
219
220 drop(guard);
221 drop(settle);
222 result
223 }
224
225 async fn await_settled(&self, next: u64) {
226 while self.state.settled_id.get() < next {
227 if self.state.on_settle.read_next().await.is_err() {
228 break;
229 }
230 }
231 }
232}