Runtime Scheduler
runtime-scheduler is Space's typed runtime scheduling policy layer. It lives in Fennel above Temporal.duration, Temporal.clock, and Temporal.recurrence-set; it is not part of the native temporal core and does not infer host-local timezones, fetch network data, create OS threads, or run background/cross-process jobs.
Public API
Require it from Fennel with (require :runtime-scheduler):
(local RuntimeScheduler (require :runtime-scheduler))
(local scheduler (RuntimeScheduler.create {:clock (Temporal.clock.fixed start)}))RuntimeScheduler.create(opts) accepts only {:clock clock?}. The clock defaults to Temporal.clock.system() and seeds the scheduler's internal epoch with clock:now().
Scheduler methods are:
scheduler:schedule-once {:delay duration :callback fn} -> handlescheduler:schedule-every {:interval duration :callback fn} -> handlescheduler:schedule-recurrence {:recurrence-set recurrence-set :zone-id string :callback fn :disambiguation keyword? :limit number?} -> handlescheduler:advance(duration) -> truescheduler:update(delta-ms) -> truescheduler:clear() -> truescheduler:drop() -> truescheduler:list() -> sequential job summaries
Handle methods are:
handle:cancel() -> truehandle:drop() -> truehandle:active?() -> boolean
The typed scheduler accepts only canonical keys. Legacy :delay-ms and :interval-ms keys are rejected here and remain valid only at the runtime-timers compatibility wrapper boundary.
Time model
Deadlines are stored as exact integer nanoseconds relative to the scheduler's internal epoch. advance(duration) requires a non-negative exact Temporal.duration and drains all due work before returning. Tests and replay can create a deterministic scheduler with Temporal.clock.fixed(instant) and then move time explicitly with advance.
update(delta-ms) is the host/engine compatibility entry point. It accepts a finite non-negative millisecond delta, converts it to exact nanoseconds, and delegates to advance. Malformed deltas fail loudly.
Catch-up and lifecycle policy
- One-shot jobs fire once when their deadline is due.
- Interval jobs fire once for each missed interval during a single
advance,update, or hoststep. - Due jobs run by earliest deadline, then creation order for equal deadlines.
- Callback errors propagate out of
advance,update, and hoststep; they are not swallowed. - Cancelling or dropping a handle is idempotent and prevents future callbacks.
- Cancelling an interval from inside its callback stops any later catch-up callbacks for that handle in the same drain.
clear/dropdeactivate all pending work. Callingclearfrom a callback prevents sibling jobs at the same deadline from firing later in that drain.- Host pause does not accumulate hidden elapsed time: paused host
updatecalls do not advance scheduled work. An explicit hoststep(delta-ms)does advance scheduled work while paused.
Recurrence scheduling
schedule-recurrence uses Temporal.recurrence-set.occurrences and requires an explicit :zone-id; there is no host-local timezone fallback. If :disambiguation is omitted, it defaults to :reject. Use :limit for finite expansion when the recurrence layer requires one.
Each occurrence schedules internal one-shot work. The callback receives exactly one public payload table:
{:scheduled-at instant :occurrence-index n}:scheduled-at is the occurrence instant and :occurrence-index is one-based within that scheduled recurrence handle.
Host scheduler integration
app-host.services.make-scheduler owns an internal runtime scheduler and exposes typed delegation methods:
host.scheduler:schedule-once(opts)host.scheduler:schedule-every(opts)host.scheduler:schedule-recurrence(opts)
The existing facet registry remains separate: register, unregister, and list continue to operate on update facets, not internal timer jobs. Normal update(delta-ms) advances typed scheduled work before facets when not paused. set-paused true prevents normal updates from advancing both typed scheduled work and facets. step(delta-ms) advances typed scheduled work and facets even while paused.
RuntimeTimers compatibility
runtime-timers remains the compatibility API for legacy callers:
RuntimeTimers.Timeout {:delay-ms number :callback fn}RuntimeTimers.Interval {:interval-ms positive-number :callback fn}RuntimeTimers.Debouncer {:delay-ms number :callback fn}RuntimeTimers.clear()
Those wrappers convert millisecond options to typed durations at their boundary and delegate to a private RuntimeScheduler. Do not add millisecond aliases to the typed scheduler API.
Validation
Use the project runtime and native Fennel validation tools:
make fennel-check
make constraints
SPACE_DISABLE_AUDIO=1 SKIP_KEYRING_TESTS=1 XDG_DATA_HOME=/tmp/space/tests/xdg-data \
SPACE_ASSETS_PATH=$(pwd)/assets \
FENNEL_PATH="$(pwd)/assets/lua/?.fnl;$(pwd)/assets/lua/?/init.fnl" \
FENNEL_MACRO_PATH="$(pwd)/assets/lua/?.fnl;$(pwd)/assets/lua/?/init.fnl" \
./build/space -m tests.test-runtime-scheduler:main