Class: Dommy::Scheduler

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initializeScheduler

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_checkpointObject

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_schedulerObject

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_msObject (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_eachObject

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_handlerObject

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_externalObject

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_microtasksObject



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.

Returns:

  • (Boolean)


70
# File 'lib/dommy/scheduler.rb', line 70

def external_pending? = !@external_inbox.empty?

#next_animation_frame_atObject

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_atObject

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_checkpointObject

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