Boost what matters

Feature Requests

Submit feature requests and boost what you want to see next.

Feature Requests
#4697
Requested

V8 procedures: async/await support for blocking host calls

## Summary V8 procedure syscalls like `fetch()` block the V8 thread for their full duration, forcing the runtime to spawn additional instances for concurrent requests — each with its own OS thread and V8 isolate. This proposal adds async/await support for procedures so they can yield at host calls, multiplexing multiple in-flight procedures on a single V8 worker. Synchronous procedures continue to work unchanged; module authors opt in to async at their own pace. ## Motivation Blocking procedure syscalls (`fetch()`) in the V8 runtime park the V8 thread for their full duration — up to 30s per request. When another operation arrives while an instance is blocked, `ModuleInstanceManager` creates a new V8 instance — spawning an OS thread, allocating a V8 isolate, and recompiling the module. The pool never shrinks, so instances accumulate at peak load. For example, an LLM chat app where each message triggers a procedure calling an LLM API (30-60s response time). With 10 concurrent users, that's 10 blocked instances each holding an OS thread and V8 heap, plus additional instances for interleaved work. This grows with every concurrent long-running request and never reclaims. PR #4663 addresses this for reducers, views, and lifecycle callbacks by moving them to a single-worker FIFO lane (`JsInstanceLane`). Procedures are deliberately left on the old pool because they block on `rt.block_on()`. V8 has native async/await and there's exactly one guest language to support, so async procedures are a natural fit. A file-by-file analysis of what the implementation would involve accompanies this issue (see comments below), along with a verification checklist, to help expedite this work. ## How the WASM runtime already solves this The WASM runtime doesn't have this problem. Both runtimes call the same `instance_env.http_request()`, but the paths diverge at the syscall layer: ```rust // WASM path (wasmtime/wasm_instance_env.rs) — yields via async host function let result = async { env.instance_env.http_request(request, body)?.await }.await; // V8 path (v8/syscall/common.rs) — blocks the thread let (response, body) = rt.block_on(env.instance_env.http_request(request, body)?)?; ``` The WASM runtime uses `SingleCoreExecutor` backed by a `tokio::task::LocalSet` — a single-threaded async executor where multiple tasks are multiplexed cooperatively. When a procedure yields at an async host function, the executor polls other tasks. Wasmtime bridges synchronous WASM guest code to async host functions via stack switching. V8 doesn't have stack switching, but it doesn't need it — native async/await serves the same purpose. #4663 brings the reducer path closer to this model (single worker, FIFO queue) but doesn't add the async yielding that would let procedures share the worker. ## What it looks like to module authors ```typescript // Synchronous procedure — works as today, blocks V8 thread (ctx) => { const resp = ctx.http.fetch(url); return resp.text(); } // Async procedure — V8 thread is free during await async (ctx) => { const resp = await ctx.http.fetch(url); return resp.text(); } ``` Both forms coexist. Synchronous procedures use the existing `rt.block_on()` path unchanged. Async procedures yield at `await` points. Module authors adopt `async` at their own pace — no migration required. Reducers remain synchronous and never yield, same as the WASM runtime. The runtime detects async functions at registration time via `fn.constructor.name === 'AsyncFunction'` and automatically selects the async execution path, providing `AsyncProcedureCtx` (where `fetch()` returns `Promise<Response>` instead of `SyncResponse`). No explicit flag is needed: ```typescript export const myProc = spacetimedb.procedure( { url: t.string() }, t.string(), async (ctx, { url }) => { const resp = await ctx.http.fetch(url); return resp.text(); } ); ``` ## `withTx` vulnerability (ships independently) The current `withTx<T>(body: (ctx) => T): T` signature silently accepts async callbacks — TypeScript infers `T = Promise<X>`, the call type-checks, and the transaction commits before the awaited body runs. This is a latent data corruption path that exists today. Fix: a conditional type `T extends Promise<any> ? never : T` rejects async callbacks at compile time, plus a runtime thenable check as defense-in-depth. This can ship independently of async procedures. ## Key implementation areas The changes needed have been traced through the codebase. The full file-by-file analysis and verification checklist are in the comments below. Here's a summary of the key areas: **Event loop.** The synchronous `for request in request_rx.iter()` loop in `spawn_instance_worker()` becomes an async event loop with `tokio::task::LocalSet`, `FuturesUnordered` for in-flight async futures, and `v8::MicrotasksPolicy::kExplicit` to control when Promise resolutions propagate. The scheduling priority (request channel biased, with non-blocking `try_next` t

1TeV
from 1 booster