fn0-ski 0.1.6

Minimal Winter CG Compatible Runtime
docs.rs failed to build fn0-ski-0.1.6
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

fn0-ski

Minimal WinterCG-compatible JS runtime embedded in fn0. Built on deno_core (V8). Bundle input is assumed to be fully resolved JS (no require / import / module loader at runtime — use rolldown to bundle).

Concurrency model

ski's runtime model is N isolates per thread, M threads:

  • Threads (M). fn0-worker spawns one OS thread per CPU core. Each thread owns a current_thread tokio runtime with its own LocalSet.
  • Isolates (N per thread). A single thread may host multiple SkiInstances simultaneously. Different projects hash-bucket onto a thread, and bundle hot-swap briefly keeps the old SkiInstance alive next to the new one on the same thread.
  • Isolate ↔ thread affinity is permanent. A SkiInstance is !Send: once created on a thread it lives and dies there. The type system enforces this — there is no API that moves an isolate across threads. An isolate never yields to a different thread, only to other isolates on its own thread.

Cooperative yield between isolates on the same thread

JS executing inside isolate A may await (e.g. via an async op), suspending its driver task. The thread's LocalSet then polls another task — possibly isolate B's driver — and that isolate makes progress. When A becomes ready the LocalSet resumes A's task.

This is safe because every entry into V8 is wrapped in an explicit v8::Isolate::enter() / exit() pair around the synchronous V8 work. At every .await point the thread's V8 enter stack is empty (or topped by an unrelated isolate), so whichever task wakes next is free to enter its own isolate cleanly.

Invariants

These invariants make N-isolates-per-thread sound. Breaking any of them risks V8 fatal aborts (e.g. Cannot create a handle without a HandleScope, or OwnedIsolate instances must be dropped in the reverse order of creation).

  1. No V8 work crosses an .await. Every block that touches a V8 handle, scope, or runtime must live inside a single synchronous enter() → ... → exit() window. Futures returned from V8 calls register their completions through op state, not by holding V8 handles across yield points.

  2. All sync V8 work is wrapped in enter()/exit(). SkiInstance::load calls isolate.exit() once initialization finishes, so a freshly loaded isolate does not claim the thread's V8 default. SkiInstance::call and SkiInstance::drive each enter() before any V8 access and exit() after, even on early return / error paths.

  3. Drop order is free. Drop for SkiInstance re-enters the isolate so that rusty_v8's automatic OwnedIsolate::Drop (which exits + asserts GetCurrent() == self) sees a balanced stack. Instances may therefore be dropped in any order on the same thread.

  4. The watchdog only signals; it never enters. The watchdog thread holds an IsolateHandle and may call terminate_execution from outside the owning thread. That is the only cross-thread V8 surface ski uses, and V8 itself guarantees it is safe. No other code path enters or otherwise touches an isolate from a non-owning thread.

Non-goals

  • Not a Node runtime: no require, no module, no dynamic import, no filesystem-style module resolution.
  • Not "more isolates per core, fewer threads": scaling is more threads, not packing more isolates onto one thread to amortize work. The N isolates per thread exist because hot-swap and hash-bucket bundling require it, not as a performance lever.
  • Not thread-safe per isolate: only the owning thread may call call or drive on a SkiInstance. Cross-thread access is rejected at compile time via !Send.