1use 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
72thread_local! {
77 static CURRENT_FIBER: RefCell<Option<Weak<Fiber>>> = const { RefCell::new(None) };
81}
82
83pub 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
93fn current_fiber_scope() -> Option<Arc<Fiber>> {
97 CURRENT_FIBER
98 .with(|slot| slot.borrow().clone())
99 .and_then(|weak| weak.upgrade())
100}
101
102type Job = Box<dyn FnOnce() + Send>;
107
108struct Entry {
111 deadline: Instant,
112 job: Job,
113 seq: u64,
114}
115
116impl Ord for Entry {
117 fn cmp(&self, other: &Self) -> CmpOrdering {
118 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
157fn 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 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 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
215struct HandleInner {
220 disposed: AtomicBool,
221 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 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
249pub 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 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
282const UNDO_LABEL_PREFIX: &str = "timer:";
288
289fn 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
312pub 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 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
361pub 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 if fut_state.resolved() {
401 return Poll::Ready(());
402 }
403 Poll::Pending
404 })
405 .await;
406 },
407 )
408}
409
410pub 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 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; }
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#[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
460pub type TickResult = Result<(), InactiveEffect>;
463
464pub trait Stream {
468 type Item;
470 fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>>;
472}
473
474enum Tick {
475 Fire,
476 FinalErr,
477}
478
479pub 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 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 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 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; }
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 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 *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
566pub 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 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 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
622pub 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 pub fn call(&self, value: T) {
646 if self.handle.cancelled() {
647 return;
648 }
649 (self.submit)(value);
650 }
651
652 pub fn receive(&mut self) -> Option<T> {
655 self.rx.try_recv().ok()
656 }
657
658 pub fn receive_timeout(&mut self, timeout: Duration) -> Option<T> {
661 self.rx.recv_timeout(timeout).ok()
662 }
663
664 pub fn is_cancelled(&self) -> bool {
666 self.handle.is_cancelled()
667 }
668
669 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
681pub 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 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 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
741pub 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 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 *pending.lock() = Some(value);
783 return;
784 }
785 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
811trait 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#[cfg(test)]
828mod tests {
829 use super::*;
830
831 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 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 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 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 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 block_on_bounded(fiber.dispose(), Duration::from_secs(2)).unwrap();
963
964 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 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(())) => {} 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 for i in 0..5 {
1010 sched.call(i);
1011 std::thread::sleep(Duration::from_millis(4));
1012 }
1013
1014 let delivered = sched.receive_timeout(Duration::from_secs(2));
1016 assert_eq!(delivered, Some(4), "debounce must deliver only the trailing value");
1017
1018 let extra = sched.receive_timeout(Duration::from_millis(120));
1020 assert_eq!(extra, None, "one burst collapses into exactly one delivery");
1021
1022 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 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 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 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 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(())) => {} 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 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}