Scheduler
The scheduler runs actor turns. It has two build-time shapes over one code path
(source/scheduler.c), selected by the target through PIT_SINGLE_THREAD
(source/sys_thread.h): the threaded desktop model and the
single-thread drain for constrained targets. Callers test
PIT_SINGLE_THREAD, never __EMSCRIPTEN__/TARGET_PLAYDATE, for threading
decisions — the macro is derived once in sys_thread.h (from the target macro
or an explicit -D) and is the single source of truth.
Threaded Mode
The default desktop build starts two kinds of thread:
- a worker pool of
sys_cpu_count()runners (actor_runner), - a timer thread (
timer_thread_func) that dispatches the timer heap.
Native endowments own any worker, completion queue, or host callback their OS mechanism requires. The scheduler has no universal I/O backend thread.
Runnable actors sit in a four-band priority queue. state_to_pq maps an
actor’s state to a band, and dequeue_priority scans the bands in index order
(0 first, 3 last), so a lower band is served only when every higher band is
empty:
| Band | State | Meaning |
|---|---|---|
PQ_READY (0) | ACTOR_READY | ordinary runnable actor — the common case, served first |
PQ_REFRESHED (1) | ACTOR_REFRESHED | defined band, no current transition assigns it |
PQ_EXHAUSTED (2) | ACTOR_EXHAUSTED | defined band, no current transition assigns it |
PQ_SLOW (3) | ACTOR_SLOW | a turn resumed after over-running its budget — served last |
Today only two states drive the queue: ACTOR_READY (the default runnable
band) and ACTOR_SLOW (a suspended turn re-queued for another resume attempt;
enough strikes halts it). ACTOR_REFRESHED and ACTOR_EXHAUSTED are reserved
middle bands the priority machinery supports but no state transition currently
sets. Actors pinned to the main thread (display/UI affinity) have a parallel
queue set (main_q_*) drained by the main-thread backend. Each queue node holds
a +1 actor ref; the worker that pops the last node of a halted actor runs
actor_free.
A turn runs under the actor’s mutex (actor_turn), so multiple queue nodes for
one actor serialize. enqueue_actor_priority makes an actor runnable and wakes
a worker.
Named Host-Callback Lanes
An endowment may constrain its actor to a named, externally driven execution
lane rather than normal or main. The target registers that lane after the
scheduler is initialized:
pit_execution_lane_t *frame_lane =
pit_execution_lane_register("playdate.frame");
The opaque handle is the provider’s drive capability. Its host callback calls
pit_execution_lane_drive(frame_lane, max_turns); a positive max_turns
bounds the work admitted by one callback, while zero drains the lane to
quiescence. The first drive binds the lane to its callback thread; later drives
from another thread are refused, and a lane permits only one active driver.
Actors pinned to it have a separate four-band queue and are never placed on
the worker or main queues.
Name lookup (pit_execution_lane_available) is used only by the shop’s spawn
validation and does not drain work.
Named-lane actor startup is deferred onto the named queue. This matters because top-level imports may activate the very host capability imposing the constraint: no Pit code for that actor runs inline on the shop worker which constructed its shell. Mailbox, timer, signal, park, and call-gate wakeups all requeue the actor on the same lane.
The lane is scheduling policy, not a fifth foreign-callback transport. Providers still use the one-shot or multishot signal, actor call gate, and SPSC ring for data and lifecycle crossings. A hard real-time callback never drives a lane or runs Pit code: it only moves bulk bytes through the preallocated ring and sets atomic state for a non-realtime lane to observe.
A host callback on the bound lane thread but outside a Pit turn must not call
the ordinary blocking gate entry: its actor can run only on the thread which
would then be waiting. pit_call_gate_call_on_lane is the explicit composition.
It queues the serialized call and drives that opaque lane synchronously until
the result is immediate. A positive turn bound returns PIT_CALL_TIMED_OUT if
older lane work consumes the budget; zero is unbounded. Suspension is always a
contract violation for this entry and returns PIT_CALL_CALLBACK_SUSPENDED
while disrupting the actor. Hard-real-time audio callbacks still use the SPSC
ring rather than running arbitrary actor turns.
Call-gate results use plain Wota serialization: no toJSON hook or other Pit
code runs after the callback returns, function properties are omitted, cycles
return PIT_CALL_NOT_SERIALIZABLE, and allocator failure remains
PIT_CALL_OUT_OF_MEMORY. A queued callback is an actor turn, so an uncaught
exception reports PIT_CALL_CALLBACK_FAILED and follows normal actor halt
semantics. A direct/reentrant provider callback reports the same status to its
C caller without creating a second turn; its surrounding provider call decides
how to surface that failure.
Registration must happen from target startup after actor_initialize has
initialized the scheduler and before the shop starts any executable claiming
that lane. A Playdate audio task is a genuinely foreign callback even though
the actor worker pool is disabled; its target threading provider must therefore
supply real cross-task synchronization before that callback and the frame
update callback can drive scheduler state concurrently.
Single-Thread Mode
Under PIT_SINGLE_THREAD the whole runtime lives on one thread: actor turns,
timers, and io completions all land there. A single cooperative drain
(pit_drain) runs every actor turn, due
timer, and inline io completion, pumped from the host loop — the browser’s
requestAnimationFrame on web, the update callback on Playdate. Because there
is no concurrent timer thread, pit_drain dispatches the timer heap inline:
it pops every timer whose deadline has passed so bursts stay on one frame.
pit_run_until_idle(budget_ms) is the batch entry point (three consumers: the
web suite harness, the Playdate update callback, headless CI). It loops
pit_drain until quiescent or the wall-clock budget is spent and returns a
pit_run_status:
| Status | Meaning |
|---|---|
PIT_RUN_IDLE | nothing pending — fully quiescent |
PIT_RUN_PENDING | only a future timer or in-flight io remains; yield to the host loop and re-enter |
PIT_RUN_BUDGET | budget spent with work still runnable |
The budget is checked between drains, so a single long turn overshoots by at
most one turn — mid-turn preemption is not implemented. budget_ms <= 0 is
uncapped.
Turn Suspension
Long turns are governed by per-actor pause flags and the timer thread. When an
actor’s turn exceeds its fast budget the timer thread fires a TIMER_PAUSE
that sets pause flag 1; a turn exceeding the slow budget repeatedly accrues
slow strikes and is ultimately halted.
A turn that never comes back accrues no strikes, because the strike is
counted on the resume path — the VM has to park for anyone to notice — and a
turn cannot park while vm_call_depth is nonzero, since a park saves a frame
and a pc and cannot unwind the host frames a nested VM entry sits under. Two
things follow, and both are load-bearing:
- The kill timer escalates to pause flag 2 when it finds a turn whose generation has not advanced and whose pause flag is still raised: it was asked to yield a whole slow budget ago and did not answer. Flag 2 raises a disruption instead of parking, and a disruption is honoured at every poll site at any call depth, so it reaches loops a pause cannot. A deliberately frozen actor is excluded by name — freezing is not overrunning, and without that test a debugger session outliving the slow budget would shoot its own debuggee.
- A start plan is a turn’s worth of work that is not a turn: it runs on the
starter’s thread, on the starting actor’s context, several nested VM
entries deep. No turn timer covers it, and the starter’s own pause flag —
the only one
TIMER_PAUSE/TIMER_KILLcan reach — is not the flag that code reads. So it carries its ownTIMER_START_KILLfor the same wall clock a turn gets (slow budget times strike ceiling), raising flag 2 directly. Never flag 1: a start plan can run atvm_call_depth0 during boot, where a park would land in a caller that has no way to resume it.
Suspension is a threaded-mode
mechanism: in single-thread mode a turn runs to completion on the one thread,
so a wall-clock mid-turn kill is impossible — pit_drain only pops a kill
timer after the turn has already ended, and TIMER_PAUSE is dropped there
with a comment. Reduction-budget preemption at the VM poll sites is the real
fix and is deferred.
Idle Kill
An actor quiet for ar_secs (default 60s) is unneeded and gets reaped — the
memory-reclamation story for constrained targets. When a mailbox goes quiet the
scheduler arms a TIMER_NATIVE_REMOVE; the timer thread executes it in
threaded mode, and pit_drain executes it inline in single-thread mode (so
idle actors are collected on web/playdate too).
Waiting is not liveness: a pending delay, park, or signal buys an actor no
time. An actor states its own lifetime through $unneeded(fn, seconds), which
sets its ar timer and a callback that gets one final turn when the timer fires.
A permanent service says so — $unneeded(-1) is never — because quiet is a
service’s normal state rather than a sign that nothing wants it.
ar_secs, the fast budget, and the slow budget are recipe build levers:
they are settings of the runtime binary, rendered into the build as build|
manifest rows, not axes of the profile stamp.
Observing the Scheduler
scheduler_snapshot is how pit ps and $inspect read the actor table, and it
never blocks on a turn mutex it does not own. It tries each actor’s mutex;
in threaded mode it retries briefly, because a busy actor is usually only
between instructions, and past that it reports the racy-but-refcount-safe
scalars — the struct cannot be freed while the walk holds a reference, and
string identities are set-once, so a mid-turn actor reported as running with
zeroed detail is an honest observation. In single-thread mode a failed try goes
straight to that fallback row, since no other thread can release the lock.
An observer that waited for a long turn to end would order itself against every lock that turn takes, which is what makes observation a source of deadlock rather than a read.
GC Roots
Each actor has its own copying heap (see GC). The scheduler owns three
containers that actor_gc_scan walks as roots, and a PitValue is rooted only
while it sits in one of them:
actor->letters— aLETTER_CALLBACK’s callback and a deliveredLETTER_PARK’spark->callback,actor->parks_head— every pending park’scallback(timer callbacks, and module-side parks such as watch),actor->signals_head— every signal’scallback.
A pending park is observed in exactly one of parks_head (PENDING) or
actor->letters as a LETTER_PARK (DELIVERED) at any moment.
Resource Ownership
Native handles exposed to Pit are actor-owned and non-transferable because they live as opaque pointers in records on one actor’s heap. Their class finalizer closes the native handle when the record becomes unreachable or the actor heap is destroyed. Files, sockets, processes, subscriptions, and similar objects all use this one ownership model. Signals and call gates hold their own actor lifetime references while native work is pending.
Halt Teardown
actor_halt claims the halt with a CAS (only the first caller does the work),
sets pause flag 2, and removes the actor from the registry so no new message
can find it. It then tears resources down in a fixed order so a later
destructor can rely on an earlier one having run:
- letters — drain the mailbox (blob letters destroyed, delivered park letters released).
- signals — detach and cancel each
pit_signal, then run its release hook. - timers — purge the heap so the timer thread can queue nothing more.
- ordinary parks — detach/cancel any controlled-source
pit_parknot already consumed through the timer registry, then run its release hook outsidemsg_mutexso the hook may stop/join its producer.
Finally the actor is enqueued once so a worker reaps it; the thread whose ref
drop takes the count to zero on a halted actor owns actor_free. The teardown
order is letters → signals → timers → ordinary parks, and every
step is idempotent.