Event Loop And Async Boundary
Otter's public runtime is handle-first and async-friendly, but one isolate
still owns one VM, one runtime state, and one GC heap. The public handle
may be Send + Sync; the isolate internals are not.
The default product path is async-first. CLI execution starts in an async
main and awaits the public Otter/RuntimeHandle stack directly.
Blocking wrappers exist only as sync-caller conveniences. Blocking does
not mean a separate synchronous runtime: the same event-loop-capable
isolate runner must remain available for timers, host ops, dynamic
modules, workers, and future async Web APIs.
The production event-loop boundary follows Deno's JsRuntime shape: the
runtime itself stays local to one isolate, while embedders drive it with
one-tick and run-to-idle style APIs. Boa's job model is the smaller
ECMA-262 reference: promise, timeout, native async, and generic jobs run
only when no execution context is active and each job runs to completion.
Runtime Layers
The intended shape is:
Otter // public facade; Clone + Send + Sync
-> RuntimeHandle // bounded command/completion API
-> IsolateRunner
-> RuntimeCore / Interpreter / RuntimeState / GcHeap // !Send + !Sync
Tokio is the default scheduler in otter-runtime, but VM crates must not
import Tokio types.
Queues
Do not overload one queue for all async work:
- VM microtask queue: Promise reactions,
queueMicrotask, async-function resume, andawaitresume. - Runtime inbox: commands, host-op completions, timers, dynamic module completion, interrupts, inspector/debug events, and shutdown.
A runtime turn runs JS work on the mutator, performs a microtask checkpoint, then folds host completions into the runtime inbox according to the selected drive mode.
Microtask checkpointing is VM work. Promise reactions, queueMicrotask,
and async-function resumes run only after the current JS execution context
unwinds and before the runtime turn is considered complete.
Tokio-specific state belongs in the runtime's internal event-loop and host-service layer. Runtime handles carry owned command payloads and settlement messages; timer callbacks re-enter the isolate by opaque timer token. Handles do not hold VM values, GC handles, or executor locks.
Drive Modes
The runner should support deterministic drive modes:
poll_one_tick: process at most one event-loop turn and checkpoint;run_until_idle: run referenced work until the runtime is idle;run_until_promise: drive until a target promise settles or the loop becomes idle with that promise still pending;run_until_command: drive until a command completion is delivered;shutdown: cancel or drain, then report leaks.
Async Host Ops
Native async APIs must split at the runtime boundary:
- validate arguments and permissions on the isolate thread;
- copy owned host data;
- create a pending promise / operation id;
- run Rust async work on the event loop without VM references;
- post an owned completion back to the isolate;
- resolve or reject the promise on a later mutator turn;
- run the microtask checkpoint.
Never move RuntimeCx, NativeCtx, Value, Frame, Gc<T>,
Local<'gc, T>, or handle scopes into a Rust future.
Host operations should be exposed through narrow runtime-owned services or typed inbox messages. The isolate runner receives only owned completion data on a later turn, then performs the JS-side resolution/checkpoint work on the mutator thread.
Cancellation and backpressure are runtime-handle concerns. Dropping or aborting host work must not leave a JS promise in an untracked state: record the operation id, decrement liveness counters, and settle or report the pending JS work on the isolate turn that observes cancellation.
Liveness And Diagnostics
Timers and host ops have ref/unref liveness. Referenced work keeps
run_until_idle alive; unreferenced work may finish if the loop is already
being driven but must not keep the runtime alive by itself.
Use ref/unref deliberately:
Reffor work that the user can observe and that should keeprun_until_idlealive;Unreffor background diagnostics or cache cleanup that may complete opportunistically but must not prevent idle shutdown.
Contributor tests should be able to inspect activity stats: pending commands, timers, host ops, dynamic module jobs, microtasks, cancellations, timeouts, and leaked work at shutdown.
RuntimeHandle::activity_stats() exposes cheap aggregate counters for this
purpose. Detailed tracing should stay opt-in so native dispatch and script
startup keep their steady-state cost.