Skip to main content

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}