Skip to content

niso-sched

Generated from crates/niso-sched/CONTRACT.md when the site was built at 7aa23e2.

HTML’s event loop for one agent: a FIFO of queued tasks, pull sources, timers with HTML’s nesting and clamping, animation frame callbacks, the microtask checkpoint’s bracket and a clock that is real or virtual. It knows no subsystem by name. It does not own script, the document or rendering; it calls them through LoopHost and the task hook.

niso’s document (niso-core’s Dom) runs on it: the event loop’s former subsystem clauses are six core sources registered when a Dom is made (websocket, modules, messages, fetch, crypto, rendering), and DomHost implements LoopHost. SimpleHost is the second host, a complete minimal one.

  • Rust:
    • Source (info, ready, run, and the provided deadline, wants_frame, live, begin_run, begin_turn, held, replenish, settled), SourceInfo (name, task_source, class, wants_frames) and Class (Turn, Batch, Settle, Rendering).
    • Scheduler: new(Clock), register_source, queue_task, tasks_pending, set_timer and clear_timer (script’s, HTML’s nesting), set_crate_timer and clear_crate_timer (niso-host’s), clear_timers, timer_due, nothing_scheduled, request_frame, cancel_frame, frame_requested, set_task_hook, begin_checkpoint, end_checkpoint, now, clock.
    • Clock (real, virtual_time, now, advance, instant_at).
    • LoopHost, what a host implements beyond niso_host::HostServices: scheduler, task_ended, update_rendering, park, and the provided collect, closing, frame_due.
    • Over a &dyn LoopHost: run, ready, needs_frame, next_timer, end_task, run_batch, run_frames.
    • FRAME_MS, REPLENISH_WAIT, TaskHookFn.
    • SimpleHost, Dispatch, Sink.
    • testing (below) and, behind the contract-tests feature, contract (scheduler_suite and its checks, no_alloc, source_suite). testing and SimpleHost stay in the normal build: the cdylib keeps nothing of testing and 289 bytes of SimpleHost’s drop glue (nm on the release libniso_app.so; all of niso-sched is 24 KB there), so a feature would not pay for itself.
  • C: include/event-loop.h, its section of the composed niso_runtime.h (niso_dom_run, niso_dom_needs_frame, niso_dom_next_timer, niso_dom_set_task_hook, the wake hook). The functions stay in niso-capi, unchanged, until step 6c generates them.
  • Glue: glue/timers.js, the timer globals (setTimeout and its kin), composed into the glue in the monolith’s order until glue modules exist (step 6c).
  1. A host keeps one Scheduler per agent, registers its sources once, and implements LoopHost with each method borrowing the host only for its own duration: script runs between the calls and may reenter the host.
  2. task_ended is the host’s safe point (niso-host requirement 2). A host with no binding performs the checkpoint-end hooks there too, as SimpleHost does; a binding performs them in its task hook.
  3. A crate’s task the host queues through HostServices::queue_task goes to Scheduler::queue_task wrapped in niso_host::run_task; the scheduler runs what it is given as is. Crate timers go to set_crate_timer, which the scheduler runs with run_task itself.
  4. park returns None only when no source is live, and returns Some(true) once a source may be ready; a host whose wakes all go through its Waker can park on it, as SimpleHost does. SimpleHost returns None for a wake after which no source is live or ready.
  5. A host’s own sources may find the host from a Cx with HostServices::as_any, as the document’s do; a crate’s sources must not (niso-host requirement 8).

niso-host’s contract: Cx, HostState (failures waiting for the run), run_task, Waker.

Each turn: if the host is closing, return; every source’s begin_turn; then the first of

  1. the batch: the tasks queued when the turn began, each ended with end_task;
  2. a Turn source’s task, after which the next turn starts at once, unless until has passed and the run has run for a frame’s time: then the Rendering sources run and the run returns, as after a batch;
  3. a Batch source’s task, which counts as the batch.

