kozan-scheduler 0.1.0

Task scheduler and async executor for the Kozan UI platform
Documentation
//! Microtask queue — HTML spec "perform a microtask checkpoint".
//!
//! # What are microtasks?
//!
//! Microtasks run between macrotasks. After each macrotask completes,
//! the microtask queue is **drained completely** — including any new
//! microtasks enqueued during the drain. This is the HTML spec behavior.
//!
//! # Chrome mapping
//!
//! | Chrome / Spec              | Kozan                           |
//! |----------------------------|---------------------------------|
//! | `Promise.then()`           | `queue_microtask(callback)`     |
//! | `queueMicrotask()`         | `queue_microtask(callback)`     |
//! | `MutationObserver`         | (future: observer microtasks)   |
//! | Microtask checkpoint       | `drain()`                       |
//!
//! # Performance
//!
//! - `VecDeque<Microtask>` — O(1) push/pop, cache-friendly.
//! - Drain is O(n) where n = total microtasks including newly enqueued.
//! - No allocations during drain (`VecDeque` reuses capacity).
//!
//! # Safety: re-entrancy
//!
//! `drain()` pops one microtask at a time and runs it. If the microtask
//! enqueues more microtasks, they are pushed to the same queue and will
//! be processed in the same drain cycle. This matches the spec exactly.
//! The drain loop terminates when the queue is empty.

use std::collections::VecDeque;

/// A microtask callback. Boxed `FnOnce()`, consumed on execution.
///
/// Microtasks are always highest priority and always drain completely.
/// There is no priority differentiation within microtasks.
pub struct Microtask {
    callback: Box<dyn FnOnce()>,
}

impl Microtask {
    /// Create a new microtask.
    #[inline]
    pub fn new(callback: impl FnOnce() + 'static) -> Self {
        Self {
            callback: Box::new(callback),
        }
    }

    /// Execute this microtask, consuming the callback.
    #[inline]
    pub fn run(self) {
        (self.callback)();
    }
}

/// The microtask queue — drained completely after each macrotask.
///
/// Like Chrome's microtask queue in `V8::MicrotaskQueue` / Blink's
/// `EventLoop::PerformMicrotaskCheckpoint()`.
///
/// # Spec behavior
///
/// > "If the microtask queue is not empty:
/// >   1. Let oldestMicrotask be the result of dequeuing from microtask queue.
/// >   2. Set event loop's currently running task to oldestMicrotask.
/// >   3. Run oldestMicrotask.
/// >   4. Set event loop's currently running task back to null.
/// >   5. Go to step 1."
///
/// Key: microtasks enqueued DURING drain are also drained in the same cycle.
pub struct MicrotaskQueue {
    queue: VecDeque<Microtask>,
}

impl MicrotaskQueue {
    /// Create an empty microtask queue.
    #[inline]
    #[must_use]
    pub fn new() -> Self {
        Self {
            queue: VecDeque::new(),
        }
    }

    /// Enqueue a microtask.
    ///
    /// If called during [`drain()`](Self::drain), the new microtask
    /// will be processed in the same drain cycle.
    #[inline]
    pub fn enqueue(&mut self, microtask: Microtask) {
        self.queue.push_back(microtask);
    }

    /// Convenience: enqueue a closure as a microtask.
    #[inline]
    pub fn queue_microtask(&mut self, callback: impl FnOnce() + 'static) {
        self.enqueue(Microtask::new(callback));
    }

    /// Drain the microtask queue completely (microtask checkpoint).
    ///
    /// Runs all microtasks, including any new ones enqueued during drain.
    /// Returns the number of microtasks executed.
    ///
    /// # Re-entrancy
    ///
    /// This takes microtasks one at a time via `pop_front()`. If a
    /// microtask enqueues more, they appear at the back and will be
    /// processed before `drain()` returns. To prevent infinite loops
    /// from buggy user code, a safety limit is enforced.
    pub fn drain(&mut self) -> usize {
        /// Maximum microtasks per drain to prevent infinite loops.
        /// Chrome doesn't have a hard limit, but infinite microtask loops
        /// are a known footgun. 10,000 is generous for legitimate use.
        const MAX_DRAIN: usize = 10_000;

        let mut executed = 0;
        while let Some(microtask) = self.queue.pop_front() {
            microtask.run();
            executed += 1;

            if executed >= MAX_DRAIN {
                // Safety valve. In debug builds, panic to catch the bug.
                debug_assert!(
                    false,
                    "microtask drain exceeded {MAX_DRAIN} — possible infinite loop"
                );
                break;
            }
        }
        executed
    }

    /// Number of pending microtasks.
    #[inline]
    #[must_use]
    pub fn len(&self) -> usize {
        self.queue.len()
    }

    /// Whether the microtask queue is empty.
    #[inline]
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.queue.is_empty()
    }
}

impl Default for MicrotaskQueue {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;
    use std::rc::Rc;

    #[test]
    fn empty_drain_returns_zero() {
        let mut q = MicrotaskQueue::new();
        assert_eq!(q.drain(), 0);
        assert!(q.is_empty());
    }

    #[test]
    fn single_microtask_executes() {
        let called = Rc::new(Cell::new(false));
        let mut q = MicrotaskQueue::new();

        let c = called.clone();
        q.queue_microtask(move || c.set(true));

        assert_eq!(q.len(), 1);
        assert_eq!(q.drain(), 1);
        assert!(called.get());
        assert!(q.is_empty());
    }

    #[test]
    fn drain_order_is_fifo() {
        let log = Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut q = MicrotaskQueue::new();

        for i in 0..5 {
            let l = log.clone();
            q.queue_microtask(move || l.borrow_mut().push(i));
        }

        q.drain();
        assert_eq!(*log.borrow(), vec![0, 1, 2, 3, 4]);
    }

    #[test]
    fn drain_includes_newly_enqueued() {
        // This is the KEY spec behavior:
        // Microtask A enqueues microtask B → B runs in the same drain.
        let log = Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut q = MicrotaskQueue::new();

        // Seed: microtask that enqueues two more.
        let l1 = log.clone();
        q.queue_microtask(move || {
            l1.borrow_mut().push(1);
        });

        let l2 = log.clone();
        q.queue_microtask(move || {
            l2.borrow_mut().push(2);
        });

        assert_eq!(q.drain(), 2);
        assert_eq!(*log.borrow(), vec![1, 2]);

        // Test re-entrant enqueue by manually simulating:
        // First microtask pushes to the queue, then we continue draining.
        let log2 = Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut q2 = MicrotaskQueue::new();

        // We can't actually enqueue during drain with &mut self,
        // but the real scheduler will use a shared reference.
        // For now, test that sequential enqueue + drain works correctly.
        let l = log2.clone();
        q2.queue_microtask(move || l.borrow_mut().push("first"));
        let l = log2.clone();
        q2.queue_microtask(move || l.borrow_mut().push("second"));

        assert_eq!(q2.drain(), 2);
        assert_eq!(*log2.borrow(), vec!["first", "second"]);
    }

    #[test]
    fn enqueue_via_struct() {
        let called = Rc::new(Cell::new(false));
        let mut q = MicrotaskQueue::new();

        let c = called.clone();
        q.enqueue(Microtask::new(move || c.set(true)));
        q.drain();

        assert!(called.get());
    }

    #[test]
    fn len_and_is_empty() {
        let mut q = MicrotaskQueue::new();
        assert!(q.is_empty());
        assert_eq!(q.len(), 0);

        q.queue_microtask(|| {});
        assert!(!q.is_empty());
        assert_eq!(q.len(), 1);

        q.drain();
        assert!(q.is_empty());
    }
}