Skip to main content

cordis/
timer.rs

1//! Fiber-scoped timer primitives for the Cordis kernel.
2//!
3//! Six upstream-style primitives, each registered as an *effect* on the
4//! owning fiber so the kernel's LIFO disposal reclaims them:
5//!
6//! | Primitive | Shape |
7//! |---|---|
8//! | [`timeout`] | one-shot delay → callback → [`EffectHandle`] |
9//! | [`sleep`] | one-shot delay → cancellable future |
10//! | [`interval`] | repeating delay → callback → [`EffectHandle`] |
11//! | [`interval_stream`] | repeating delay → [`Interval`] stream |
12//! | [`debounce`] | trailing-edge collapse of bursts → [`Scheduled`]`<T>` |
13//! | [`throttle`] | leading + optional trailing edge rate limit → [`Scheduled`]`<T>` |
14//!
15//! # Runtime expectations
16//!
17//! Timing runs on a **dedicated shared timer thread** (`cordis-timer`),
18//! never on the owning fiber's task: the thread sleeps until the nearest
19//! deadline in the shared wheel, drains every due entry under one short
20//! critical section, then runs callbacks outside the wheel lock. Wheel
21//! entries are one-shot; repeating registrations re-arm themselves from
22//! inside their own callback. Callbacks must be cheap and non-blocking —
23//! a stuck callback delays later firings but cannot kill the thread
24//! (panics are caught and logged; the thread survives). The [`Interval`]
25//! stream is polled by its owner: ticks queue in a channel while nobody
26//! polls, and after disposal the stream yields exactly ONE final
27//! `Err(InactiveEffect)` item before closing.
28//!
29//! # Fiber scoping
30//!
31//! Every registration made inside a fiber scope (see
32//! [`with_current_fiber`]) pushes an undo onto that fiber via
33//! [`Fiber::push_undo_labeled`]; when the fiber is disposed
34//! ([`Fiber::dispose`]) or reactively passes through `Unloading` (effects
35//! disposed LIFO), the undo cancels that registration. Dropping an
36//! [`EffectHandle`] does NOT cancel anything — dispose it explicitly. A
37//! registration made outside any fiber scope logs a warning and returns an
38//! orphan handle whose explicit disposal still works but which no fiber
39//! cancels automatically.
40//!
41//! ```no_run
42//! use cordis::timer::{timeout, with_current_fiber};
43//! use cordis::{Context, Fiber};
44//! use std::sync::Arc;
45//! use std::time::Duration;
46//!
47//! let ctx = Context::new_root();
48//! let fiber = Arc::new(Fiber::new());
49//! let handle = with_current_fiber(&fiber, || {
50//!     timeout(Duration::from_millis(10), || println!("fired once"))
51//! });
52//! // ...later, from async code: fiber.dispose().await cancels the timer.
53//! ```
54
55use std::cell::RefCell;
56use std::cmp::Ordering as CmpOrdering;
57use std::collections::BinaryHeap;
58use std::future::Future;
59use std::pin::Pin;
60use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
61use std::sync::mpsc::{Receiver, Sender, TryRecvError};
62use std::sync::{Arc, LazyLock, Weak};
63use std::task::{Context as TaskContext, Poll, Waker};
64use std::thread::JoinHandle;
65use std::time::{Duration, Instant};
66
67use parking_lot::Mutex;
68
69use crate::effect::Disposable;
70use crate::fiber::{Fiber, UndoMeta};
71
72// ---------------------------------------------------------------------------
73// Current-fiber scope
74// ---------------------------------------------------------------------------
75
76thread_local! {
77    /// Weak reference to the fiber that timer registrations attach to on
78    /// this thread. Weak is deliberate: registering a timer must not extend
79    /// the fiber's lifetime, and a dead fiber has nothing left to cancel.
80    static CURRENT_FIBER: RefCell<Option<Weak<Fiber>>> = const { RefCell::new(None) };
81}
82
83/// Run `f` with `fiber` installed as the current timer scope, restoring the
84/// previous scope afterwards. Registrations inside `f` push their undo onto
85/// `fiber`, so [`Fiber::dispose`] cancels them.
86pub fn with_current_fiber<R>(fiber: &Arc<Fiber>, f: impl FnOnce() -> R) -> R {
87    let prev = CURRENT_FIBER.with(|slot| slot.borrow_mut().replace(Arc::downgrade(fiber)));
88    let out = f();
89    CURRENT_FIBER.with(|slot| *slot.borrow_mut() = prev);
90    out
91}
92
93/// Read (and upgrade) the current fiber scope WITHOUT consuming it: several
94/// primitives may register under one scope. A dead fiber upgrades to `None`,
95/// which degrades that registration to the orphan path.
96fn current_fiber_scope() -> Option<Arc<Fiber>> {
97    CURRENT_FIBER
98        .with(|slot| slot.borrow().clone())
99        .and_then(|weak| weak.upgrade())
100}
101
102// ---------------------------------------------------------------------------
103// Shared timer wheel
104// ---------------------------------------------------------------------------
105
106type Job = Box<dyn FnOnce() + Send>;
107
108/// One scheduled one-shot entry. Ordered by deadline (min-heap via reversed
109/// [`Ord`]); ties break on `seq` so ordering is total and deterministic.
110struct Entry {
111    deadline: Instant,
112    job: Job,
113    seq: u64,
114}
115
116impl Ord for Entry {
117    fn cmp(&self, other: &Self) -> CmpOrdering {
118        // BinaryHeap is a max-heap; reverse so earliest deadline pops first.
119        other
120            .deadline
121            .cmp(&self.deadline)
122            .then_with(|| other.seq.cmp(&self.seq))
123    }
124}
125
126impl PartialOrd for Entry {
127    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
128        Some(self.cmp(other))
129    }
130}
131
132impl Eq for Entry {}
133
134impl PartialEq for Entry {
135    fn eq(&self, other: &Self) -> bool {
136        self.deadline == other.deadline && self.seq == other.seq
137    }
138}
139
140#[derive(Default)]
141struct Wheel {
142    heap: BinaryHeap<Entry>,
143}
144
145static WHEEL: LazyLock<Arc<Mutex<Wheel>>> =
146    LazyLock::new(|| Arc::new(Mutex::new(Wheel::default())));
147
148static TIMER_THREAD: LazyLock<JoinHandle<()>> = LazyLock::new(|| {
149    std::thread::Builder::new()
150        .name("cordis-timer".into())
151        .spawn(run_timer_thread)
152        .expect("spawn cordis-timer thread")
153});
154
155static NEXT_SEQ: AtomicU64 = AtomicU64::new(1);
156
157/// Insert one entry into the shared wheel, spawning/unparking the shared
158/// timer thread as needed.
159fn schedule(deadline: Instant, job: Job) {
160    let entry =
161        Entry { deadline, job, seq: NEXT_SEQ.fetch_add(1, Ordering::Relaxed) };
162    WHEEL.lock().heap.push(entry);
163    timer_thread().thread().unpark();
164}
165
166fn timer_thread() -> &'static JoinHandle<()> {
167    &TIMER_THREAD
168}
169
170fn run_timer_thread() {
171    loop {
172        // Phase 1 — read the nearest deadline WITHOUT holding the lock, then
173        // sleep until it (or until an unpark announces an earlier insert).
174        let sleep_for: Option<Duration> = {
175            let w = WHEEL.lock();
176            w.heap
177                .peek()
178                .map(|top| top.deadline.saturating_duration_since(Instant::now()))
179        };
180        if sleep_for != Some(Duration::ZERO) {
181            match sleep_for {
182                Some(d) => std::thread::park_timeout(d),
183                None => std::thread::park(),
184            }
185        }
186        // Phase 2 — drain every due entry under one short critical section;
187        // callbacks run AFTER the lock is released so they can schedule new
188        // entries (self-re-arming intervals, debounce/throttle emits)
189        // without reentrant deadlocks.
190        let due: Vec<Job> = {
191            let mut w = WHEEL.lock();
192            let now = Instant::now();
193            let mut fired = Vec::new();
194            while let Some(top) = w.heap.peek() {
195                if top.deadline > now {
196                    break;
197                }
198                fired.push(w.heap.pop().expect("peeked entry exists").job);
199            }
200            fired
201        };
202        for job in due {
203            catch_panic(job);
204        }
205    }
206}
207
208fn catch_panic(job: Job) {
209    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
210    if let Err(err) = result {
211        tracing::warn!(error = ?err, "cordis timer callback panicked; timer thread survives");
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Handles and cancellation
217// ---------------------------------------------------------------------------
218
219struct HandleInner {
220    disposed: AtomicBool,
221    /// Optional extra teardown run exactly once at cancellation (waking a
222    /// sleeping/stream waiter, invalidating armed jobs). Plain timers leave
223    /// it empty.
224    on_dispose: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
225}
226
227impl Default for HandleInner {
228    fn default() -> Self {
229        Self { disposed: AtomicBool::new(false), on_dispose: Mutex::new(None) }
230    }
231}
232
233impl HandleInner {
234    fn set_on_dispose(&self, hook: Box<dyn Fn() + Send + Sync>) {
235        *self.on_dispose.lock() = Some(hook);
236    }
237
238    /// Idempotent cancellation: flips the flag once, then runs the hook.
239    fn trigger_dispose(&self) {
240        if self.disposed.swap(true, Ordering::AcqRel) {
241            return;
242        }
243        if let Some(hook) = self.on_dispose.lock().take() {
244            hook();
245        }
246    }
247}
248
249/// Effect handle for a registered timer primitive.
250///
251/// Clones share one cancellation flag; cancelling any clone cancels the
252/// registration. Cancellation is ALSO pushed onto the owning fiber as a
253/// labeled undo, so kernel-driven fiber teardown cancels timers without
254/// caller action. Dropping the handle does NOT cancel.
255pub struct EffectHandle {
256    inner: Arc<HandleInner>,
257}
258
259impl Clone for EffectHandle {
260    fn clone(&self) -> Self {
261        Self { inner: self.inner.clone() }
262    }
263}
264
265impl EffectHandle {
266    /// True once this registration was cancelled.
267    pub fn is_cancelled(&self) -> bool {
268        self.inner.disposed.load(Ordering::Acquire)
269    }
270
271    fn cancelled(&self) -> bool {
272        self.inner.disposed.load(Ordering::Acquire)
273    }
274}
275
276impl Disposable for EffectHandle {
277    fn dispose(self: Box<Self>) {
278        self.inner.trigger_dispose();
279    }
280}
281
282// ---------------------------------------------------------------------------
283// Registration plumbing
284// ---------------------------------------------------------------------------
285
286/// Undo label prefix used for every timer effect pushed onto a fiber.
287const UNDO_LABEL_PREFIX: &str = "timer:";
288
289/// Cancel the handle produced by `register` through the current fiber scope
290/// (if any). The undo closure owns a clone of the handle and disposes it
291/// when the fiber's undo stack unwinds — through full [`Fiber::dispose`] or
292/// a reactive pass through `Unloading`.
293fn scoped_registration(label: &str, register: impl FnOnce() -> EffectHandle) -> EffectHandle {
294    let handle = register();
295    match current_fiber_scope() {
296        Some(fiber) => {
297            let meta = UndoMeta::new(format!("{UNDO_LABEL_PREFIX}{label}"));
298            let dispose_handle = handle.clone();
299            fiber.push_undo_labeled(
300                meta,
301                Box::new(move || Disposable::dispose(Box::new(dispose_handle))),
302            );
303        }
304        None => tracing::warn!(
305            label = %label,
306            "cordis timer registered outside a fiber scope; nothing will auto-cancel it"
307        ),
308    }
309    handle
310}
311
312// ---------------------------------------------------------------------------
313// timeout / sleep / interval / interval_stream
314// ---------------------------------------------------------------------------
315
316/// One-shot: run `callback` after `delay` on the timer thread.
317///
318/// Returns an [`EffectHandle`] tied to the owning fiber; cancellation
319/// (explicit or via fiber teardown) prevents the callback from ever running.
320pub fn timeout<F>(delay: Duration, callback: F) -> EffectHandle
321where
322    F: FnOnce() + Send + 'static,
323{
324    scoped_registration("timeout", || {
325        let flag = Arc::new(HandleInner::default());
326        let fire_flag = flag.clone();
327        schedule(
328            Instant::now() + delay,
329            Box::new(move || {
330                // Flag check guards the race where cancellation lands after
331                // the drain picked this entry up but before it ran.
332                if !fire_flag.disposed.load(Ordering::Acquire) {
333                    callback();
334                }
335            }),
336        );
337        EffectHandle { inner: flag }
338    })
339}
340
341struct SleepState {
342    done: AtomicBool,
343    cancelled: AtomicBool,
344    waker: Mutex<Option<Waker>>,
345}
346
347impl SleepState {
348    fn new() -> Self {
349        Self {
350            done: AtomicBool::new(false),
351            cancelled: AtomicBool::new(false),
352            waker: Mutex::new(None),
353        }
354    }
355
356    fn resolved(&self) -> bool {
357        self.done.load(Ordering::Acquire) || self.cancelled.load(Ordering::Acquire)
358    }
359}
360
361/// One-shot wait without a callback: the future resolves after `delay`, or
362/// early (silently) when the returned [`EffectHandle`] is disposed first.
363///
364/// The future is driven by its owner; the timer side only flips the
365/// completion flag on the shared thread.
366pub fn sleep(delay: Duration) -> (EffectHandle, impl Future<Output = ()> + Send) {
367    let state = Arc::new(SleepState::new());
368    let handle = scoped_registration("sleep", || {
369        let job_state = state.clone();
370        schedule(
371            Instant::now() + delay,
372            Box::new(move || {
373                job_state.done.store(true, Ordering::Release);
374                if let Some(wk) = job_state.waker.lock().take() {
375                    wk.wake();
376                }
377            }),
378        );
379        let inner = Arc::new(HandleInner::default());
380        let hook_state = state.clone();
381        inner.set_on_dispose(Box::new(move || {
382            hook_state.cancelled.store(true, Ordering::Release);
383            if let Some(wk) = hook_state.waker.lock().take() {
384                wk.wake();
385            }
386        }));
387        EffectHandle { inner }
388    });
389
390    let fut_state = state;
391    (
392        handle,
393        async move {
394            core::future::poll_fn(move |cx| {
395                if fut_state.resolved() {
396                    return Poll::Ready(());
397                }
398                *fut_state.waker.lock() = Some(cx.waker().clone());
399                // Re-check after registering to close the lost-wakeup race.
400                if fut_state.resolved() {
401                    return Poll::Ready(());
402                }
403                Poll::Pending
404            })
405            .await;
406        },
407    )
408}
409
410/// Repeating: run `callback` every `delay`. Each tick re-arms the NEXT tick
411/// from the moment it fires (cadence never runs ahead of the callback).
412pub fn interval<F>(delay: Duration, callback: F) -> EffectHandle
413where
414    F: FnMut() + Send + 'static,
415{
416    scoped_registration("interval", || {
417        let flag = Arc::new(HandleInner::default());
418        // The callback sits in a shared cell so the self-re-arming job can
419        // invoke it by mutable borrow each tick; a panicking callback leaves
420        // the cell intact and simply stops further re-arming.
421        type CallbackCell = Arc<Mutex<Option<Box<dyn FnMut() + Send>>>>;
422        let cell: CallbackCell = Arc::new(Mutex::new(Some(Box::new(callback))));
423        fn rearm(
424            flag: Arc<HandleInner>,
425            cell: CallbackCell,
426            delay: Duration,
427        ) {
428            schedule(
429                Instant::now() + delay,
430                Box::new(move || {
431                    if flag.disposed.load(Ordering::Acquire) {
432                        return; // chain ends; nothing re-armed
433                    }
434                    {
435                        let mut guard = cell.lock();
436                        if let Some(cb) = guard.as_mut() {
437                            cb();
438                        }
439                    }
440                    rearm(flag, cell, delay);
441                }),
442            );
443        }
444        rearm(flag.clone(), cell, delay);
445        EffectHandle { inner: flag }
446    })
447}
448
449/// Sentinel error yielded once by a disposed [`Interval`].
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub struct InactiveEffect;
452
453impl std::fmt::Display for InactiveEffect {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        write!(f, "timer effect went inactive (disposed)")
456    }
457}
458impl std::error::Error for InactiveEffect {}
459
460/// Item type of the [`Interval`] stream: `Ok(())` per tick, then exactly one
461/// final `Err(InactiveEffect)` after disposal, then end-of-stream.
462pub type TickResult = Result<(), InactiveEffect>;
463
464/// Minimal single-item async stream trait mirroring
465/// `futures_core::Stream::poll_next`; implemented by [`Interval`]. cordis
466/// stays dependency-free; a futures-core adapter can wrap it externally.
467pub trait Stream {
468    /// Item type yielded by the stream.
469    type Item;
470    /// Yield the next item, or `None` once the stream has ended.
471    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>>;
472}
473
474enum Tick {
475    Fire,
476    FinalErr,
477}
478
479/// Tick stream from [`interval_stream`]: `Ok(())` per elapsed tick, exactly
480/// ONE `Err(InactiveEffect)` after disposal, then the stream stays closed.
481/// Ticks queue in an unbounded channel while nobody polls, so slow
482/// consumers observe every live tick (no coalescing); live ticks queued
483/// before a disposal are discarded so teardown is always the final
484/// observation.
485pub struct Interval {
486    rx: Receiver<Tick>,
487    state: Arc<HandleInner>,
488    waker: Arc<Mutex<Option<Waker>>>,
489    final_emitted: AtomicBool,
490}
491
492impl Interval {
493    /// True once this stream's registration was disposed.
494    pub fn is_cancelled(&self) -> bool {
495        self.state.disposed.load(Ordering::Acquire)
496    }
497}
498
499impl Drop for Interval {
500    fn drop(&mut self) {
501        // Dropping the stream stops scheduling; idempotent if already torn
502        // down through the fiber.
503        self.state.trigger_dispose();
504    }
505}
506
507impl Stream for Interval {
508    type Item = TickResult;
509
510    fn poll_next(
511        self: Pin<&mut Self>,
512        cx: &mut TaskContext<'_>,
513    ) -> Poll<Option<Self::Item>> {
514        // SAFETY: structural pin projection over owned fields; nothing is
515        // moved out of `self`, so `get_unchecked_mut` cannot violate the
516        // pinning guarantee here.
517        let this = unsafe { self.get_unchecked_mut() };
518        loop {
519            match this.rx.try_recv() {
520                Ok(Tick::Fire) => {
521                    if this.state.disposed.load(Ordering::Acquire) {
522                        continue; // discard ticks queued before disposal
523                    }
524                    return Poll::Ready(Some(Ok(())));
525                }
526                Ok(Tick::FinalErr) => {
527                    if this.final_emitted.swap(true, Ordering::AcqRel) {
528                        continue;
529                    }
530                    return Poll::Ready(Some(Err(InactiveEffect)));
531                }
532                Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => {
533                    if this.final_emitted.load(Ordering::Acquire) {
534                        return Poll::Ready(None);
535                    }
536                    if this.state.disposed.load(Ordering::Acquire) {
537                        // Drain any residual items so the final error is the
538                        // last observation.
539                        while matches!(this.rx.try_recv(), Ok(Tick::Fire)) {}
540                        if !this.final_emitted.swap(true, Ordering::AcqRel) {
541                            return Poll::Ready(Some(Err(InactiveEffect)));
542                        }
543                        return Poll::Ready(None);
544                    }
545                    // Live wait: register, then double-check for the race
546                    // where a tick/disposal landed between try_recv and now.
547                    *this.waker.lock() = Some(cx.waker().clone());
548                    if this.state.disposed.load(Ordering::Acquire) {
549                        continue;
550                    }
551                    match this.rx.try_recv() {
552                        Ok(Tick::FinalErr) => {
553                            if !this.final_emitted.swap(true, Ordering::AcqRel) {
554                                return Poll::Ready(Some(Err(InactiveEffect)));
555                            }
556                        }
557                        Ok(Tick::Fire) => return Poll::Ready(Some(Ok(()))),
558                        Err(_) => return Poll::Pending,
559                    }
560                }
561            }
562        }
563    }
564}
565
566/// Repeating registration returning a stream instead of running a callback.
567///
568/// The returned [`Interval`] is polled by its owner; disposal via the
569/// owning fiber's undo stack produces the single final
570/// `Err(InactiveEffect)` and closes the stream.
571pub fn interval_stream(delay: Duration) -> Interval {
572    let (tx, rx) = std::sync::mpsc::channel::<Tick>();
573    let flag = Arc::new(HandleInner::default());
574    let waker_slot: Arc<Mutex<Option<Waker>>> = Arc::new(Mutex::new(None));
575
576    scoped_registration("interval_stream", || {
577        fn rearm(
578            flag: Arc<HandleInner>,
579            tx: Sender<Tick>,
580            waker_slot: Arc<Mutex<Option<Waker>>>,
581            delay: Duration,
582        ) {
583            schedule(
584                Instant::now() + delay,
585                Box::new(move || {
586                    if flag.disposed.load(Ordering::Acquire) {
587                        // Chain ends: announce teardown exactly once so a
588                        // parked poller observes the final error promptly.
589                        let _ = tx.send(Tick::FinalErr);
590                        if let Some(wk) = waker_slot.lock().take() {
591                            wk.wake();
592                        }
593                        return;
594                    }
595                    let _ = tx.send(Tick::Fire);
596                    if let Some(wk) = waker_slot.lock().take() {
597                        wk.wake();
598                    }
599                    rearm(flag, tx, waker_slot, delay);
600                }),
601            );
602        }
603        // Dispose hook: same prompt-teardown announcement for explicit
604        // cancellation between ticks.
605        let hook_flag = flag.clone();
606        let hook_tx = tx.clone();
607        let hook_waker = waker_slot.clone();
608        flag.set_on_dispose(Box::new(move || {
609            hook_flag.disposed.store(true, Ordering::Release);
610            let _ = hook_tx.send(Tick::FinalErr);
611            if let Some(wk) = hook_waker.lock().take() {
612                wk.wake();
613            }
614        }));
615        rearm(flag.clone(), tx, waker_slot.clone(), delay);
616        EffectHandle { inner: flag.clone() }
617    });
618
619    Interval { rx, state: flag, waker: waker_slot, final_emitted: AtomicBool::new(false) }
620}
621
622// ---------------------------------------------------------------------------
623// Debounce / throttle
624// ---------------------------------------------------------------------------
625
626/// Emitter/consumer pair for [`debounce`] and [`throttle`]: call
627/// [`Scheduled::call`] with each burst value; collapsed/rate-limited
628/// deliveries reach the consumer through [`Scheduled::receive`] /
629/// [`Scheduled::receive_timeout`]. Cancelling the paired handle (explicitly
630/// or through fiber teardown) drops pending values; receives then return
631/// `None`.
632///
633/// The consumer side is synchronous by design: `receive_timeout` parks the
634/// calling thread, which keeps the primitives usable from plain threads and
635/// from `block_on` style glue alike.
636pub struct Scheduled<T> {
637    tx: Sender<T>,
638    rx: Receiver<T>,
639    handle: EffectHandle,
640    submit: Box<dyn Fn(T) + Send + Sync>,
641}
642
643impl<T> Scheduled<T> {
644    /// Submit one value into the burst window. No-op once cancelled.
645    pub fn call(&self, value: T) {
646        if self.handle.cancelled() {
647            return;
648        }
649        (self.submit)(value);
650    }
651
652    /// Non-blocking receive of the next delivered value; `None` when nothing
653    /// is pending or the emitter was cancelled/drained.
654    pub fn receive(&mut self) -> Option<T> {
655        self.rx.try_recv().ok()
656    }
657
658    /// Blocking receive bounded by `timeout`; `None` on timeout or once the
659    /// emitter is cancelled and drained.
660    pub fn receive_timeout(&mut self, timeout: Duration) -> Option<T> {
661        self.rx.recv_timeout(timeout).ok()
662    }
663
664    /// True once this emitter was cancelled.
665    pub fn is_cancelled(&self) -> bool {
666        self.handle.is_cancelled()
667    }
668
669    /// Explicitly cancel this emitter (same effect as fiber disposal).
670    pub fn cancel(&self) {
671        self.handle.inner.trigger_dispose();
672    }
673}
674
675impl<T: Send + 'static> Disposable for Scheduled<T> {
676    fn dispose(self: Box<Self>) {
677        self.handle.inner.trigger_dispose();
678    }
679}
680
681/// Collapse a burst of [`Scheduled::call`]s into ONE trailing delivery,
682/// emitted `delay` after the LAST call of the burst (sliding quiet window).
683/// Every call replaces the pending value and re-arms the emit deadline;
684/// superseded emit jobs observe a stale generation and no-op.
685pub fn debounce<T: Send + 'static>(delay: Duration) -> Scheduled<T> {
686    let (tx, rx) = std::sync::mpsc::channel::<T>();
687    let flag = Arc::new(HandleInner::default());
688    let latest: Arc<Mutex<Option<T>>> = Arc::new(Mutex::new(None));
689    let generation = Arc::new(AtomicU64::new(0));
690
691    let handle = scoped_registration("debounce", || {
692        // Disposal invalidates armed jobs and drops any pending value so a
693        // late emit delivers nothing.
694        let hook_latest = latest.clone();
695        let hook_gen = generation.clone();
696        let hook_flag = flag.clone();
697        flag.set_on_dispose(Box::new(move || {
698            hook_flag.disposed.store(true, Ordering::Release);
699            *hook_latest.lock() = None;
700            hook_gen.fetch_add(1, Ordering::SeqCst);
701        }));
702        EffectHandle { inner: flag.clone() }
703    });
704
705    let submit = {
706        let latest = latest.clone();
707        let generation = generation.clone();
708        let flag = flag.clone();
709        let tx = tx.clone();
710        Box::new(move |value: T| {
711            if flag.disposed.load(Ordering::Acquire) {
712                return;
713            }
714            *latest.lock() = Some(value);
715            let my_gen = generation.fetch_add(1, Ordering::SeqCst) + 1;
716            let emit_latest = latest.clone();
717            let emit_gen = generation.clone();
718            let emit_tx = tx.clone();
719            let emit_flag = flag.clone();
720            schedule(
721                Instant::now() + delay,
722                Box::new(move || {
723                    // Stale job: a newer call re-armed the window.
724                    if emit_gen.load(Ordering::SeqCst) != my_gen {
725                        return;
726                    }
727                    if emit_flag.disposed.load(Ordering::Acquire) {
728                        return;
729                    }
730                    if let Some(v) = emit_latest.lock().take() {
731                        let _ = emit_tx.send(v);
732                    }
733                }),
734            );
735        }) as Box<dyn Fn(T) + Send + Sync>
736    };
737
738    Scheduled { tx, rx, handle, submit }
739}
740
741/// Rate limiter with leading edge plus optional trailing edge.
742///
743/// With `no_trailing = false`: the FIRST value of a quiet-period burst
744/// delivers immediately (leading edge) and the LAST value received during
745/// the window delivers at window end (trailing edge, `delay` after the
746/// leading delivery). With `no_trailing = true`: leading deliveries only;
747/// values arriving during the window are dropped. The window itself is
748/// fixed-length: it always closes `delay` after the leading delivery so a
749/// fresh burst can lead again.
750pub fn throttle<T: Send + 'static>(delay: Duration, no_trailing: bool) -> Scheduled<T> {
751    let (tx, rx) = std::sync::mpsc::channel::<T>();
752    let flag = Arc::new(HandleInner::default());
753    let pending: Arc<Mutex<Option<T>>> = Arc::new(Mutex::new(None));
754    let window_open = Arc::new(AtomicBool::new(false));
755
756    let handle = scoped_registration("throttle", || {
757        // Disposal drops any pending trailing value; the window flag resets
758        // so a cancelled emitter leaves nothing behind.
759        let hook_pending = pending.clone();
760        let hook_window = window_open.clone();
761        let hook_flag = flag.clone();
762        flag.set_on_dispose(Box::new(move || {
763            hook_flag.disposed.store(true, Ordering::Release);
764            *hook_pending.lock() = None;
765            hook_window.store(false, Ordering::Release);
766        }));
767        EffectHandle { inner: flag.clone() }
768    });
769
770    let submit = {
771        let pending = pending.clone();
772        let window_open = window_open.clone();
773        let flag = flag.clone();
774        let tx = tx.clone();
775        Box::new(move |value: T| {
776            if flag.disposed.load(Ordering::Acquire) {
777                return;
778            }
779            if window_open.swap(true, Ordering::SeqCst) {
780                // Window active: keep only the newest value for the trailing
781                // edge (which no_trailing discards at window close anyway).
782                *pending.lock() = Some(value);
783                return;
784            }
785            // Leading edge passes immediately; the close job always runs to
786            // reopen the window, delivering the trailing value only when
787            // requested.
788            let _ = tx.send(value);
789            let close_pending = pending.clone();
790            let close_window = window_open.clone();
791            let close_flag = flag.clone();
792            let close_tx = tx.clone();
793            schedule(
794                Instant::now() + delay,
795                Box::new(move || {
796                    close_window.store(false, Ordering::Release);
797                    if close_flag.disposed.load(Ordering::Acquire) || no_trailing {
798                        return;
799                    }
800                    if let Some(v) = close_pending.lock().take() {
801                        let _ = close_tx.send_for_throttle_trailing(v);
802                    }
803                }),
804            );
805        }) as Box<dyn Fn(T) + Send + Sync>
806    };
807
808    Scheduled { tx, rx, handle, submit }
809}
810
811/// Tiny helper keeping the trailing-edge send site readable; identical to
812/// `Sender::send` apart from naming the intent.
813trait SendForThrottleTrailing<T> {
814    fn send_for_throttle_trailing(&self, value: T) -> Result<(), std::sync::mpsc::SendError<T>>;
815}
816
817impl<T> SendForThrottleTrailing<T> for Sender<T> {
818    fn send_for_throttle_trailing(&self, value: T) -> Result<(), std::sync::mpsc::SendError<T>> {
819        self.send(value)
820    }
821}
822
823// ---------------------------------------------------------------------------
824// Tests
825// ---------------------------------------------------------------------------
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    /// Drive `fut` to completion on the current thread with a bounded
832    /// overall budget so a regression fails fast instead of hanging CI.
833    fn block_on_bounded<F: Future>(fut: F, budget: Duration) -> F::Output {
834        let started = Instant::now();
835        let waker = Waker::noop();
836        let mut cx = TaskContext::from_waker(waker);
837        let mut fut = Box::pin(fut);
838        loop {
839            match fut.as_mut().poll(&mut cx) {
840                Poll::Ready(v) => return v,
841                Poll::Pending => {
842                    assert!(
843                        started.elapsed() <= budget,
844                        "future did not resolve within {budget:?}"
845                    );
846                    std::thread::sleep(Duration::from_millis(2));
847                }
848            }
849        }
850    }
851
852    /// Single-threaded stream poll; `None` means "not ready yet".
853    fn poll_stream_once(stream: &mut Interval) -> Option<TickResult> {
854        let waker = Waker::noop();
855        let mut cx = TaskContext::from_waker(waker);
856        match Stream::poll_next(Pin::new(stream), &mut cx) {
857            Poll::Ready(item) => item,
858            Poll::Pending => None,
859        }
860    }
861
862    #[test]
863    fn timeout_fires_once_and_disposes_with_fiber() {
864        let fiber = Arc::new(Fiber::new());
865        let hits = Arc::new(AtomicU64::new(0));
866        let h = hits.clone();
867        let handle = with_current_fiber(&fiber, || {
868            timeout(Duration::from_millis(20), move || {
869                h.fetch_add(1, Ordering::SeqCst);
870            })
871        });
872        assert!(!handle.is_cancelled());
873        assert_eq!(hits.load(Ordering::SeqCst), 0, "nothing fired before the deadline");
874
875        std::thread::sleep(Duration::from_millis(60));
876        assert_eq!(hits.load(Ordering::SeqCst), 1, "callback must run exactly once");
877
878        // Disposal via the owning fiber cancels the effect.
879        block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
880        assert!(handle.is_cancelled(), "fiber disposal must cancel timers");
881        assert_eq!(hits.load(Ordering::SeqCst), 1, "no extra fire after disposal");
882    }
883
884    #[test]
885    fn timeout_dispose_before_deadline_prevents_fire() {
886        let fiber = Arc::new(Fiber::new());
887        let hits = Arc::new(AtomicU64::new(0));
888        let h = hits.clone();
889        let handle = with_current_fiber(&fiber, || {
890            timeout(Duration::from_millis(60), move || {
891                h.fetch_add(1, Ordering::SeqCst);
892            })
893        });
894        Disposable::dispose(Box::new(handle));
895        std::thread::sleep(Duration::from_millis(90));
896        assert_eq!(hits.load(Ordering::SeqCst), 0, "disposed timeout must never fire");
897    }
898
899    #[test]
900    fn sleep_resolves_and_dispose_resolves_early() {
901        let fiber = Arc::new(Fiber::new());
902        let (handle, fut) = with_current_fiber(&fiber, || sleep(Duration::from_millis(30)));
903        block_on_bounded(fut, Duration::from_secs(2));
904        assert!(!handle.is_cancelled());
905
906        // Early-exit path: disposal resolves a pending sleep immediately.
907        let fiber2 = Arc::new(Fiber::new());
908        let (handle2, fut2) = with_current_fiber(&fiber2, || sleep(Duration::from_millis(500)));
909        let started = Instant::now();
910        let poller = std::thread::spawn(move || {
911            block_on_bounded(fut2, Duration::from_secs(2));
912            started.elapsed()
913        });
914        std::thread::sleep(Duration::from_millis(30));
915        Disposable::dispose(Box::new(handle2));
916        let elapsed = poller.join().expect("poller thread");
917        assert!(
918            elapsed < Duration::from_millis(400),
919            "disposal must resolve the pending sleep early (took {elapsed:?})"
920        );
921    }
922
923    #[test]
924    fn interval_ticks_repeatedly_and_stops_on_dispose() {
925        let fiber = Arc::new(Fiber::new());
926        let hits = Arc::new(AtomicU64::new(0));
927        let h = hits.clone();
928        let handle = with_current_fiber(&fiber, || {
929            interval(Duration::from_millis(10), move || {
930                h.fetch_add(1, Ordering::SeqCst);
931            })
932        });
933        std::thread::sleep(Duration::from_millis(55));
934        let count = hits.load(Ordering::SeqCst);
935        assert!(count >= 2, "interval must tick repeatedly (got {count})");
936
937        Disposable::dispose(Box::new(handle));
938        std::thread::sleep(Duration::from_millis(60));
939        assert_eq!(hits.load(Ordering::SeqCst), count, "ticks must stop after disposal");
940    }
941
942    #[test]
943    fn interval_stream_final_err_on_dispose() {
944        let fiber = Arc::new(Fiber::new());
945        let mut stream =
946            with_current_fiber(&fiber, || interval_stream(Duration::from_millis(10)));
947
948        // Collect two live ticks.
949        let mut live_ticks = 0u32;
950        let deadline = Instant::now() + Duration::from_secs(2);
951        while live_ticks < 2 {
952            assert!(Instant::now() < deadline, "timed out collecting live ticks");
953            if let Some(item) = poll_stream_once(&mut stream) {
954                assert_eq!(item, Ok(()), "live ticks must be Ok");
955                live_ticks += 1;
956            } else {
957                std::thread::sleep(Duration::from_millis(2));
958            }
959        }
960
961        // Dispose through the owning fiber.
962        block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
963
964        // Exactly ONE final Err(InactiveEffect), then end-of-stream.
965        let final_item =
966            poll_stream_once(&mut stream).expect("final err item must arrive after disposal");
967        assert_eq!(final_item, Err(InactiveEffect));
968        assert!(
969            poll_stream_once(&mut stream).is_none(),
970            "stream must terminate after the final error"
971        );
972        assert!(stream.is_cancelled());
973    }
974
975    #[test]
976    fn interval_stream_discards_stale_live_ticks_before_final_err() {
977        let fiber = Arc::new(Fiber::new());
978        let mut stream =
979            with_current_fiber(&fiber, || interval_stream(Duration::from_millis(5)));
980
981        // Accumulate several live ticks WITHOUT polling, then dispose; the
982        // final observation must be the error, not a stale Ok.
983        std::thread::sleep(Duration::from_millis(18));
984        block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
985
986        let mut saw_err = false;
987        let deadline = Instant::now() + Duration::from_secs(2);
988        while Instant::now() < deadline {
989            match poll_stream_once(&mut stream) {
990                Some(Err(InactiveEffect)) => {
991                    saw_err = true;
992                    break;
993                }
994                Some(Ok(())) => {} // stale live tick: keep draining
995                None => std::thread::sleep(Duration::from_millis(2)),
996            }
997        }
998        assert!(saw_err, "teardown must be observable as the final error");
999        assert!(poll_stream_once(&mut stream).is_none());
1000    }
1001
1002    #[test]
1003    fn debounce_collapses_bursts() {
1004        let fiber = Arc::new(Fiber::new());
1005        let mut sched =
1006            with_current_fiber(&fiber, || debounce::<u32>(Duration::from_millis(40)));
1007
1008        // Burst: five calls inside the sliding quiet window (~16ms total).
1009        for i in 0..5 {
1010            sched.call(i);
1011            std::thread::sleep(Duration::from_millis(4));
1012        }
1013
1014        // Only the LAST value survives the window.
1015        let delivered = sched.receive_timeout(Duration::from_secs(2));
1016        assert_eq!(delivered, Some(4), "debounce must deliver only the trailing value");
1017
1018        // Quiet period: no further deliveries.
1019        let extra = sched.receive_timeout(Duration::from_millis(120));
1020        assert_eq!(extra, None, "one burst collapses into exactly one delivery");
1021
1022        // Fiber disposal cancels the emitter; later calls are no-ops.
1023        block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
1024        assert!(sched.is_cancelled());
1025        sched.call(9);
1026        assert_eq!(sched.receive_timeout(Duration::from_millis(50)), None);
1027    }
1028
1029    #[test]
1030    fn throttle_trailing_edge_respected() {
1031        let fiber = Arc::new(Fiber::new());
1032        let mut sched =
1033            with_current_fiber(&fiber, || throttle::<u32>(Duration::from_millis(50), false));
1034
1035        // Burst of five rapid calls (~16ms, inside one 50ms window).
1036        for i in 0..5 {
1037            sched.call(i);
1038            std::thread::sleep(Duration::from_millis(4));
1039        }
1040
1041        let leading = sched.receive_timeout(Duration::from_secs(1));
1042        assert_eq!(leading, Some(0), "leading edge passes immediately");
1043
1044        let trailing = sched.receive_timeout(Duration::from_secs(2));
1045        assert_eq!(trailing, Some(4), "trailing edge must respect the last value");
1046
1047        let extra = sched.receive_timeout(Duration::from_millis(120));
1048        assert_eq!(extra, None, "exactly leading + trailing per burst");
1049
1050        block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
1051        assert!(sched.is_cancelled());
1052    }
1053
1054    #[test]
1055    fn throttle_no_trailing_drops_rest_of_burst() {
1056        let fiber = Arc::new(Fiber::new());
1057        let mut sched =
1058            with_current_fiber(&fiber, || throttle::<u32>(Duration::from_millis(50), true));
1059
1060        for i in 0..4 {
1061            sched.call(i);
1062            std::thread::sleep(Duration::from_millis(4));
1063        }
1064
1065        assert_eq!(sched.receive_timeout(Duration::from_secs(1)), Some(0));
1066        assert_eq!(
1067            sched.receive_timeout(Duration::from_millis(150)),
1068            None,
1069            "no_trailing must drop every value after the leading one"
1070        );
1071    }
1072
1073    #[test]
1074    fn fiber_death_cancels_all_timers() {
1075        let fiber = Arc::new(Fiber::new());
1076
1077        let (t_handle, timeout_hits, interval_hits, i_handle, mut stream) =
1078            with_current_fiber(&fiber, || {
1079            let th = Arc::new(AtomicU64::new(0));
1080            let th2 = th.clone();
1081            let t = timeout(Duration::from_millis(70), move || {
1082                th2.fetch_add(1, Ordering::SeqCst);
1083            });
1084            let ih = Arc::new(AtomicU64::new(0));
1085            let ih2 = ih.clone();
1086            let i = interval(Duration::from_millis(15), move || {
1087                ih2.fetch_add(1, Ordering::SeqCst);
1088            });
1089            let s = interval_stream(Duration::from_millis(12));
1090            (t, th, ih, i, s)
1091        });
1092        let _ = (&t_handle, &i_handle);
1093
1094        // Let the interval tick at least once BEFORE death; the 70ms timeout
1095        // must still be pending.
1096        std::thread::sleep(Duration::from_millis(50));
1097        let pre_interval_hits = interval_hits.load(Ordering::SeqCst);
1098        assert!(pre_interval_hits >= 1, "interval should tick before fiber death");
1099        assert_eq!(timeout_hits.load(Ordering::SeqCst), 0, "timeout still pending");
1100
1101        // Fiber death: every timer effect must be cancelled.
1102        block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
1103
1104        std::thread::sleep(Duration::from_millis(150));
1105        assert_eq!(
1106            timeout_hits.load(Ordering::SeqCst),
1107            0,
1108            "pending timeout must never fire after fiber death"
1109        );
1110        assert_eq!(
1111            interval_hits.load(Ordering::SeqCst),
1112            pre_interval_hits,
1113            "interval must stop ticking after fiber death"
1114        );
1115
1116        // The surviving stream observes the teardown as the final error.
1117        let mut saw_final_err = false;
1118        let deadline = Instant::now() + Duration::from_secs(2);
1119        while Instant::now() < deadline {
1120            match poll_stream_once(&mut stream) {
1121                Some(Err(InactiveEffect)) => {
1122                    saw_final_err = true;
1123                    break;
1124                }
1125                Some(Ok(())) => {} // stale tick, keep polling
1126                None => std::thread::sleep(Duration::from_millis(2)),
1127            }
1128        }
1129        assert!(saw_final_err, "disposed interval_stream must yield Err(InactiveEffect)");
1130        assert!(poll_stream_once(&mut stream).is_none(), "then terminate");
1131    }
1132
1133    #[test]
1134    fn orphan_registration_warns_but_disposable() {
1135        // No fiber scope: registration warns and returns an orphan handle
1136        // whose explicit disposal still prevents firing.
1137        let hits = Arc::new(AtomicU64::new(0));
1138        let h = hits.clone();
1139        let handle = timeout(Duration::from_millis(30), move || {
1140            h.fetch_add(1, Ordering::SeqCst);
1141        });
1142        Disposable::dispose(Box::new(handle));
1143        std::thread::sleep(Duration::from_millis(60));
1144        assert_eq!(hits.load(Ordering::SeqCst), 0);
1145    }
1146
1147    #[test]
1148    fn undo_labels_are_recorded_on_the_fiber() {
1149        let fiber = Arc::new(Fiber::new());
1150        with_current_fiber(&fiber, || {
1151            timeout(Duration::from_millis(500), || {});
1152            interval(Duration::from_millis(500), || {});
1153        });
1154        let labels = fiber.pending_undo_labels();
1155        assert!(
1156            labels.iter().any(|l| l.contains("timer:timeout")),
1157            "labels: {labels:?}"
1158        );
1159        assert!(
1160            labels.iter().any(|l| l.contains("timer:interval")),
1161            "labels: {labels:?}"
1162        );
1163    }
1164}