After a batch, every Turn and Batch source runs one task in rank order, the Rendering sources run if a frame’s time passed since they last did, and the run returns if until has passed and it has run for a frame’s time. With no batch: if no timer is due and a source is held, return; else if Settle sources are ready, one empty task ends for all of them, then each is settled; else the Rendering sources run.

Then the next item is the earliest of the next timer, by (time, sequence), the frame (frame_due: by default FRAME_MS after the oldest request; the document says the first frame boundary after it, or a frame after the last update while animations run) and the earliest source deadline after now; on a tie a timer goes first, then the frame, then the deadline. If it is later than now: after a batch, the next turn; else once per wait each source may replenish (if one did, return when the item is after until, else next turn); else if a source is live, park until the item or until; else, if the item is after until, return, else skip the clock ahead. A due timer runs (script’s at its nesting level, a crate’s through run_task) and ends its task; a due frame is update_rendering at that time; at a due deadline the next turn starts, where the source is ready.

Rank order: networking, embedder, posted message, then the rest of TaskSource; by name within one.

  1. needs_frame holds exactly when a frame is due at some time (frame_due), a source wants a frame or a run has work (ready); next_timer is now while a run has work, else exactly max(now, min(timers ∪ source deadlines)): never before now, now exactly when a run has work or a timer is due, infinite when nothing is scheduled. A run makes progress exactly when next_timer is now or a frame is due. contract::agreement, over random interleavings of tasks, timers, frames, runs and six sources (turn, budgeted, batch with held messages, settle, rendering, deadlines) on SimpleHost; and on the document, over its queued tasks, crate timers, frames, module messages and window messages (niso-core event_loop.rs tests).
  2. Queued tasks run in FIFO order a batch at a time; a task queued by a task of the batch runs in a later batch. contract::fifo.
  3. Turn and batch sources run in rank order, one task each per turn after a batch. contract::fairness. With no batch, turn sources drain before a due timer runs. contract::turns_drain_before_timers. A turn source that always has work, or a chain of tasks that each queue the next, cannot keep a run from returning once until has passed and it ran for a frame’s time. contract::turns_are_bounded, contract::batches_are_bounded.
  4. Timers fire in (due time, sequence) order; a cleared timer never fires, even when cleared by an earlier task of the same turn; a due timer runs after each batch source’s turn. contract::timers. Script’s timers set more than five levels deep wait at least 4 ms; crate timers are never clamped. contract::nesting. A frame goes before a timer only when it is due strictly earlier. contract::frame_and_timer_ties. An animation frame callback requested in a frame runs in the next frame. contract::frames_requested_in_a_frame.
  5. After every task (queued, a source’s, a timer’s, an animation frame callback) the task hook runs once, then task_ended. contract::checkpoint. Settle sources share one empty task. contract::settle. The rendering steps run after the batch. contract::rendering_after_a_batch.
  6. With a virtual clock and no live source, run(until) moves time only to the next due item and never waits. contract::virtual_time. With a live source it parks until a wake or the item. contract::live_wait. When the last live source stops being live, the parked run returns. contract::live_ends. When a source’s deadline is the next item, the clock moves (or the run parks) to it and the source runs. contract::deadlines. A budgeted source takes per run what it had when the run began, plus one allotment before the clock moves. contract::budgets. While a source is held and no timer is due, the run returns, settle and rendering included; a due timer keeps it going. contract::held.
  7. ready, needs_frame and next_timer allocate nothing and run no script (for sources that keep their own obligations), after each ran once (a platform may make a lock on first use, as macOS does). contract::no_alloc.

ready is true iff run would return Ok(true), budgets and held aside; a source woken from another thread is ready before the wake is delivered; a time-based readiness is reported as a deadline, and the source is ready once the clock reaches it; live is true while another thread may still make it ready, and a source that stops being live wakes the host.

