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:

BandStateMeaning
PQ_READY (0)ACTOR_READYordinary runnable actor — the common case, served first
PQ_REFRESHED (1)ACTOR_REFRESHEDdefined band, no current transition assigns it
PQ_EXHAUSTED (2)ACTOR_EXHAUSTEDdefined band, no current transition assigns it
PQ_SLOW (3)ACTOR_SLOWa 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:

StatusMeaning
PIT_RUN_IDLEnothing pending — fully quiescent
PIT_RUN_PENDINGonly a future timer or in-flight io remains; yield to the host loop and re-enter
PIT_RUN_BUDGETbudget 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_KILL can reach — is not the flag that code reads. So it carries its own TIMER_START_KILL for the same wall clock a turn gets (slow budget times strike ceiling), raising flag 2 directly. Never flag 1: a start plan can run at vm_call_depth 0 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 — a LETTER_CALLBACK’s callback and a delivered LETTER_PARK’s park->callback,
  • actor->parks_head — every pending park’s callback (timer callbacks, and module-side parks such as watch),
  • actor->signals_head — every signal’s callback.

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:

  1. letters — drain the mailbox (blob letters destroyed, delivered park letters released).
  2. signals — detach and cancel each pit_signal, then run its release hook.
  3. timers — purge the heap so the timer thread can queue nothing more.
  4. ordinary parks — detach/cancel any controlled-source pit_park not already consumed through the timer registry, then run its release hook outside msg_mutex so 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.