Class: Dommy::Scheduler
- Inherits:
-
Object
- Object
- Dommy::Scheduler
- Defined in:
- lib/dommy/scheduler.rb
Overview
Deterministic host-side scheduler for timers, rAF, and microtasks.
Time advances only when the host explicitly calls advance_time.
Defined Under Namespace
Classes: Timer
Constant Summary collapse
- FRAME_MS =
16- MAX_NESTING_BEFORE_CLAMP =
HTML timer initialization steps: once a timer is nested deeper than 5, a sub-4ms timeout is clamped to 4ms. Besides matching browsers, this is what stops a self-rescheduling setTimeout(0) / setInterval(0) from spinning forever at the same virtual instant (the clamp pushes it into a future frame, so
advance_time(0)'s due-now loop terminates). 5- MIN_NESTED_DELAY_MS =
4- IDLE_DEADLINE =
requestIdleCallback has no real idle period here; the callback always sees a fixed budget and didTimeout: false.
{"timeRemaining" => 50.0, "didTimeout" => false}.freeze
Instance Attribute Summary collapse
-
#microtask_checkpoint ⇒ Object
An optional hook (set by a JS runtime) that drains the ENGINE's microtask (promise-job) queue — the other half of a microtask checkpoint.
-
#native_microtask_scheduler ⇒ Object
An optional hook (set by a JS runtime) that enqueues a microtask onto the engine's NATIVE promise-job queue.
-
#now_ms ⇒ Object
readonly
Returns the value of attribute now_ms.
-
#raf_checkpoint_each ⇒ Object
See @raf_checkpoint_each: opt-in per-callback microtask checkpointing for animation frames (test harnesses driving async frameworks).
-
#timer_error_handler ⇒ Object
An optional hook (set by a JS runtime) invoked when a timer / rAF / idle callback raises.
Instance Method Summary collapse
- #advance_time(delta_ms) ⇒ Object
- #cancel_animation_frame(id) ⇒ Object
- #cancel_idle_callback(id) ⇒ Object
- #clear_interval(id) ⇒ Object
- #clear_timeout(id) ⇒ Object
-
#deliver_external ⇒ Object
Run all externally-posted completions.
- #drain_microtasks ⇒ Object
- #drain_timers(advance: 0) ⇒ Object
-
#external_pending? ⇒ Boolean
True when a worker has handed back work not yet delivered.
-
#initialize ⇒ Scheduler
constructor
A new instance of Scheduler.
-
#next_animation_frame_at ⇒ Object
The earliest due time among pending requestAnimationFrame callbacks, or nil when none are scheduled.
-
#next_due_timer_at ⇒ Object
Public accessor for eval-time auto-drain: keep advancing the clock until no timers remain (or a safety budget runs out).
-
#perform_microtask_checkpoint ⇒ Object
A WHATWG microtask checkpoint: drain the scheduler's own microtask queue AND the engine's promise-job queue (via the runtime hook).
-
#post_external(&thunk) ⇒ Object
Post a completion to be run on the page (JS) thread the next time the event loop drains.
- #queue_microtask(callback) ⇒ Object
- #request_animation_frame(callback) ⇒ Object
-
#request_idle_callback(callback, timeout = 0) ⇒ Object
WHATWG requestIdleCallback — modeled as a deferred timer that hands the callback an IdleDeadline-shaped Hash.
- #set_interval(callback, interval_ms) ⇒ Object
- #set_timeout(callback, delay_ms) ⇒ Object
Constructor Details
#initialize ⇒ Scheduler
Returns a new instance of Scheduler.
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/dommy/scheduler.rb', line 23 def initialize @now_ms = 0 @next_id = 1 @timers = {} @microtasks = [] @native_microtask_scheduler = nil @external_inbox = Thread::Queue.new # The nesting level of the timer task currently running (0 at top level); # a timer scheduled while it runs nests one deeper. Drives the 4ms clamp. @nesting_level = 0 # Opt-in: run a microtask checkpoint after EACH animation-frame callback # rather than once after the whole frame's batch. Off by default (WHATWG # batches the frame's rAF callbacks under a single checkpoint). A test # harness that drives async frameworks enables it so a framework whose # rAF-scheduled promise chain (Turbo's stream render -> self-removal) must # settle before another same-frame rAF callback (the test's own # `await nextAnimationFrame()` assertion) observes the result — matching the # ordering a real browser's inter-frame timing produces in practice. @raf_checkpoint_each = false end |
Instance Attribute Details
#microtask_checkpoint ⇒ Object
An optional hook (set by a JS runtime) that drains the ENGINE's microtask
(promise-job) queue — the other half of a microtask checkpoint. The
scheduler owns only its own @microtasks; the real microtask queue lives
in the JS engine, so a spec-compliant checkpoint must drain both. WHATWG
§8.1.7.3: the event loop performs a microtask checkpoint after running each
task. Without this the scheduler would run every due timer task back-to-back
and only drain microtasks once at the end, which reorders a microtask queued
by one task after the next task — breaking code (Apollo/RxJS link chains)
that relies on the per-task checkpoint. Absent in vanilla CRuby use (then a
checkpoint drains only @microtasks).
99 100 101 |
# File 'lib/dommy/scheduler.rb', line 99 def microtask_checkpoint @microtask_checkpoint end |
#native_microtask_scheduler ⇒ Object
An optional hook (set by a JS runtime) that enqueues a microtask onto the
engine's NATIVE promise-job queue. When present, queue_microtask routes
through it so host-side microtasks (e.g. MutationObserver delivery)
interleave FIFO with JS await/Promise reactions instead of draining on a
separate pass. Absent in vanilla CRuby use (falls back to @microtasks).
77 78 79 |
# File 'lib/dommy/scheduler.rb', line 77 def native_microtask_scheduler @native_microtask_scheduler end |
#now_ms ⇒ Object (readonly)
Returns the value of attribute now_ms.
44 45 46 |
# File 'lib/dommy/scheduler.rb', line 44 def now_ms @now_ms end |
#raf_checkpoint_each ⇒ Object
See @raf_checkpoint_each: opt-in per-callback microtask checkpointing for animation frames (test harnesses driving async frameworks).
103 104 105 |
# File 'lib/dommy/scheduler.rb', line 103 def raf_checkpoint_each @raf_checkpoint_each end |
#timer_error_handler ⇒ Object
An optional hook (set by a JS runtime) invoked when a timer / rAF / idle callback raises. The host inspects the error and returns truthy to swallow it — e.g. a runtime execution-budget interrupt: a runaway callback the engine force-killed should be recorded and let browsing continue, not crash it (WHATWG: a timer callback's exception must not escape its dispatch). When swallowed, the offending timer is dropped so a self-rescheduling interval does not re-fire and re-stall every tick. Returning falsy (or no handler at all — vanilla CRuby use) re-raises, so genuine host bugs still surface.
87 88 89 |
# File 'lib/dommy/scheduler.rb', line 87 def timer_error_handler @timer_error_handler end |
Instance Method Details
#advance_time(delta_ms) ⇒ Object
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/dommy/scheduler.rb', line 174 def advance_time(delta_ms) deliver_external # apply any responses that arrived since the last drain target = @now_ms + [delta_ms.to_i, 0].max while next_due_timer_at && next_due_timer_at <= target @now_ms = next_due_timer_at run_due_timers perform_microtask_checkpoint deliver_external end @now_ms = target perform_microtask_checkpoint deliver_external nil end |
#cancel_animation_frame(id) ⇒ Object
131 132 133 |
# File 'lib/dommy/scheduler.rb', line 131 def cancel_animation_frame(id) cancel_timer(id) end |
#cancel_idle_callback(id) ⇒ Object
141 142 143 |
# File 'lib/dommy/scheduler.rb', line 141 def cancel_idle_callback(id) cancel_timer(id) end |
#clear_interval(id) ⇒ Object
118 119 120 |
# File 'lib/dommy/scheduler.rb', line 118 def clear_interval(id) cancel_timer(id) end |
#clear_timeout(id) ⇒ Object
109 110 111 |
# File 'lib/dommy/scheduler.rb', line 109 def clear_timeout(id) cancel_timer(id) end |
#deliver_external ⇒ Object
Run all externally-posted completions. PAGE THREAD ONLY — drained as part of advancing the event loop (see #advance_time).
58 59 60 61 62 63 64 65 66 |
# File 'lib/dommy/scheduler.rb', line 58 def deliver_external until @external_inbox.empty? thunk = @external_inbox.pop(true) CallableInvoker.invoke(thunk) end nil rescue ThreadError nil # raced empty between empty? and pop — nothing left to do end |
#drain_microtasks ⇒ Object
154 155 156 157 158 159 160 161 |
# File 'lib/dommy/scheduler.rb', line 154 def drain_microtasks until @microtasks.empty? callback = @microtasks.shift CallableInvoker.invoke(callback, @now_ms) end nil end |
#drain_timers(advance: 0) ⇒ Object
190 191 192 |
# File 'lib/dommy/scheduler.rb', line 190 def drain_timers(advance: 0) advance_time(advance) end |
#external_pending? ⇒ Boolean
True when a worker has handed back work not yet delivered. Lets the host keep the loop alive (ticking) until in-flight network responses are applied.
70 |
# File 'lib/dommy/scheduler.rb', line 70 def external_pending? = !@external_inbox.empty? |
#next_animation_frame_at ⇒ Object
The earliest due time among pending requestAnimationFrame callbacks, or
nil when none are scheduled. Lets a host flush RAF (advancing to the next
frame boundary) during a settle without jumping the clock past
not-yet-due setTimeouts.
204 205 206 |
# File 'lib/dommy/scheduler.rb', line 204 def next_animation_frame_at @timers.values.select { |t| t.active && t.kind == :raf }.map(&:due_at).min end |
#next_due_timer_at ⇒ Object
Public accessor for eval-time auto-drain: keep advancing the clock until no timers remain (or a safety budget runs out).
196 197 198 |
# File 'lib/dommy/scheduler.rb', line 196 def next_due_timer_at @timers.values.select(&:active).map(&:due_at).min end |
#perform_microtask_checkpoint ⇒ Object
A WHATWG microtask checkpoint: drain the scheduler's own microtask queue
AND the engine's promise-job queue (via the runtime hook). Host-side
microtasks route onto the engine queue when a runtime is wired, so in that
mode @microtasks is usually empty and the engine drain does the work; in
vanilla CRuby the engine hook is absent and only @microtasks drains.
168 169 170 171 172 |
# File 'lib/dommy/scheduler.rb', line 168 def perform_microtask_checkpoint drain_microtasks @microtask_checkpoint&.call nil end |
#post_external(&thunk) ⇒ Object
Post a completion to be run on the page (JS) thread the next time the event loop drains. THREAD-SAFE: this is the one place another thread may hand work back — e.g. a network worker delivering a response. It only enqueues a thunk; the thunk runs single-threaded with the DOM/JS (see #deliver_external), so workers never touch Dommy/QuickJS state. The thunk takes no arguments.
51 52 53 54 |
# File 'lib/dommy/scheduler.rb', line 51 def post_external(&thunk) @external_inbox << thunk nil end |
#queue_microtask(callback) ⇒ Object
145 146 147 148 149 150 151 152 |
# File 'lib/dommy/scheduler.rb', line 145 def queue_microtask(callback) if @native_microtask_scheduler @native_microtask_scheduler.call(callback) else @microtasks << callback end nil end |
#request_animation_frame(callback) ⇒ Object
122 123 124 125 126 127 128 129 |
# File 'lib/dommy/scheduler.rb', line 122 def request_animation_frame(callback) frames = ((@now_ms / FRAME_MS) + 1) * FRAME_MS id = next_id # rAF is frame-aligned (never the same instant twice), so it needs no # nesting clamp; nesting 0. @timers[id] = Timer.new(id, :raf, callback, frames, nil, true, 0) id end |
#request_idle_callback(callback, timeout = 0) ⇒ Object
WHATWG requestIdleCallback — modeled as a deferred timer that hands the callback an IdleDeadline-shaped Hash. No real idle period in dommy.
137 138 139 |
# File 'lib/dommy/scheduler.rb', line 137 def request_idle_callback(callback, timeout = 0) register_timer(:idle, callback, timeout.to_i, nil) end |
#set_interval(callback, interval_ms) ⇒ Object
113 114 115 116 |
# File 'lib/dommy/scheduler.rb', line 113 def set_interval(callback, interval_ms) ms = [interval_ms.to_i, 0].max register_timer(:interval, callback, ms, ms) end |
#set_timeout(callback, delay_ms) ⇒ Object
105 106 107 |
# File 'lib/dommy/scheduler.rb', line 105 def set_timeout(callback, delay_ms) register_timer(:timeout, callback, delay_ms.to_i, nil) end |