contract::source_suite checks the first of these (not ready, then ready and runs, then not ready once drained). It runs on the fakes and on the document’s modules and messages sources (niso-core event_loop.rs tests). The document’s other four do not run it yet, and get it when they leave the core: websocket in step 7 (niso-websocket; it is ready while a watcher fired, which a read may find had nothing), fetch in step 11 (niso-fetch), crypto in step 12 (niso-crypto). The rendering source stays in the core; it runs whatever is pending, and a media query list that did not change runs nothing, so it gets the suite in step 18, when media’s events move to niso-media’s rendering step and what is left is a plain queue. The wake-on-not-live and deadline obligations have no generic check; the fakes keep them and live_ends and deadlines check the loop’s side.

  • Class has four kinds, not two, and Source has begin_turn, held and settled: the loop’s order before the move (messages yield to timers like a batch, finished fetches and crypto results share one empty task, worker loads are answered every turn) needs them to stay the same.
  • Source::begin_run(until) is new: budgeted sources (native modules) take per run what they had when it began, and the messages source learns the run’s until.
  • “ready iff run returns Ok(true)” is relaxed: a budgeted source over its budget, and a held one, are ready but run nothing until the next run (or a replenish).
  • next_timer does not say now for a frame that is due: needs_frame reports it, and the host runs on its next display frame. 8.2’s “now exactly when run can make progress” holds for everything but a due frame (guarantee 1).
  • 8.2’s set_checkpoint(fn) and set_render(fn) are replaced: the binding’s checkpoint is the task hook (set_task_hook, the C hook as before) and the host’s LoopHost::task_ended; rendering is LoopHost::update_rendering and frame_due.
  • Sources register on the Scheduler (register_source), not through niso-host’s Registrar: the document’s are core-internal, and no crate has a source yet. The Registrar route comes with the first crate that does (step 7).
  • 8.2’s obligation that live “becomes false within one turn after the last such thread is done” is replaced by: a source that stops being live wakes the host. A turn is not a time a parked host would ever reach.
  • queue_task takes no TaskSource (there is one FIFO), and script’s and crates’ timers have separate calls, as their ids are separate spaces.
  • The real-time wait is the host’s park: the document’s is wait_network’s socket poll until WebSocket leaves the core (step 7).
  • run called from inside a task (guarantee 8 of 8.2) is not refused yet; a nested Dom::run keeps its own run state, as before.
  • Guarantee 7 is checked on SimpleHost; the document’s sources take locks in ready as before.
  • testing::ScriptedSource is not “ready at given virtual times”: a test adds its tasks, and it has one deadline at a time, at which it becomes ready and polls.

Two changes to the loop’s behaviour came with the step’s review, beyond the move: a turn source is now bounded per run like a batch (3), and a source’s deadline is an item the run moves the clock or parks to (6). The document’s only deadline is an unwatched WebSocket’s poll a frame ahead, so a run with one now parks at most a frame at a time.

Not Send: a Scheduler and its host live on the agent’s thread. The host’s Waker is what other threads touch; sources are Sync statics whose state lives with the host.

The checks borrow the source list once per call and iterate it (six sources in the document) with one indirect call each and no allocation; needs_frame asks wants_frame only of the sources whose SourceInfo says wants_frames (none of the document’s). Queuing a task boxes its closure once. needs_frame and next_timer costs before and after the move are in the step’s metrics.

testing::ScriptedSource (tasks a test adds; optionally budgeted, live, held, wanting frames or with a deadline; any class), testing::ThreadSource (a real thread delivers after a delay, or gives up, then wakes the host) and testing::Recorder (an ordered log). Their constructors leak, since sources are &'static, so Miri runs the tests with -Zmiri-ignore-leaks: MIRIFLAGS=-Zmiri-ignore-leaks cargo +nightly miri test -p niso-sched. ScriptedSource’s checks are atomics only, so they never allocate or lock.

SimpleHost::new(Clock::virtual_time()), install a crate, register_source, set_sink for events, then run(until). It passes niso-host’s host_suite, as FakeHost and the document do.