diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0519f5..9b41299 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,11 @@ jobs: strategy: matrix: node-version: [24] + async-engine: [yield, jspi] + + env: + WASMOON_ASYNC: ${{ matrix.async-engine }} + NODE_OPTIONS: ${{ matrix.async-engine == 'jspi' && '--experimental-wasm-jspi' || '' }} steps: - uses: actions/checkout@v7 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 600545a..1095bb1 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,5 +7,5 @@ "printWidth": 140, "tabWidth": 4, "sortPackageJson": false, - "ignorePatterns": ["rolldown.config.*.js"] + "ignorePatterns": ["rolldown.config.*.js", "docs/**"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index c9c4b84..0a9cd20 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,7 +11,7 @@ "consistent-function-scoping": "off", "promise/always-return": "off" }, - "ignorePatterns": ["dist/**", "build/**", "rolldown.config.ts", "rolldown.config.*.js", "utils/**"], + "ignorePatterns": ["dist/**", "build/**", "docs/**", "rolldown.config.ts", "rolldown.config.*.js", "utils/**"], "env": { "builtin": true } diff --git a/README.md b/README.md index cee9827..aa9f537 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ build. ### Promises -Promises can be await'd from Lua with some caveats detailed in the below section. To await a Promise call `:await()` on it which will yield the Lua execution until the promise completes. +Promises can be `await`'d from Lua by calling `:await()` on them, which parks the Lua execution until the promise settles and then returns its value (or raises its rejection as a Lua error). ```js import { LuaRuntime } from 'wasmoon' @@ -246,60 +246,66 @@ try { } ``` -### Async/Await +### Async engine -It's not possible to await in a callback from JS into Lua. This is a limitation of Lua but there are some workarounds. It can also be encountered when yielding at the top-level of a file. An example where you might encounter this is a snippet like this: +There are two engines: + +- **Coroutine yielding** is the fallback when JSPI is unavailable. An `:await()` parks by yielding + the running coroutine, so it works at the top level of a run and anywhere Lua can yield. +- **JSPI** is selected automatically where available. It suspends the WebAssembly stack, allowing awaits across C-call + boundaries such as `table.sort` comparators and `string.gsub` callbacks, and inside + Lua-resumed coroutines. It requires a platform with JSPI support. ```js -local res = sleep(1):next(function () - sleep(10):await() - return 15 -end) -print("res", res:await()) +const lua = await LuaRuntime.load() // JSPI when available, yielding otherwise +const accelerated = await LuaRuntime.load({ async: 'jspi' }) // requires JSPI +const portable = await LuaRuntime.load({ async: 'yield' }) // coroutine yielding on every platform ``` -Which will throw an error like this: +Both engines use the same run isolation, cancellation, and host-yield contract. Each run owns +its limits and interrupt state, even when several runs or states share a runtime. Repeated awaits +and host yields give the event loop a turn every 256 async steps, so timers can run without paying +for a macrotask on every await. -``` -Error: Lua Error(ErrorRun/2): cannot resume dead coroutine - at Thread.assertOk (/home/tstableford/projects/wasmoon/dist/index.js:409:23) - at Thread. (/home/tstableford/projects/wasmoon/dist/index.js:142:22) - at Generator.throw () - at rejected (/home/tstableford/projects/wasmoon/dist/index.js:26:69) +A run parked on a promise can be interrupted by a `timeout` or an `AbortSignal`, without waiting for +the promise to settle. This also applies while an `onYield` handler is pending; closing the state +rejects its parked runs and callbacks: + +```js +await state.doString('sleep(60000):await()', { timeout: 1000 }) // rejects after ~1s ``` -Or like this: +#### Awaiting in a JS→Lua callback -``` -attempt to yield across a C-call boundary +A Lua function called from JS runs synchronously and returns its value directly. If it `:await()`s, +the call becomes asynchronous and returns a `Promise` instead. These callbacks use coroutine +yielding even when JSPI is enabled, so they cannot await across C-call boundaries: + +```js +state.set('handler', null) +await state.doString('handler = function(x) return sleep(10):await() + x end') +const handler = state.get('handler') +console.log(await handler(5)) // a promise, because the callback awaited ``` -You can workaround this by doing something like below: +#### Handling a top-level `coroutine.yield` -```lua -function async(callback) - return function(...) - local co = coroutine.create(callback) - local safe, result = coroutine.resume(co, ...) - - return Promise.create(function(resolve, reject) - local function step() - if coroutine.status(co) == "dead" then - local send = safe and resolve or reject - return send(result) - end - - safe, result = coroutine.resume(co) - - if safe and result == Promise.resolve(result) then - result:finally(step) - else - step() - end - end - - result:finally(step) - end) - end -end +A top level `coroutine.yield` that is not an `:await()` is a _host yield_. Pass `onYield` to receive +its values and decide what the resume hands back; its return may be a promise, a `LuaMultiReturn` of +several values, or a single value: + +```js +const thread = state.newThread() +thread.loadString('local reply = coroutine.yield("ping") return reply') +const [result] = await thread.run(0, { + onYield: (values) => { + console.log(values[0]) // "ping" + return 'pong' + }, +}) +console.log(result) // "pong" ``` + +A promise passed to `coroutine.yield` is a host value, not an internal await. The handler receives +it unchanged. Without a handler, yielded values are discarded and the coroutine resumes without +arguments. diff --git a/bench/async.js b/bench/async.js new file mode 100644 index 0000000..4cc9ab9 --- /dev/null +++ b/bench/async.js @@ -0,0 +1,73 @@ +// node --expose-gc bench/async.js [path/to/index.js] +// An alternate bundle makes before/after comparisons use exactly the same workloads. +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const { LuaRuntime } = await import(pathToFileURL(resolve(process.argv[2] ?? 'dist/index.js')).href) +const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] + +for (const engine of ['yield', 'jspi']) { + const runtime = await LuaRuntime.load({ async: engine }) + const state = runtime.createState({ inject: true, memory: { trace: true } }) + state.set('ready', Promise.resolve(1)) + state.set('add', (a, b) => a + b) + state.doStringSync('function addLua(a, b) return a + b end') + const addLua = state.get('addLua') + const workloads = { + '20k settled awaits': () => state.doString('local n = 0 for i=1,20000 do n = n + ready:await() end return n'), + '20k JS to Lua calls': () => { + for (let i = 0; i < 20000; i++) assert.equal(addLua(i, 1), i + 1) + }, + '20k Lua to JS calls': () => state.doStringSync('local n = 0 for i=1,20000 do n = add(n, 1) end return n'), + '1k async entries': async () => { + for (let i = 0; i < 1000; i++) assert.equal(await state.doString('return 1'), 1) + }, + 'CPU loop': () => state.doStringSync('local n = 0 for i=1,1000000 do n = n + i end return n'), + } + const timings = {} + for (const [name, run] of Object.entries(workloads)) { + for (let i = 0; i < 3; i++) await run() + const samples = [] + for (let i = 0; i < 7; i++) { + const start = performance.now() + await run() + samples.push(performance.now() - start) + } + timings[name] = Number(median(samples).toFixed(2)) + } + + const heapSamples = [] + const bufferSamples = [] + const luaSamples = [] + for (let sample = 0; sample < 5; sample++) { + let release + state.set( + 'gate', + new Promise((resolve) => { + release = resolve + }), + ) + state.gc.collect() + global.gc?.() + const before = process.memoryUsage() + const luaBefore = state.memory.used + const runs = Array.from({ length: 1000 }, () => state.doString('gate:await() return 1')) + await new Promise((resolve) => setTimeout(resolve, 20)) + global.gc?.() + const during = process.memoryUsage() + heapSamples.push(during.heapUsed - before.heapUsed) + bufferSamples.push(during.arrayBuffers - before.arrayBuffers) + luaSamples.push(state.memory.used - luaBefore) + release() + assert.ok((await Promise.all(runs)).every((value) => value === 1)) + } + console.log( + JSON.stringify({ + engine, + medianMs: timings, + parked1000: { jsHeapBytes: median(heapSamples), arrayBufferBytes: median(bufferSamples), luaBytes: median(luaSamples) }, + }), + ) + state.close() +} diff --git a/docs/async-redesign/README.md b/docs/async-redesign/README.md new file mode 100644 index 0000000..4beda98 --- /dev/null +++ b/docs/async-redesign/README.md @@ -0,0 +1,449 @@ +# Rethinking async in wasmoon + +Design proposal, 2026-09-05. Everything marked *measured* below was run against the current +`dist/` (Node 26.7, V8 14.6) or against a one-off wasm build; the scripts are in `experiments/`. + +## TL;DR + +The current model has one primitive, "yield the Lua coroutine and let a JS loop look at what was +yielded", and everything (awaiting, time slicing, cancellation, callbacks) is squeezed through it. +That is why awaits fail inside `table.sort`, `gsub`, `promise:next(luaFn)` and every JS→Lua +callback, why top-level `coroutine.yield` values are silently dropped, why a parked run cannot be +aborted or timed out, and why a loop of awaits starves the event loop. + +Proposal, in two layers: + +1. **A real scheduler in JS, engine-agnostic.** Every asynchronous entry point becomes a *run*: + one Lua coroutine, one JS promise, one cancellation scope. The scheduler owns parking and + resuming, uses an explicit yield protocol instead of sniffing the last yielded value, resumes + in microtasks with a starvation guard, can cancel a parked run, and lets JS→Lua callbacks + return a promise when the Lua side suspends. This fixes most of the list above with no wasm + change and is required anyway as the fallback engine. +2. **JSPI as the primary engine where available.** With `-sSUPPORT_LONGJMP=wasm` (+47 bytes of + wasm, -746 bytes of glue JS, and *measured* 30-45% faster `pcall`/`error`/yield paths on its + own) the interpreter runs under `WebAssembly.promising`, and an await anywhere, including + across C boundaries and inside coroutines nobody drives, simply suspends the wasm stack. + *Measured*: a suspension costs 0.3-1.6 µs depending on the stack strategy versus 6 µs for + today's coroutine round trip; CPU-bound code is unaffected; 1000 parked runs cost ~4 MB, the + same as today. Two things make it work in this codebase: a C-side trampoline that calls a plain + import first and a `Suspending` import only when suspension is allowed, and saving each run's + slice of the linear-memory C stack across a suspension (without that, interleaved runs corrupt + each other; both *measured*). See §6 for the full numbers. + +JSPI is in Chrome/Edge 137+, Firefox 153+, Node 25+ (Node 24 only behind a flag) and not in +Safari, so the scheduler layer stays the contract and JSPI is a feature-detected accelerator with +identical observable semantics wherever both can express them. + +--- + +## 1. How it works today + +``` +doString(code) Lua JS function `sleep` + │ newAnchoredThread + load │ │ + │ thread.run() ── lua_resume ────────► │ sleep(10):await() │ + │ │ └► promise ext `await`: │ + │ │ pendingAwaits[L] = {…} │ + │ │ push(promise) │ + │ │ lua_yieldk(L, 1, 0, k) ─────┼──► longjmp → JS exception + │ ◄── LUA_YIELD, 1 value ──────────────┘ │ (EmscriptenEH, rethrown) + │ getValue(-1); isPromise? await it │ + │ else: setImmediate / setTimeout(0) │ + │ lua_resume(L, 0) ──────────────────► │ continuation k: │ + │ │ result ready? push, return 1 │ + │ │ not ready? lua_yieldk again │ + │ ◄── LUA_OK ── getStackValues() │ │ +``` + +Relevant code: `Thread.run` (`src/thread.ts:158`), the promise extension +(`src/type-extensions/promise.ts`), `FunctionTypeExtension.getValue` which calls Lua from JS +with a synchronous `lua_pcallk` (`src/type-extensions/function.ts:191`), and the limit hook +(`src/thread.ts` `applyHook`/`checkYieldLimits`). + +Properties of this model: + +- The *only* way to park is `lua_yieldk` on the coroutine `run()` is driving. Anything that is + not that coroutine, or has a C frame between it and the await, cannot park. +- The host decides what a yield *means* by looking at the last yielded value. Await and + "cooperative time slice" share one channel; user yields have no channel at all. +- JS→Lua calls are always synchronous. The callback runs on a pooled thread under `lua_pcallk` + and a yield is an error. +- Awaits inside a coroutine that Lua code resumes (not the host) work by *polling*: the + continuation re-yields with zero values until the promise has settled, and the resumer has to + `coroutine.yield()` to the host between attempts (see the README workaround). +- Limits are checked by the debug hook while Lua runs and by `run()` around yields only. + +## 2. What is wrong (all *measured*, `experiments/current-behavior.mjs`) + +| # | Behaviour | Result today | +|---|---|---| +| 1 | `coroutine.yield(io.stdout)` at top level of `doString` | throws `TypeError: the Lua type 'userdata' with metatable 'FILE*' has no JS representation`. `run()` calls `getValue(-1)` on whatever the script yielded. | +| 2 | `local a, b = coroutine.yield(1, 2)` at top level | `a`, `b` are nil. Values are popped and discarded, resume passes nothing back. | +| 3 | `:await()` inside a `table.sort` comparator or a `gsub` callback | `cannot await in a thread that cannot yield, use doString instead of doStringSync`. Wrong diagnosis: we *are* in `doString`, this is a C-call boundary. | +| 4 | `:await()` inside `promise:next(function() ... end)` | same error. The callback is entered from JS via `lua_pcallk`. | +| 5 | JS calls a Lua function that awaits (`state.get('f')(2)`) | same error. No way to get a promise back. | +| 6 | `while not flag() do Promise.resolve(1):await() end` with a 5 ms timer setting `flag` | 20 000 iterations, timer never fires. Resumes are microtasks with no macrotask boundary. | +| 7 | `sleep(300):await()` with `{ signal }` aborted at 10 ms, or `{ timeout: 10 }` | rejects after **301 ms**. Limits are only observed after the promise settles. | +| 8 | 200 bare `coroutine.yield()` round trips | 6 ms in Node. In browsers `setTimeout(0)` is clamped to 4 ms when nested, so the same script takes ~800 ms. | +| 9 | Cost of one await round trip on an already settled promise | ~6 µs (10k in 59 ms): two `lua_resume` calls, a longjmp through a JS exception, a Map lookup, a promise `then`. | +| 10 | Nested `coroutine.resume(co)` where `co` awaits | returns `true` immediately with the promise as its value; the caller must poll. Also `pendingAwaits` keeps the record until the state closes if `co` is never resumed again (documented leak, `promise.ts:141`). | + +Things that do work and should keep working: awaits inside `pcall`, inside Lua metamethods +called from Lua (`__index` from Lua code is yieldable in 5.4+), inside Lua iterators, several +host-driven runs interleaving on one state, `Promise.all`, rejection → Lua error and back. + +Smaller structural issues worth fixing while there: + +- `pendingInterrupt` lives on the root thread and every `resume()` clears it, so two interleaved + runs can clobber each other's interrupt (safe today only because hook → `lua_error` → + `assertOk` never crosses an await). +- `stateToThread` allocates a fresh `Thread` for every callback made from a coroutine the JS side + has not seen before. +- Every `lua_yieldk` from a C function longjmps, which under `SUPPORT_LONGJMP=emscripten` is a JS + exception plus the `isEmscriptenUnwind` brand hack in `rolldown.config.ts`. + +## 3. Constraints + +- **Size and speed budget.** `glue.wasm` is 192,923 bytes after a long flag sweep; the JS bundle + was cut to ~98 KB. Asyncify would roughly double the wasm and slow the interpreter by tens of + percent, so it is out. +- **Targets.** `browserslist`: Chrome ≥134, Firefox ≥138, Safari ≥26; `engines`: Node ≥24. JSPI + coverage today: Chrome/Edge 137+, Firefox 153+, Node 25+ by default (`--experimental-wasm-jspi` + on Node 24), Safari none. Any design needs a non-JSPI path with the same API. +- **One wasm instance, many states, one linear-memory C stack.** Everything that suspends shares + the 1 MB Emscripten stack. This is the one place JSPI needs help (section 5.4). +- **Lua's own rules stay.** A `lua_State` cannot be entered twice concurrently (*measured*: two + promising `lua_pcallk` on the same thread → `memory access out of bounds`), so every run still + needs its own coroutine, as `callByteCode` already does. + +## 4. Options considered + +**A. Asyncify.** Solves the C-boundary problem by rewriting the wasm. Rejected on size and speed, +and it still allows only one in-flight suspension per instance. + +**B. Keep `lua_yieldk`, fix the scheduler.** No wasm change. Fixes 1, 2, 4, 5, 6, 7, 8, 10 and the +misleading message in 3. Does *not* fix 3 itself (C-call boundary) and cannot make an await inside +a Lua-resumed coroutine transparent. Needed regardless as the fallback. + +**C. JSPI.** Fixes everything in the table including 3, and awaits inside Lua-resumed coroutines +become transparent (the resumer simply waits). *Measured* on a `-sSUPPORT_LONGJMP=wasm` build, +raw glue, Node 26 (`experiments/jspi-raw.mjs`): + +| Measurement | Result | +|---|---| +| await inside `table.sort` comparator, `gsub` callback, `coroutine.wrap` body | all work | +| Lua `error()` after a suspension, `lua_error` from a suspended import | caught by `pcall` correctly | +| `coroutine.yield` alongside JSPI | works | +| Suspending import called while **not** under `promising` | traps `trying to suspend without WebAssembly.promising` **even when it does not return a promise** | +| Suspending import called with a JS `invoke_*` longjmp trampoline on the stack (today's build) | traps `trying to suspend JS frames` | +| 10k `lua_pcallk` round trips, sync vs `promising` | 17.2 ms vs 39.3 ms (+2.2 µs per entry) | +| 100k imports that never suspend, plain vs `Suspending` | 4.1 ms vs 13.1 ms (+0.09 µs per call) | +| 10k suspensions on an already settled promise | 2.6 ms (0.26 µs each; today 6 µs) | +| 200k-element `table.sort`, sync vs `promising` | 100 ms vs 91 ms (noise) | +| wasm size, `SUPPORT_LONGJMP=emscripten` → `wasm` | 192,923 → 192,970 bytes; glue JS 87,012 → 86,266 | +| 100k `pcall(f)` without error, same switch | 12.2 ms → 6.8 ms | +| 100k `pcall(error, "x")`, same switch | 136 ms → 87 ms | +| 100k Lua-only `coroutine.yield`/resume, same switch | 97 ms → 65 ms | +| 100k `lua_yieldk` / `lua_error` from a JS function, same switch | 115 → 83 ms / 145 → 103 ms | +| heapsort.lua, same switch | 13.4 ms → 13.5 ms | + +Two concurrent runs on different states interleaving deep C recursion (`pcall` + `gsub` buffers) +across suspensions **corrupt each other** unless the linear-memory stack is managed: a Lua longjmp +escapes as a raw `WebAssembly.Exception`, then `C stack overflow`, then `memory access out of +bounds` (`experiments/jspi-stack.mjs`, unmanaged). With the management described in 5.4 the same +test passes every round. Pyodide documents the same problem and fix for CPython +(blog.pyodide.org, "Integrating JSPI with the WebAssembly C Runtime"). + +**Recommendation: B as the contract, C as the engine when present.** The visible API is defined +by B; C removes B's remaining limitations where the platform allows and is detected at load. + +## 5. Proposed architecture + +### 5.1 The run + +```ts +/** One asynchronous entry into Lua. Owned by the scheduler, never exposed as is. */ +interface Run { + thread: Thread // its own coroutine (lua_newthread), anchored in the registry + limits: LuaThreadLimits // deadline, budget, signal; replaces the root pendingInterrupt slot + settle: Deferred + parked?: Parked // what it is waiting on, if anything + onYield?: (values: MultiReturn) => unknown | Promise +} +``` + +`doString`, `doFile`, `Thread.run` and (new) asynchronous Lua function calls all create a run. +Sync entry points (`doStringSync`, `call`, `runSync`) do not; they increment a module-wide +`syncDepth` counter for the duration of the call (see 5.3). + +### 5.2 One suspender interface, two engines + +```ts +interface Suspender { + /** Called by the promise extension from inside a Lua→JS call. Must not return normally + * unless it has a value to hand back. */ + park(thread: Thread, promise: PromiseLike): number // returns a Lua result count + /** Drives a run to completion. */ + drive(run: Run, argCount: number): Promise +} +``` + +**YieldSuspender (fallback, today's mechanism cleaned up).** + +- `park`: refuses unless `lua_isyieldable`; records `{ promise }` on the run (or, for a coroutine + Lua code is resuming, in an ephemeron-keyed registry table, see 5.6); yields **two** values: + the module's await token (a lightuserdata the module owns, like `interruptToken`) and the + promise. Continuation `k` is one shared function pointer as now. +- `drive`: `lua_resume` loop. On `LUA_YIELD`: if slot `-2` is the await token (pointer compare, no + `getValue` on user values) → await the promise, resume; otherwise it is a **host yield** → + hand `getStackValues` to `run.onYield`, resume with whatever it returns (awaited if a promise). + Default `onYield` is "give the event loop a turn and resume with nothing", which is today's + behaviour minus the discarded values bug. +- Resume happens in the promise's own microtask. A per-run counter forces a macrotask boundary + after N consecutive microtask resumes (or after T ms of continuous running), which fixes #6 + without paying a macrotask per await. +- Macrotask = `scheduler.yield()` if present, else `MessageChannel` (no 4 ms clamp), else + `setImmediate`/`setTimeout`. Fixes #8. + +**JspiSuspender.** + +- Enabled when `typeof WebAssembly.Suspending === 'function'` *and* the glue was built with + `SUPPORT_LONGJMP=wasm`. `drive` calls `promising(lua_resume)`; host yields work exactly as + above, so the run loop is shared. +- `park` never yields; it returns a marker that tells the C trampoline (5.3) to call the + `Suspending` import, which returns the promise and lets the VM switch stacks. +- Nothing is recorded anywhere: the pending state *is* the suspended wasm stack. #10's leak and + the polling protocol disappear, and an await inside a coroutine that Lua resumes just makes the + resumer wait, which is what users expect. The README polling pattern keeps working unchanged + (the loop observes the coroutine finishing). + +### 5.3 The C trampoline, and when suspension is allowed + +Today each JS function is a C closure whose C function is an `addFunction` trampoline +(`functionWrapper`). Two facts from the measurements shape the replacement: a `Suspending` import +traps whenever it is reached outside a `promising` call, and it traps if a JS frame sits between +it and the `promising` boundary. So the decision to suspend has to be taken *before* touching the +`Suspending` import, and from a wasm frame. + +```c +/* src/native/wasmoon.c */ +extern int wasmoon_call(lua_State *L); /* plain import: runs the JS function */ +extern int wasmoon_await(lua_State *L); /* Suspending import: returns the stashed promise */ + +static int wasmoon_jsfunction(lua_State *L) { + int n = wasmoon_call(L); /* >= 0: results pushed; -1: a promise is pending */ + if (n == -1) n = wasmoon_await(L); /* wasm frame → import, no JS in between */ + return n; +} +``` + +`wasmoon_call` is today's `functionWrapper` body plus the promise extension's `await`, and ends +with one decision: + +| Situation | `wasmoon_call` does | +|---|---| +| result is not an await request | push results, return count (unchanged) | +| `:await()` and `syncDepth === 0` and JSPI engine | stash the promise, return -1 → VM suspends | +| `:await()` and `syncDepth > 0` (a sync entry point is on the JS stack) or no JSPI | `YieldSuspender.park` if `lua_isyieldable`, else the error, now worded "cannot await here: a synchronous call is on the stack" / "…across a C-call boundary" | + +`syncDepth` is exact because resumptions only ever happen from an empty JS stack (promise +reactions are microtasks), and a sync entry point cannot yield to the event loop. The one edge, a +run *started* from inside a sync callback, degrades to the yield path, which is correct. + +Both imports are provided through Emscripten's `instantiateWasm` hook so `wasmoon_await` can be +wrapped in `WebAssembly.Suspending` when available and be a plain never-called stub otherwise; no +`-sJSPI` flag, no Asyncify glue. `addFunction` stays for user-registered raw C functions and the +hook; the per-state trampoline pool becomes one C function with the JS reference in an upvalue, +as today. + +### 5.4 Linear-memory stack management (JSPI only) + +JSPI switches the wasm stack, not the `__stack_pointer` global or the memory it points at. When a +resumed run returns through a frame (which restores `__stack_pointer` to that frame's entry) and +then pushes new frames, it writes over any other suspended run's frames below. *Measured*: a Lua +longjmp escapes as a raw `WebAssembly.Exception`, then `C stack overflow`, then `memory access out +of bounds`; the pointer also drifts down 224 bytes per interleaving. Pyodide hit and documented the +same thing for CPython. + +Two strategies were measured (`experiments/jspi-modes.mjs`); both pass the interleaving stress and +the nested-start case (a run started from inside another run's JS callback): + +| | **copy** (recommended) | **region** | +|---|---|---| +| Mechanism | on suspension copy `[sp, mainSP)` to a JS buffer and set SP back; before resuming copy it back and restore `sp`. The promising wrapper restores its own entry SP after the call returns. | `malloc` a 256 KB region per promising run, set SP to its top; the suspension saves/restores SP. | +| Suspension on a settled promise | 1.6 µs | 0.5 µs | +| Suspension 30 `pcall` levels deep (~30 KB of C frames) | 3.9 µs | 0.5 µs | +| 1000 parked runs | rss +4.4 MB, wasm memory +0 | rss +224 MB, wasm memory +247 MB | +| Bytes copied per suspension in the stress test | 23 KB avg | 0 | + +Copy is Pyodide's "simplest fix" and is what the numbers favour: parked runs are the common +steady state for anything event driven, and a suspension is still 4x cheaper than today's round +trip in the worst case measured. Worst-case slice size is bounded by Lua's own C-call limit: +*measured* 108 KB (nested `gsub` to the limit), 45 KB (nested `pcall`), 22 KB (`table.sort` +comparators). Follow-ups if the copy ever shows up in a profile: copy `[sp, entrySP)` instead of +up to the main top (needs the run's entry SP, i.e. run tracking), and pool the buffers by size +class instead of `slice`. + +Why copying back stale bytes over another run's range is safe: a resume only ever happens from an +empty JS stack, at which point every other run is either finished or suspended and therefore +holding its own copy, which it restores on its own resume. Sync entry points push below whatever +the current SP is and finish before anything can resume. + +### 5.5 JS→Lua calls become sync-or-promise + +`getValue` for a function returns a callable that: + +1. acquires a coroutine (pool as today) and runs the function with `lua_resume`, not `lua_pcallk`; +2. if it finishes, returns the value synchronously (unchanged fast path, no promise allocated); +3. if it yields with the await protocol, hands the coroutine to the scheduler as a run and + **returns a Promise**; a host yield inside a callback is an error, as now. + +Under JSPI the same callable still uses `lua_resume` synchronously (a `promising` entry would +cost +2.2 µs on the hot interop path and would *always* return a promise). An await inside it +takes the yield path because `syncDepth > 0`, so `promise:next(function() sleep():await() end)`, +`array:map(luaFn)` with an awaiting `luaFn`, and `await state.get('handler')(req)` all work in +both engines. The footgun ("sometimes a promise") is real but is the only shape that composes with +JS APIs that accept callbacks; `decorate(fn, { call: 'sync' })` can opt a function out (throw on +suspension) and `{ call: 'async' }` can force a promise. + +### 5.6 Cancellation and limits while parked + +- A run's `signal`/`timeout` is raced against every park. On abort the run is resumed with the + interrupt token pushed and `lua_error` (the hook's existing mechanism), so `__close`/to-be-closed + variables run, `pcall` in the script cannot swallow it (token check in `assertOk` as today), and + the coroutine ends in a defined state. Same code path in both engines; in JSPI the resume is + "return from the `Suspending` import, then `lua_error`", never a foreign JS exception thrown + into wasm. The eventual settlement of the abandoned promise is ignored. Fixes #7. +- `pendingInterrupt` moves onto the run. Nested coroutines inherit the hook and report to the + run that owns them (they already inherit the hook function pointer). +- Fallback engine, coroutine resumed by Lua code (not a run): the pending record is keyed in a + registry ephemeron table `{ [thread] = box }` whose box `__gc` drops the JS record, so an + abandoned awaiting coroutine is collected with the thread instead of at `state.close()`. + +### 5.7 Lua-side surface + +- `promise:await()` unchanged. +- `Promise.async(fn, ...)` (inject mode) starts `fn` as its own run and returns a promise. This is + the README workaround implemented in JS with no polling, and the recommended way to fan out. + Works identically in both engines. +- Under the fallback engine, `coroutine.resume(co)` where `co` awaits keeps today's semantics + (yields the promise up; resumer polls). Under JSPI the resumer waits. Documented as "the + fallback is a subset". + +## 6. Performance and memory + +Everything here is *measured* (Node 26.7, `experiments/perf-dist.mjs`, `perf-raw.mjs`, +`jspi-modes.mjs`), best of 5. + +**Hot paths that must not regress** + +| Path | Today | Proposed | Why | +|---|---|---|---| +| Lua→JS function call that does not await | 0.04 µs import | same + one C call | the plain import runs first; the `Suspending` import is only reached when suspension was decided | +| JS→Lua callback (100k calls, pooled thread) | `lua_pcallk` 14.6 ms; through `getValue` wrapper 16.4 ms | `lua_resume` 14.8 ms | same cost; a promise is only allocated when the callback actually suspends | +| CPU-bound Lua (heapsort.lua) | 13.4 ms | 13.5 ms under wasm EH; 91 vs 100 ms for a 200k `table.sort` under `promising` | no Asyncify instrumentation, JSPI is free while not suspending | +| `pcall`, `error`, coroutine yield/resume | see §4 | 30-45% faster | wasm EH replaces the `invoke_*` JS trampolines and JS exceptions | + +**Per-run and per-await costs** + +| | Today | Fallback engine | JSPI engine | +|---|---|---|---| +| Starting a run (`doString('return 1')`) | 4.1 µs | ~same (a `Run` object and a deferred on top) | +2.2 µs for the `promising` entry (39.3 vs 17.2 ms per 10k) | +| Await of a settled promise | 6 µs (two resumes, a JS-exception longjmp, Map lookup) | ~4 µs (wasm-EH longjmp, no Map, starvation counter) | 1.6 µs copy / 0.5 µs region | +| Await 30 C levels deep | error today | error | 3.9 µs copy / 0.5 µs region | +| Bare `coroutine.yield()` round trip | 30 µs Node, ~4 ms browsers | ~µs (`MessageChannel`), no clamp | same loop | +| Resume 1000 parked runs | 2.5 ms | ~same | 0.9 ms | + +**Memory** + +| | Today | Fallback engine | JSPI engine (copy) | JSPI engine (region) | +|---|---|---|---|---| +| 1000 concurrently parked runs | rss +3.8 MB (1.9 MB JS heap; a Lua thread and a pending record each) | ~same, minus the leaked records | rss +4.4 MB (V8 suspended stack ~4-5 KB each, saved slice a few KB) | rss +224 MB | +| Per parked await, steady state | pending record until resume or `state.close()` | pending record until resume, cancel, or the thread's `__gc` | the saved stack slice, freed on resume | the region, freed on completion | +| Abandoned awaiting coroutine | leaks until `state.close()` | collected with the thread (ephemeron) | nothing to leak | nothing to leak | +| Extra bundle | | ~1-2 KB (scheduler) | + a few hundred bytes (wrapper, copy) | | + +Take-aways: the redesign does not touch the interop or interpreter hot paths; awaits get 1.5-10x +cheaper depending on engine; wasm EH is a straight speed win for error handling; and the copy +strategy keeps memory flat in the number of parked runs, which the region strategy does not. + +## 7. API changes (JS) + +| Today | Proposed | Notes | +|---|---|---| +| `doString/doFile` → Promise | unchanged | now a run; abortable while parked | +| `doStringSync/doFileSync` | unchanged | `syncDepth++` around the call | +| `thread.run(argCount, options)` | `+ options.onYield(values) → resume values \| Promise` | top-level `coroutine.yield` becomes a usable host channel; default keeps time-slicing behaviour | +| `thread.runSync`, `thread.call` | unchanged | | +| Lua function from `getValue`: sync, throws on yield | sync result **or** Promise when it suspends | `decorate(fn, { call: 'sync' \| 'async' })` to pin | +| `LuaRunOptions.signal` "observed after the promise settles" | interrupts a parked run immediately | doc change + behaviour | +| `Promise.create/all/resolve` (inject) | `+ Promise.async` | | +| `LuaRuntime.load()` | `+ options.async?: 'auto' \| 'jspi' \| 'yield'` and `runtime.engine` | `'auto'` default; `'yield'` for tests and for diffing behaviour | +| error `cannot await in a thread that cannot yield, use doString instead of doStringSync` | split into "synchronous call on the stack" and "C-call boundary (use the JSPI engine or Promise.async)" | | + +Breaking: (a) callbacks can now return promises; (b) top-level yield values are no longer +discarded; (c) `signal`/`timeout` fire while parked. All three are today's bugs rather than +features, but (a) deserves a major version note. + +## 8. Build changes + +- `-sSUPPORT_LONGJMP=wasm` in `utils/build-wasm.sh`. Required for JSPI (no `invoke_*` JS frames), + +47 bytes wasm, smaller glue, and a native longjmp instead of a JS exception on every + `lua_yieldk`/`lua_error` from a C function. `isEmscriptenUnwind` becomes + `err instanceof WebAssembly.Exception` and the `EmscriptenEH` brand plugin in + `rolldown.config.ts` goes away. All target browsers support wasm EH. +- `src/native/wasmoon.c` gains `wasmoon_jsfunction` and the two imports; the imports are + resolved in `Module.instantiateWasm`. +- No `-sJSPI`, no `-sASYNCIFY`. Feature detection at load, both engines in the bundle (the JSPI + part is a few hundred bytes). + +## 9. Phasing + +1. **Scheduler rewrite (fallback engine only).** `Run`, await token protocol, `onYield`, macrotask + policy, starvation guard, cancellation while parked, per-run interrupt slot, ephemeron for + nested awaits, sync-or-promise callbacks. Ship behind no flag; this is a bugfix release with + one semver-major note. Tests: everything in §2 becomes a test. +2. **`SUPPORT_LONGJMP=wasm` + `wasmoon_jsfunction` in C.** Behaviour-neutral on its own; measure + heapsort and interop benches (memory says the current `-Oz` set wins on heapsort, this flag + was size-neutral in that sweep and was not speed-tested). +3. **JspiSuspender.** `promising(lua_resume)`, stack slice copy (§5.4), `syncDepth` gate, + `Suspending` import via `instantiateWasm`. Gate on the §6 numbers: interop bench unchanged, + heapsort unchanged, 1000 parked runs under 10 MB. Run the whole test suite under `async: 'jspi'` and + `async: 'yield'` on Node ≥25 and browsers; add the interleaving stress from + `experiments/jspi-stack.mjs` as a test. +4. **Docs.** Replace the README "Async/Await" workaround with `Promise.async`, describe the two + engines and the one semantic difference (§5.7). + +## 10. Risks and open questions + +- **Copy cost on deep stacks.** 4 µs at 30 C levels; a pathological script awaiting inside deeply + nested `gsub` callbacks pays ~10 µs per await. Acceptable, and the `[sp, entrySP)` follow-up + halves it. +- **`stackRestore` interaction with Emscripten internals.** `stringToUTF8OnStack`/`withCString` + use the C stack; they must not straddle a park. Today `pushValue` never does, keep it that way. +- **JSPI and the debug hook.** Untested: a hook that fires and `lua_error`s while another run is + suspended. Should be fine (same thread only), needs a test. +- **Firefox/Safari fallback fidelity.** The C-call-boundary case stays an error on Safari and + Firefox <153. Message must point at `Promise.async` or restructuring. +- **Same-function two trampolines?** Not needed: one C function, decision at call time. Verify + `lua_pushcclosure` with a real C function (not `addFunction`) does not change `getReferenceBox` + upvalue handling. +- **Name of `Promise.async` / `decorate` options.** Bikeshed. + +## Appendix: experiments + +All in `experiments/`, run from the repo root. + +- `current-behavior.mjs`: the table in §2 against `dist/`. +- `build-wasm-eh.sh`: the build script with `-sSUPPORT_LONGJMP=wasm`, output to `/out`; run via + `podman run --rm -v "$PWD:/wasmoon" -v "$OUT:/out" docker.io/emscripten/emsdk /out/build-wasm-eh.sh`. +- `jspi-raw.mjs `: JSPI feature and timing matrix (§4 C) against the raw glue. +- `jspi-sp.mjs `: prints `__stack_pointer` around suspensions (shows the drift). +- `jspi-stack.mjs [mitigate]`: the interleaving stress; fails without `mitigate`, passes + with it. +- `jspi-modes.mjs `: the two stack strategies of §5.4 against the + stress, the nested start, suspension timings and memory for 1000 parked runs. Run with + `--expose-gc`. +- `perf-raw.mjs [jspi]`: heapsort, `pcall`/`error`/yield throughput, worst-case C stack + depth; with `jspi`, memory and entry costs. Run on both glues to get the wasm EH comparison. +- `perf-dist.mjs`: today's callback call cost and parked-run memory against `dist/`. diff --git a/docs/async-redesign/experiments/build-wasm-eh.sh b/docs/async-redesign/experiments/build-wasm-eh.sh new file mode 100755 index 0000000..3a2ffef --- /dev/null +++ b/docs/async-redesign/experiments/build-wasm-eh.sh @@ -0,0 +1,222 @@ +#!/bin/bash -e +cd /wasmoon/utils +true + +LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") + +# Do not add --closure here: it renames properties, which would strip the brand the JS build puts on +# the glue's longjmp unwind classes (see rolldown.config.ts) and turn every unwind into a Lua error. +if [ "$1" == "dev" ]; +then + extension=(-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2) +else + extension=(-Oz -fno-inline-functions -DLUA_USE_JUMPTABLE=0 -s BINARYEN_EXTRA_PASSES=gufa-optimizing,converge) +fi + +# Everything that is not about the filesystem, shared by both glues below so they can share one +# `glue.wasm`. +COMMON=( + -s WASM=1 + -s SUPPORT_LONGJMP=wasm + "${extension[@]}" + -s EXPORTED_RUNTIME_METHODS="[ + 'addFunction', \ + 'removeFunction', \ + 'FS', \ + 'PATH', \ + 'ENV', \ + 'getValue', \ + 'setValue', \ + 'lengthBytesUTF8', \ + 'stringToUTF8', \ + 'stringToNewUTF8', \ + 'stringToUTF8OnStack', \ + 'stackSave', \ + 'stackRestore', \ + 'UTF8ToString', \ + 'HEAPU8', \ + 'HEAPU32' + ]" + -s DEFAULT_LIBRARY_FUNCS_TO_INCLUDE="[ + '\$FS_mkdirTree', \ + '\$PATH', \ + '\$PATH_FS' + ]" + -s INCOMING_MODULE_JS_API="[ + 'locateFile', \ + 'preRun', \ + 'print', \ + 'printErr' \ + ]" + -s MODULARIZE=1 + -s ALLOW_TABLE_GROWTH=1 + -s EXPORT_NAME="initWasmModule" + -s ALLOW_MEMORY_GROWTH=1 + -s STRICT=1 + -s EXPORT_ES6=1 + -s MALLOC=emmalloc + -s STACK_SIZE=1MB + -s WASM_BIGINT + -s EXPORTED_FUNCTIONS="[ + '_malloc', \ + '_free', \ + '_realloc', \ + '_luaL_checkversion_', \ + '_luaL_getmetafield', \ + '_luaL_callmeta', \ + '_luaL_tolstring', \ + '_luaL_argerror', \ + '_luaL_typeerror', \ + '_luaL_checklstring', \ + '_luaL_optlstring', \ + '_luaL_checknumber', \ + '_luaL_optnumber', \ + '_luaL_checkinteger', \ + '_luaL_optinteger', \ + '_luaL_checkstack', \ + '_luaL_checktype', \ + '_luaL_checkany', \ + '_luaL_newmetatable', \ + '_luaL_setmetatable', \ + '_luaL_testudata', \ + '_luaL_checkudata', \ + '_luaL_where', \ + '_luaL_fileresult', \ + '_luaL_execresult', \ + '_luaL_ref', \ + '_luaL_unref', \ + '_luaL_loadfilex', \ + '_luaL_loadbufferx', \ + '_luaL_loadstring', \ + '_luaL_newstate', \ + '_luaL_len', \ + '_luaL_addgsub', \ + '_luaL_gsub', \ + '_luaL_setfuncs', \ + '_luaL_getsubtable', \ + '_luaL_traceback', \ + '_luaL_requiref', \ + '_luaL_buffinit', \ + '_luaL_prepbuffsize', \ + '_luaL_addlstring', \ + '_luaL_addstring', \ + '_luaL_addvalue', \ + '_luaL_pushresult', \ + '_luaL_pushresultsize', \ + '_luaL_buffinitsize', \ + '_lua_newstate', \ + '_lua_close', \ + '_lua_newthread', \ + '_lua_closethread', \ + '_lua_atpanic', \ + '_lua_version', \ + '_lua_absindex', \ + '_lua_gettop', \ + '_lua_settop', \ + '_lua_pushvalue', \ + '_lua_rotate', \ + '_lua_copy', \ + '_lua_checkstack', \ + '_lua_xmove', \ + '_lua_isnumber', \ + '_lua_isstring', \ + '_lua_iscfunction', \ + '_lua_isinteger', \ + '_lua_isuserdata', \ + '_lua_type', \ + '_lua_typename', \ + '_lua_tonumberx', \ + '_lua_tointegerx', \ + '_lua_toboolean', \ + '_lua_tolstring', \ + '_lua_rawlen', \ + '_lua_tocfunction', \ + '_lua_touserdata', \ + '_lua_tothread', \ + '_lua_topointer', \ + '_lua_arith', \ + '_lua_rawequal', \ + '_lua_compare', \ + '_lua_pushnil', \ + '_lua_pushnumber', \ + '_lua_pushinteger', \ + '_lua_pushlstring', \ + '_lua_pushstring', \ + '_lua_pushcclosure', \ + '_lua_pushboolean', \ + '_lua_pushlightuserdata', \ + '_lua_pushthread', \ + '_lua_getglobal', \ + '_lua_gettable', \ + '_lua_getfield', \ + '_lua_geti', \ + '_lua_rawget', \ + '_lua_rawgeti', \ + '_lua_rawgetp', \ + '_lua_createtable', \ + '_lua_newuserdatauv', \ + '_lua_getmetatable', \ + '_lua_getiuservalue', \ + '_lua_setglobal', \ + '_lua_settable', \ + '_lua_setfield', \ + '_lua_seti', \ + '_lua_rawset', \ + '_lua_rawseti', \ + '_lua_rawsetp', \ + '_lua_setmetatable', \ + '_lua_setiuservalue', \ + '_lua_callk', \ + '_lua_pcallk', \ + '_lua_load', \ + '_lua_dump', \ + '_lua_yieldk', \ + '_lua_resume', \ + '_lua_status', \ + '_lua_isyieldable', \ + '_lua_setwarnf', \ + '_lua_warning', \ + '_lua_error', \ + '_lua_next', \ + '_lua_concat', \ + '_lua_len', \ + '_lua_stringtonumber', \ + '_lua_getallocf', \ + '_lua_setallocf', \ + '_lua_toclose', \ + '_lua_closeslot', \ + '_lua_getstack', \ + '_lua_getinfo', \ + '_lua_getlocal', \ + '_lua_setlocal', \ + '_lua_getupvalue', \ + '_lua_setupvalue', \ + '_lua_upvalueid', \ + '_lua_upvaluejoin', \ + '_lua_sethook', \ + '_lua_gethook', \ + '_lua_gethookmask', \ + '_lua_gethookcount', \ + '_luaopen_base', \ + '_luaopen_coroutine', \ + '_luaopen_table', \ + '_luaopen_io', \ + '_luaopen_os', \ + '_luaopen_string', \ + '_luaopen_utf8', \ + '_luaopen_math', \ + '_luaopen_debug', \ + '_luaopen_package', \ + '_luaL_openselectedlibs', \ + '_lua_gc' \ + ]" +) + +# The default glue, for every environment. Its filesystem is Emscripten's in-memory one, and +# -lnodefs.js adds the NODEFS backend so a host directory can be mounted into it (LuaModuleOptions +# `mounts`). Nothing here reaches the host on its own. +emcc "${COMMON[@]}" \ + -lnodefs.js \ + -s ENVIRONMENT="web,worker,node" \ + -o /out/glue.js \ + ${LUA_SRC} diff --git a/docs/async-redesign/experiments/current-behavior.mjs b/docs/async-redesign/experiments/current-behavior.mjs new file mode 100644 index 0000000..4b256c2 --- /dev/null +++ b/docs/async-redesign/experiments/current-behavior.mjs @@ -0,0 +1,34 @@ +import { LuaRuntime } from '../../../dist/index.js' +const lua = await LuaRuntime.load() +const state = lua.createState({ inject: true }) +state.set('sleep', (ms) => new Promise((r) => setTimeout(r, ms))) +const probe = async (name, code, opts) => { + const t = Date.now() + try { const r = await state.doString(code, opts); console.log(`[${name}] ok ->`, r, `${Date.now()-t}ms`) } + catch (e) { console.log(`[${name}] ERR ->`, String(e.message).split('\n')[0], `${Date.now()-t}ms`) } +} +await probe('top-level yield of unrepresentable value', `coroutine.yield(io.stdout) return 1`) +await probe('top-level yield values discarded', `local a, b = coroutine.yield(1, 2) return a, b`) +await probe('await inside table.sort comparator', `local t = {3,2,1} table.sort(t, function(a,b) sleep(1):await() return a', await state.get('callback')(2)) } catch (e) { console.log('[JS calls Lua fn that awaits] ERR ->', e.message.split('\n')[0]) } +let fired = false; setTimeout(() => { fired = true }, 5) +state.set('fired', () => fired) +await probe('resolved await loop starves timers? (cap 20000 iterations)', `local n=0 while not fired() and n < 20000 do Promise.resolve(1):await() n=n+1 end return n, fired()`) +await probe('200 bare coroutine.yield() round trips', `for i=1,200 do coroutine.yield() end return 1`) +const ac = new AbortController(); setTimeout(() => ac.abort(), 10) +await probe('abort while parked on 300ms sleep', `sleep(300):await() return 1`, { signal: ac.signal }) +await probe('timeout 10ms while parked on 300ms sleep', `sleep(300):await() return 1`, { timeout: 10 }) +const t0 = Date.now() +const rs = await Promise.all([1,2,3].map(i => state.doString(`sleep(30):await() return ${i}`))) +console.log('[3 concurrent doString on one state] ->', rs, `${Date.now()-t0}ms`) +await probe('nested coroutine.resume of awaiting coroutine, no polling', `local co = coroutine.create(function() sleep(1):await() return 3 end) local ok, v = coroutine.resume(co) return ok, tostring(v), coroutine.status(co)`) +// cost of one await round trip on an already-resolved promise +await probe('10000 awaits of resolved promise (timing)', `local p = Promise.resolve(1) for i=1,10000 do p:await() end return 1`) +state.close() diff --git a/docs/async-redesign/experiments/jspi-modes.mjs b/docs/async-redesign/experiments/jspi-modes.mjs new file mode 100644 index 0000000..f0e46bb --- /dev/null +++ b/docs/async-redesign/experiments/jspi-modes.mjs @@ -0,0 +1,98 @@ +// Compares three ways of handling the linear-memory C stack under JSPI: +// none : nothing (known to corrupt) +// region : a malloc'd stack region per promising run, SP saved/restored around each suspension +// copy : shared stack; on suspension copy out [sp, mainSP) and restore it before resuming +// Usage: node jspi-modes.mjs +const mode = process.argv[3] +const { default: init } = await import(process.argv[2]) +const M = await init({}) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const cstr = (s) => M.stringToNewUTF8(s) +const tostr = (L, i) => M.UTF8ToString(M._lua_tolstring(L, i, 0)) +const newState = () => { const L = M._luaL_newstate(); M._luaL_openselectedlibs(L, 0xffff, 0); return L } +const load = (L, code) => { const p = cstr(code); const st = M._luaL_loadbufferx(L, p, M.lengthBytesUTF8(code), p, 0); M._free(p); if (st) throw new Error('load: ' + tostr(L, -1)) } +const pcallRaw = WebAssembly.promising(M._lua_pcallk) +const REGION = 256 * 1024 +const mainSP = M.stackSave() +let copiedBytes = 0, suspensions = 0 +const pcallAsync = async (L, ...args) => { + if (mode === 'copy') { + // Restore whatever SP the caller had, so a run started from inside an active run's callback + // hands the stack back to that run when it completes or first suspends. + const entry = M.stackSave() + const p = pcallRaw(L, ...args); M.stackRestore(entry); return p + } + if (mode !== 'region') return pcallRaw(L, ...args) + const region = M._malloc(REGION) + M.stackRestore(region + REGION) + try { const p = pcallRaw(L, ...args); M.stackRestore(mainSP); return await p } + finally { M.stackRestore(mainSP); M._free(region) } +} +const suspending = (fn) => new WebAssembly.Suspending( + mode === 'region' ? async (...args) => { const sp = M.stackSave(); try { return await fn(...args) } finally { M.stackRestore(sp) } } + : mode === 'copy' ? async (...args) => { + const sp = M.stackSave() + const saved = M.HEAPU8.slice(sp, mainSP) // the frames this run needs back + copiedBytes += saved.length; suspensions++ + try { return await fn(...args) } finally { M.HEAPU8.set(saved, sp); M.stackRestore(sp) } + } + : fn) +const run = async (L, code) => { const top = M._lua_gettop(L); load(L, code); const st = await pcallAsync(L, 0, 1, 0, 0, 0); const v = tostr(L, -1); M._lua_settop(L, top); if (st) throw new Error(`status ${st}: ${v}`); return v } +const def = (L, name, ptr) => { M._lua_pushcclosure(L, ptr, 0); const p = cstr(name); M._lua_setglobal(L, p); M._free(p) } +const sleepPtr = M.addFunction(suspending(async (L) => { await sleep(M._lua_tonumberx(L, 1, 0)); M._lua_pushinteger(L, 42n); return 1 }), 'ii') +const microPtr = M.addFunction(suspending(async (L) => { await Promise.resolve(); M._lua_pushinteger(L, 1n); return 1 }), 'ii') +const mk = () => { const L = newState(); def(L, 'jspiSleep', sleepPtr); def(L, 'jspiMicro', microPtr); return L } +const deep = (ms, depth, tag) => ` + local function f(n) + if n == 0 then jspiSleep(${ms}) return "" end + local mine = ("${tag}"):rep(200) .. ("%03d"):format(n) + local inner + local r = (mine):gsub("%d%d%d", function(d) + local ok, v = pcall(function() return f(n - 1) end) + if not ok then error(v, 0) end + inner = v + return d + end, 1) + assert(r == mine, "gsub buffer clobbered at level " .. n) + return inner .. mine + end + local out = f(${depth}) + assert(#out == ${depth} * 203, "result length clobbered") + return "ok"` +const A = mk(), B = mk(), C = mk() +const fmt = (e) => `${e?.constructor?.name}: ${String(e?.message ?? e)}`.slice(0, 120) +console.log(`mode: ${mode}`) +for (let round = 1; round <= 3; round++) { + try { + const r = await Promise.all([ + run(A, `pcall(function() jspiSleep(2) end) ${deep(0, 40, "A")}`), + run(B, deep(30, 40, "B")), + run(C, `for i = 1, 5 do pcall(function() jspiSleep(3) end) ${deep(1, 20, "C")} end return "ok"`), + ]) + console.log(` interleaving round ${round}:`, r.join(',')) + } catch (e) { console.log(` interleaving round ${round}: FAILED:`, fmt(e)) } +} +// Nested start: a run started from inside another run's JS callback while that run is active. +try { + let inner + const startInner = M.addFunction((L) => { inner = run(C, deep(5, 30, "N")); return 0 }, 'ii'); def(A, 'startInner', startInner) + const outer = run(A, `startInner() ${deep(2, 30, "O")}`) + console.log(' nested start:', await outer, await inner) +} catch (e) { console.log(' nested start: FAILED:', fmt(e)) } +console.log(` stack bytes copied per suspension (avg): ${suspensions ? Math.round(copiedBytes / suspensions).toLocaleString() : 'n/a'}`) + +let s = performance.now(); await run(A, `for i=1,10000 do jspiMicro() end`) +console.log(` 10k suspensions on a settled promise: ${(performance.now() - s).toFixed(1)} ms`) +s = performance.now(); await run(A, `local function f(n) if n == 0 then for i=1,2000 do jspiMicro() end return end pcall(f, n-1) end f(30)`) +console.log(` 2k suspensions 30 pcall levels deep: ${(performance.now() - s).toFixed(1)} ms`) + +// Memory for 1000 concurrently parked runs. +let release; const gate = new Promise((r) => { release = r }) +const waitPtr = M.addFunction(suspending(async () => { await gate; return 0 }), 'ii') +const L0 = newState(); M._lua_checkstack(L0, 1100); const threads = [] +for (let i = 0; i < 1000; i++) { const T = M._lua_newthread(L0); def(T, 'wait', waitPtr); threads.push(T) } +global.gc?.(); const before = process.memoryUsage(); const heapBefore = M.HEAPU8.length +const runs = threads.map((T) => { load(T, `wait() return 1`); return pcallAsync(T, 0, 1, 0, 0, 0) }) +global.gc?.(); const during = process.memoryUsage() +console.log(` 1000 parked runs: rss +${((during.rss - before.rss) / 1048576).toFixed(1)} MB, js heap +${((during.heapUsed - before.heapUsed) / 1048576).toFixed(1)} MB, wasm memory +${((M.HEAPU8.length - heapBefore) / 1048576).toFixed(1)} MB`) +s = performance.now(); release(); await Promise.all(runs); console.log(` resume all 1000: ${(performance.now() - s).toFixed(1)} ms`) diff --git a/docs/async-redesign/experiments/jspi-raw.mjs b/docs/async-redesign/experiments/jspi-raw.mjs new file mode 100644 index 0000000..320a56a --- /dev/null +++ b/docs/async-redesign/experiments/jspi-raw.mjs @@ -0,0 +1,52 @@ +// Drives the raw Emscripten glue (no wasmoon TS layer) so it can point at an experimental build. +const glue = process.argv[2] +const { default: init } = await import(glue) +const M = await init({}) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const cstr = (s) => M.stringToNewUTF8(s) +const tostr = (L, i) => M.UTF8ToString(M._lua_tolstring(L, i, 0)) +const newState = () => { const L = M._luaL_newstate(); M._luaL_openselectedlibs(L, 0xffff, 0); return L } +const load = (L, code) => { const p = cstr(code); const n = M.lengthBytesUTF8(code); const st = M._luaL_loadbufferx(L, p, n, p, 0); M._free(p); if (st) throw new Error('load: ' + tostr(L, -1)) } +const pcallAsync = WebAssembly.promising(M._lua_pcallk) +const run = async (L, code) => { const top = M._lua_gettop(L); load(L, code); const st = await pcallAsync(L, 0, 1, 0, 0, 0); const v = tostr(L, -1); M._lua_settop(L, top); if (st) throw new Error(`status ${st}: ${v}`); return v } +const runSync = (L, code) => { const top = M._lua_gettop(L); load(L, code); const st = M._lua_pcallk(L, 0, 1, 0, 0, 0); const v = tostr(L, -1); M._lua_settop(L, top); if (st) throw new Error(`status ${st}: ${v}`); return v } +const def = (L, name, ptr) => { M._lua_pushcclosure(L, ptr, 0); const p = cstr(name); M._lua_setglobal(L, p); M._free(p) } + +const fns = { + jspiSleep: M.addFunction(new WebAssembly.Suspending(async (L) => { const ms = M._lua_tonumberx(L, 1, 0); await sleep(ms); M._lua_pushinteger(L, 42n); return 1 }), 'ii'), + jspiSync: M.addFunction(new WebAssembly.Suspending((L) => { M._lua_pushinteger(L, 1n); return 1 }), 'ii'), + jspiMicro: M.addFunction(new WebAssembly.Suspending(async (L) => { await Promise.resolve(); M._lua_pushinteger(L, 1n); return 1 }), 'ii'), + jspiThrow: M.addFunction(new WebAssembly.Suspending(async (L) => { await sleep(1); const p = cstr('boom after suspend'); M._lua_pushstring(L, p); M._free(p); return M._lua_error(L) }), 'ii'), + plainFn: M.addFunction((L) => { M._lua_pushinteger(L, 1n); return 1 }, 'ii'), +} +const mk = () => { const L = newState(); for (const [n, p] of Object.entries(fns)) def(L, n, p); return L } +const L1 = mk(), L2 = mk() +const t = async (name, f) => { try { console.log(`[${name}]`, await f()) } catch (e) { console.log(`[${name}] FAILED:`, e.message.split('\n')[0]) } } + +await t('await in C function', () => run(L1, `return jspiSleep(5) * 2`)) +await t('inside table.sort comparator', () => run(L1, `local t={3,2,1} table.sort(t, function(a,b) jspiSleep(1) return a run(L1, `return (("abc"):gsub(".", function(c) jspiSleep(1) return c:upper() end))`)) +await t('inside coroutine, no host yield', () => run(L1, `local co=coroutine.wrap(function() return jspiSleep(1) end) return co()`)) +await t('lua error after suspend is pcall-able', () => run(L1, `local ok, e = pcall(jspiThrow) return tostring(ok) .. " " .. tostring(e)`)) +await t('lua error() after suspend in Lua', () => run(L1, `local ok, e = pcall(function() jspiSleep(1) error("boom") end) return tostring(e)`)) +await t('coroutine.yield still works alongside', () => run(L1, `local co = coroutine.create(function() jspiSleep(1) coroutine.yield(7) return 8 end) local _, a = coroutine.resume(co) local _, b = coroutine.resume(co) return a .. b`)) +await t('sync pcall with a suspending import (expected to fail)', () => runSync(L1, `return jspiSleep(1)`)) +await t('sync pcall with non-suspending Suspending import', () => runSync(L1, `return jspiSync()`)) + +const stress = (L, ms) => run(L, `for i=1,40 do local r = ("x"):rep(64):gsub(".", function() jspiSleep(${ms}) return "y" end) assert(r == ("y"):rep(64), "clobbered") end return "ok"`) +await t('two states interleaving suspended runs (shadow stack)', () => Promise.all([stress(L1, 1), stress(L2, 2)])) +await t('same state, two concurrent promising pcalls', () => Promise.all([stress(L1, 1), stress(L1, 2)])) +// Deep C recursion live across suspensions on both sides. +const deep = (L, ms) => run(L, `local function f(n) if n == 0 then jspiSleep(${ms}) return 0 end return 1 + tonumber(("%d"):format(f(n-1))) end for i=1,20 do assert(f(150) == 150) end return "ok"`) +await t('deep interleaved recursion across suspensions', () => Promise.all([deep(L1, 1), deep(L2, 2)])) + +const time = async (name, f) => { const s = performance.now(); await f(); console.log(` ${name}: ${(performance.now() - s).toFixed(1)} ms`) } +await time('100k Suspending imports that never suspend (promising pcall)', () => run(L1, `for i=1,100000 do jspiSync() end`)) +await time('100k plain imports (promising pcall)', () => run(L1, `for i=1,100000 do plainFn() end`)) +await time('100k plain imports (sync pcall)', async () => runSync(L1, `for i=1,100000 do plainFn() end`)) +await time('10k promising pcall round trips', async () => { for (let i = 0; i < 10000; i++) await run(L1, `return 1`) }) +await time('10k sync pcall round trips', async () => { for (let i = 0; i < 10000; i++) runSync(L1, `return 1`) }) +await time('10k suspensions on resolved promise', () => run(L1, `for i=1,10000 do jspiMicro() end`)) +await time('1k suspensions on setTimeout(0)', () => run(L1, `for i=1,1000 do jspiSleep(0) end`)) +await time('heapsort-ish CPU loop, sync pcall', async () => runSync(L1, `local t={} for i=1,200000 do t[i]=(i*7919)%1000 end table.sort(t)`)) +await time('heapsort-ish CPU loop, promising pcall', () => run(L1, `local t={} for i=1,200000 do t[i]=(i*7919)%1000 end table.sort(t)`)) diff --git a/docs/async-redesign/experiments/jspi-sp.mjs b/docs/async-redesign/experiments/jspi-sp.mjs new file mode 100644 index 0000000..8c21ef0 --- /dev/null +++ b/docs/async-redesign/experiments/jspi-sp.mjs @@ -0,0 +1,23 @@ +const { default: init } = await import(process.argv[2]) +const M = await init({}) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const cstr = (s) => M.stringToNewUTF8(s) +const newState = () => { const L = M._luaL_newstate(); M._luaL_openselectedlibs(L, 0xffff, 0); return L } +const load = (L, code) => { const p = cstr(code); M._luaL_loadbufferx(L, p, M.lengthBytesUTF8(code), p, 0); M._free(p) } +const pcallRaw = WebAssembly.promising(M._lua_pcallk) +const log = (tag) => console.log(tag.padEnd(46), 'SP =', M.stackSave()) +log('main, before anything') +const ptr = M.addFunction(new WebAssembly.Suspending(async (L) => { + const name = M.UTF8ToString(M._lua_tolstring(L, 1, 0)); const ms = M._lua_tonumberx(L, 2, 0) + log(`import entry (${name})`) + await sleep(ms) + log(`import after await, before resume (${name})`) + M._lua_pushinteger(L, 1n); return 1 +}), 'ii') +const def = (L) => { M._lua_pushcclosure(L, ptr, 0); const p = cstr('susp'); M._lua_setglobal(L, p); M._free(p) } +const A = newState(), B = newState(); def(A); def(B) +load(A, `susp("A shallow", 5) local function f(n) if n == 0 then susp("A deep", 5) return end f(n-1) end f(60)`) +load(B, `local function f(n) if n == 0 then susp("B deep", 20) return end f(n-1) end f(60)`) +const pa = pcallRaw(A, 0, 0, 0, 0, 0); log('main, after A suspended') +const pb = pcallRaw(B, 0, 0, 0, 0, 0); log('main, after B suspended') +await Promise.all([pa, pb]); log('main, both done') diff --git a/docs/async-redesign/experiments/jspi-stack.mjs b/docs/async-redesign/experiments/jspi-stack.mjs new file mode 100644 index 0000000..3ef1e1f --- /dev/null +++ b/docs/async-redesign/experiments/jspi-stack.mjs @@ -0,0 +1,61 @@ +// Adversarial for the linear-memory C stack. Lua->Lua calls do not recurse in C, so recursion goes +// through pcall (a lua_longjmp with a setjmp buffer per level on the C stack) and gsub (a +// luaL_Buffer per level), each level verifying its own C-stack data after the inner levels ran. +const mitigate = process.argv[3] === 'mitigate' +const { default: init } = await import(process.argv[2]) +const M = await init({}) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const cstr = (s) => M.stringToNewUTF8(s) +const tostr = (L, i) => M.UTF8ToString(M._lua_tolstring(L, i, 0)) +const newState = () => { const L = M._luaL_newstate(); M._luaL_openselectedlibs(L, 0xffff, 0); return L } +const load = (L, code) => { const p = cstr(code); const st = M._luaL_loadbufferx(L, p, M.lengthBytesUTF8(code), p, 0); M._free(p); if (st) throw new Error('load: ' + tostr(L, -1)) } +const pcallRaw = WebAssembly.promising(M._lua_pcallk) +const REGION = 256 * 1024 +const mainSP = M.stackSave() +const pcallAsync = async (L, ...args) => { + if (!mitigate) return pcallRaw(L, ...args) + const region = M._malloc(REGION) + M.stackRestore(region + REGION) + try { const p = pcallRaw(L, ...args); M.stackRestore(mainSP); return await p } + finally { M.stackRestore(mainSP); M._free(region) } +} +const suspending = (fn) => new WebAssembly.Suspending(mitigate + ? async (...args) => { const sp = M.stackSave(); try { return await fn(...args) } finally { M.stackRestore(sp) } } + : fn) +const run = async (L, code) => { const top = M._lua_gettop(L); load(L, code); const st = await pcallAsync(L, 0, 1, 0, 0, 0); const v = tostr(L, -1); M._lua_settop(L, top); if (st) throw new Error(`status ${st}: ${v}`); return v } +const def = (L, name, ptr) => { M._lua_pushcclosure(L, ptr, 0); const p = cstr(name); M._lua_setglobal(L, p); M._free(p) } +const sleepPtr = M.addFunction(suspending(async (L) => { const ms = M._lua_tonumberx(L, 1, 0); await sleep(ms); M._lua_pushinteger(L, 42n); return 1 }), 'ii') +const mk = () => { const L = newState(); def(L, 'jspiSleep', sleepPtr); return L } +const deep = (ms, depth, tag) => ` + local function f(n) + if n == 0 then jspiSleep(${ms}) return "" end + local mine = ("${tag}"):rep(200) .. ("%03d"):format(n) + local inner + local r = (mine):gsub("%d%d%d", function(d) + local ok, v = pcall(function() return f(n - 1) end) + if not ok then error(v, 0) end + inner = v + return d + end, 1) + assert(r == mine, "gsub buffer clobbered at level " .. n) + return inner .. mine + end + local out = f(${depth}) + assert(#out == ${depth} * 203, "result length clobbered") + return "ok"` +const A = mk(), B = mk() +for (let round = 1; round <= 3; round++) { + try { + const r = await Promise.all([ + run(A, `pcall(function() jspiSleep(2) end) ${deep(0, 40, "A")}`), // shallow park, then deep C recursion while B is parked + run(B, deep(30, 40, "B")), // deep C recursion, then park for 30ms + ]) + console.log(`round ${round}:`, r) + } catch (e) { console.log(`round ${round}: FAILED:`, `${e?.constructor?.name}: ${String(e?.message ?? e)}`.slice(0, 160)) } +} +try { + // Single run: park deep, and while parked let the main JS thread make deep sync calls. + const parked = run(A, deep(20, 40, "A")) + load(B, deep(0, 40, "B").replace('jspiSleep(0)', '')); const st = M._lua_pcallk(B, 0, 1, 0, 0, 0); const v = tostr(B, -1); M._lua_settop(B, 0) + console.log('deep sync call while A parked deep:', st, v, '| A ->', await parked) +} catch (e) { console.log('sync-while-parked FAILED:', `${e?.constructor?.name}: ${String(e?.message ?? e)}`.slice(0, 160)) } diff --git a/docs/async-redesign/experiments/perf-dist.mjs b/docs/async-redesign/experiments/perf-dist.mjs new file mode 100644 index 0000000..5f089c3 --- /dev/null +++ b/docs/async-redesign/experiments/perf-dist.mjs @@ -0,0 +1,25 @@ +import { LuaRuntime } from '../../../dist/index.js' +const lua = await LuaRuntime.load() +const M = lua.module +const state = lua.createState({ inject: true }) +const time = (name, f, n = 5) => { let best = Infinity; for (let i = 0; i < n; i++) { const s = performance.now(); f(); best = Math.min(best, performance.now() - s) } console.log(` ${name}: ${best.toFixed(1)} ms (best of ${n})`) } + +console.log('== callbacks: lua_pcallk vs lua_resume on a pooled thread ==') +state.doStringSync('function f(x) return x + 1 end') +const T = state.newThread() +const N = 100000 +time(`${N} lua_pcallk calls`, () => { for (let i = 0; i < N; i++) { M.lua_getglobal(T.address, 'f'); M.lua_pushinteger(T.address, i); M.lua_pcallk(T.address, 1, 1, 0, 0, null); M.lua_settop(T.address, 0) } }) +time(`${N} lua_resume calls`, () => { for (let i = 0; i < N; i++) { M.lua_getglobal(T.address, 'f'); M.lua_pushinteger(T.address, i); M.lua_resume(T.address, null, 1, M.resultCountScratch); M.lua_settop(T.address, 0) } }) +const jsF = state.get('f') +time(`${N} calls through today's getValue wrapper`, () => { for (let i = 0; i < N; i++) jsF(i) }) + +console.log('== today: memory for 1000 concurrently parked doString awaits ==') +let release; const gate = new Promise((r) => { release = r }) +state.set('wait', () => gate) +global.gc?.(); const before = process.memoryUsage(); const heapBefore = M.emscripten.HEAPU8.length +const runs = []; for (let i = 0; i < 1000; i++) runs.push(state.doString('wait():await() return 1')) +await new Promise((r) => setTimeout(r, 20)); global.gc?.(); const during = process.memoryUsage() +console.log(` 1000 parked runs: rss +${((during.rss - before.rss) / 1048576).toFixed(1)} MB, js heap +${((during.heapUsed - before.heapUsed) / 1048576).toFixed(1)} MB, wasm memory grew ${(M.emscripten.HEAPU8.length - heapBefore) / 1048576} MB`) +const s = performance.now(); release(); await Promise.all(runs); console.log(` resuming all 1000: ${(performance.now() - s).toFixed(1)} ms`) +time('1000 doString of "return 1" (run setup cost today)', () => { const ps = []; for (let i = 0; i < 1000; i++) ps.push(state.doString('return 1')) }, 3) +state.close() diff --git a/docs/async-redesign/experiments/perf-raw.mjs b/docs/async-redesign/experiments/perf-raw.mjs new file mode 100644 index 0000000..bccbb3f --- /dev/null +++ b/docs/async-redesign/experiments/perf-raw.mjs @@ -0,0 +1,60 @@ +// Raw-glue measurements. Usage: node perf-raw.mjs [jspi] +const { default: init } = await import(process.argv[2]) +const jspi = process.argv[3] === 'jspi' +const M = await init({}) +const cstr = (s) => M.stringToNewUTF8(s) +const tostr = (L, i) => M.UTF8ToString(M._lua_tolstring(L, i, 0)) +const L = M._luaL_newstate(); M._luaL_openselectedlibs(L, 0xffff, 0) +const load = (T, code) => { const p = cstr(code); const st = M._luaL_loadbufferx(T, p, M.lengthBytesUTF8(code), p, 0); M._free(p); if (st) throw new Error('load: ' + tostr(T, -1)) } +const runSync = (T, code) => { const top = M._lua_gettop(T); load(T, code); const st = M._lua_pcallk(T, 0, 1, 0, 0, 0); const v = tostr(T, -1); M._lua_settop(T, top); if (st) throw new Error(`status ${st}: ${v}`); return v } +const time = (name, f, n = 5) => { let best = Infinity; for (let i = 0; i < n; i++) { const s = performance.now(); f(); best = Math.min(best, performance.now() - s) } console.log(` ${name}: ${best.toFixed(1)} ms (best of ${n})`) } +const heapsort = (await import('node:fs')).readFileSync(new URL('../../../bench/heapsort.lua', import.meta.url), 'utf8') + +console.log('== CPU / error paths ==') +runSync(L, 'hs = (function() ' + heapsort + ' end)()'); time('heapsort.lua', () => runSync(L, 'return hs()')) +time('100k pcall(error) round trips', () => runSync(L, `for i=1,100000 do pcall(error, "x") end`)) +time('100k pcall(f) no error', () => runSync(L, `local f = function() end for i=1,100000 do pcall(f) end`)) +time('100k coroutine.yield/resume (Lua only)', () => runSync(L, `local co = coroutine.wrap(function() while true do coroutine.yield() end end) for i=1,100000 do co() end`)) +// A C function that yields: lua_yieldk from C is a longjmp. Use a JS import that yields. +const yieldPtr = M.addFunction((T) => M._lua_yieldk(T, 0, 0, 0), 'ii') +M._lua_pushcclosure(L, yieldPtr, 0); { const p = cstr('cyield'); M._lua_setglobal(L, p); M._free(p) } +time('100k yields from a C (JS) function via longjmp', () => runSync(L, `local co = coroutine.wrap(function() while true do cyield() end end) for i=1,100000 do co() end`)) +const errPtr = M.addFunction((T) => { const p = cstr('e'); M._lua_pushstring(T, p); M._free(p); return M._lua_error(T) }, 'ii') +M._lua_pushcclosure(L, errPtr, 0); { const p = cstr('cerror'); M._lua_setglobal(L, p); M._free(p) } +time('100k lua_error from a C (JS) function', () => runSync(L, `for i=1,100000 do pcall(cerror) end`)) + +console.log('== C stack depth ==') +const minSP = { v: Infinity } +const probePtr = M.addFunction((T) => { minSP.v = Math.min(minSP.v, M.stackSave()); return 0 }, 'ii') +M._lua_pushcclosure(L, probePtr, 0); { const p = cstr('probe'); M._lua_setglobal(L, p); M._free(p) } +const top = M.stackSave() +for (const [name, code] of [ + ['pcall recursion to the C limit', `local n = 0 local function f() n = n + 1 probe() local ok, e = pcall(f) if not ok and n == 1 then return e end end f() return n`], + ['gsub recursion to the C limit', `local n = 0 local function f() n = n + 1 probe() local ok, e = pcall(function() ("a"):gsub("a", f) end) end f() return n`], + ['string.format/ tostring / sort nesting', `local n = 0 local function f() n = n + 1 probe() pcall(table.sort, {2,1}, function(a,b) f() return a { release = r }) + const waitPtr = M.addFunction(new WebAssembly.Suspending(async (T) => { await gate; return 0 }), 'ii') + const N = 1000 + M._lua_checkstack(L, N + 10); const threads = [] + for (let i = 0; i < N; i++) { const T = M._lua_newthread(L); M._lua_pushcclosure(T, waitPtr, 0); const p = cstr('wait'); M._lua_setglobal(T, p); M._free(p); threads.push(T) } + // threads stay anchored on the main stack + global.gc?.(); const before = process.memoryUsage() + const heapBefore = M.HEAPU8.length + const runs = threads.map((T) => { load(T, `wait() return 1`); return pcallAsync(T, 0, 1, 0, 0, 0) }) + global.gc?.(); const during = process.memoryUsage() + console.log(` ${N} suspended runs: rss +${((during.rss - before.rss) / 1048576).toFixed(1)} MB, js heap +${((during.heapUsed - before.heapUsed) / 1048576).toFixed(1)} MB, external +${((during.external - before.external) / 1048576).toFixed(1)} MB, wasm memory grew ${(M.HEAPU8.length - heapBefore) / 1048576} MB`) + const s = performance.now(); release(); await Promise.all(runs) + console.log(` resuming all ${N}: ${(performance.now() - s).toFixed(1)} ms`) + // Cost of starting a promising run that completes synchronously, vs sync, on a fresh thread each time. + const T = M._lua_newthread(L) + time('10k promising pcall (sync completion) on one thread', () => { for (let i = 0; i < 10000; i++) { load(T, 'return 1'); pcallAsync(T, 0, 1, 0, 0, 0); M._lua_settop(T, 0) } }) + time('10k sync pcall on one thread', () => { for (let i = 0; i < 10000; i++) { load(T, 'return 1'); M._lua_pcallk(T, 0, 1, 0, 0, 0); M._lua_settop(T, 0) } }) + time('10k malloc/free of a 256 KB region', () => { for (let i = 0; i < 10000; i++) M._free(M._malloc(262144)) }) + time('10k malloc/free of a 64 KB region', () => { for (let i = 0; i < 10000; i++) M._free(M._malloc(65536)) }) +} diff --git a/src/async.ts b/src/async.ts new file mode 100644 index 0000000..4a85f49 --- /dev/null +++ b/src/async.ts @@ -0,0 +1,126 @@ +/** + * A macrotask, so pending promise reactions *and* timers get a turn before Lua is resumed. A + * microtask would starve timer driven code such as setTimeout based sleeps. + * + * MessageChannel where it exists, because a browser clamps a nested setTimeout to 4ms while a + * message post is not clamped; setImmediate under Node; setTimeout as the last resort. + */ +import { LuaAbortError, type LuaInterruptError, LuaTimeoutError } from './types' + +type Macrotask = (task: () => void) => void + +const macrotask: Macrotask = (() => { + if (typeof setImmediate === 'function') { + return (task: () => void) => void setImmediate(task) + } + if (typeof MessageChannel === 'function') { + const channel = new MessageChannel() + const queue: Array<() => void> = [] + channel.port1.onmessage = () => queue.shift()?.() + return (task: () => void) => { + queue.push(task) + channel.port2.postMessage(null) + } + } + return (task: () => void) => void setTimeout(task, 0) +})() + +export const yieldToEventLoop = (): Promise => { + return new Promise((resolve) => macrotask(resolve)) +} + +/** + * The interrupt a deadline or aborted signal calls for, or undefined when neither has fired. The + * single source of both the abort-vs-timeout classification and its message, shared by the debug + * hook's limit check and the JSPI await hook. + */ +export function limitError(signal: AbortSignal | undefined, deadline: number | undefined): LuaInterruptError | undefined { + if (signal?.aborted) { + return new LuaAbortError('thread aborted') + } + if (deadline !== undefined && Date.now() >= deadline) { + return new LuaTimeoutError('thread timeout exceeded') + } + return undefined +} + +export type SettleOutcome = + | { interrupted: false; resolved: true; value: unknown } + | { interrupted: false; resolved: false; error: unknown } + | { interrupted: true; error: LuaInterruptError } + +/** + * Awaits `promise`, but reports an interruption instead if `signal` aborts or `deadline` passes + * first. Used by the JSPI await hook, which unwinds the suspended run with a Lua error when it is + * interrupted rather than waiting for the promise it was parked on. + */ +export function settleOrInterrupt(promise: unknown, signal: AbortSignal | undefined, deadline: number | undefined): Promise { + const settled: Promise = Promise.resolve(promise).then( + (value) => ({ interrupted: false, resolved: true, value }), + (error) => ({ interrupted: false, resolved: false, error }), + ) + if (signal === undefined && deadline === undefined) { + return settled + } + + return new Promise((resolve) => { + let done = false + const finish = (outcome: SettleOutcome): void => { + if (done) { + return + } + done = true + if (timer !== undefined) { + clearTimeout(timer) + } + if (onAbort !== undefined) { + signal?.removeEventListener('abort', onAbort) + } + resolve(outcome) + } + + let timer: ReturnType | undefined + const scheduleDeadline = (): void => { + if (deadline === undefined) { + return + } + // Timers can fire early and delays above 2^31-1 overflow to 1ms in Node. + timer = setTimeout( + () => { + const error = limitError(signal, deadline) + if (error) { + finish({ interrupted: true, error }) + } else { + scheduleDeadline() + } + }, + Math.min(0x7fffffff, Math.max(0, deadline - Date.now())), + ) + } + scheduleDeadline() + + let onAbort: (() => void) | undefined + if (signal !== undefined) { + if (signal.aborted) { + finish({ interrupted: true, error: new LuaAbortError('thread aborted') }) + return + } + onAbort = () => finish({ interrupted: true, error: new LuaAbortError('thread aborted') }) + signal.addEventListener('abort', onAbort, { once: true }) + } + + settled.then(finish) + }) +} + +/** + * Returned by a JS function through the C trampoline to ask it to suspend the JSPI stack. The + * function extension turns it into the -1 the trampoline reads; nothing else produces it. + */ +export const SUSPEND: unique symbol = Symbol('wasmoon.suspend') + +/** One record per coroutine await, shared by the continuation and its host driver. */ +export interface PendingAwait { + promise: Promise + result: { status: 'fulfilled' | 'rejected'; value: unknown } | undefined +} diff --git a/src/module.ts b/src/module.ts index 869a99d..159ea31 100755 --- a/src/module.ts +++ b/src/module.ts @@ -3,7 +3,18 @@ // `/// ` at the top of the emitted declarations to cover everyone // else. tsc drops the directive when it is written here, hence the build step. import initWasmModule from '../build/glue.js' -import { defaultWarnHandler, LUA_REGISTRYINDEX, type LuaAddress, LuaReturn, LuaType, type LuaWarnHandler, PointerSize } from './types' +import { limitError, settleOrInterrupt, yieldToEventLoop } from './async' +import type Thread from './thread' +import { + defaultWarnHandler, + LUA_REGISTRYINDEX, + type LuaAddress, + type LuaInterruptError, + LuaReturn, + LuaType, + type LuaWarnHandler, + PointerSize, +} from './types' // A rolldown plugin will resolve this to the current version on package.json import version from 'package-version' @@ -95,6 +106,12 @@ export interface LuaModuleOptions { * that state overrides it. Defaults to `console.warn`. */ onWarn?: LuaWarnHandler | undefined + /** + * Which async engine to run under. `'auto'` (the default) uses JSPI where supported, with + * coroutine yielding as its fallback. `'jspi'` requires JSPI and throws if it is unavailable. + * `'yield'` uses coroutine yielding on every platform. + */ + async?: 'auto' | 'jspi' | 'yield' | undefined } // One-shot conversions, so a single stateless codec pair is shared by every module. Streaming @@ -340,12 +357,14 @@ export default class LuaModule { throw new Error(`fs: 'host' needs glue-host.js, which the bundle replaced with a stub because it targets the browser`) } + const asyncEngine = opts.async ?? 'auto' const load = async (wasmFile?: string): Promise => { return new LuaModule( await init({ ...(wasmFile === undefined ? {} : { locateFile: () => wasmFile }), preRun, printErr }), opts.onWarn, fs, mounts, + asyncEngine, ) } @@ -396,6 +415,8 @@ export default class LuaModule { public readonly onWarn: LuaWarnHandler | undefined /** Which filesystem this module was loaded with, as {@link LuaModuleOptions.fs}. */ public readonly fs: LuaFileSystem + /** The async engine requested at load, as {@link LuaModuleOptions.async}. */ + public readonly asyncEngine: 'auto' | 'jspi' | 'yield' public luaL_checkversion_: (L: LuaAddress, ver: number, sz: number) => void public luaL_getmetafield: (L: LuaAddress, obj: number, e: string | null) => LuaType @@ -536,6 +557,10 @@ export default class LuaModule { public lua_upvalueid: (L: LuaAddress, fidx: number, n: number) => LuaAddress public lua_upvaluejoin: (L: LuaAddress, fidx1: number, n1: number, fidx2: number, n2: number) => void public lua_sethook: (L: LuaAddress, func: number | null, mask: number, count: number) => void + /** Installs the await hook the {@link wasmoon_push_jsfunction} closures suspend through. */ + public wasmoon_set_await_hook: (hook: number) => void + /** Pops the reference box on the stack and pushes a JS function closure over it and `callHook`. */ + public wasmoon_push_jsfunction: (L: LuaAddress, callHook: number) => void public lua_gethook: (L: LuaAddress) => number public lua_gethookmask: (L: LuaAddress) => number public lua_gethookcount: (L: LuaAddress) => number @@ -590,6 +615,59 @@ export default class LuaModule { * of the limit that was actually hit. */ public readonly interruptToken: number + /** + * The wasm stack pointer while control is in JS with none of our wasm frames below, captured + * once. A JSPI suspension frees the C stack from the run's frontier up to here, and restores it + * before resuming. See {@link installAwaitHook}. + */ + public readonly mainStackPointer: number + /** + * Set true only while a JSPI promising resume is the innermost driver on the stack, so an + * `:await()` knows a suspend would reach a promising boundary. A synchronous entry point + * (doStringSync, a JS→Lua callback) sets it false around its own resume, so an await there + * takes the yield path instead of trapping. + */ + public stackCanSuspend = false + /** + * The promise a JSPI `:await()` stashed for the C trampoline's await hook to suspend on, along + * with how to marshal its settled value back onto the Lua stack. Read synchronously by the + * await hook right after the await closure returns, so a single slot is reentrancy safe. + */ + public pendingSuspend: + | { + run: Thread + promise: PromiseLike + signal: AbortSignal | undefined + deadline: number | undefined + isClosed: () => boolean + onResolve: (value: unknown) => number + onReject: (error: unknown) => number + onInterrupt: (error: LuaInterruptError) => number + } + | undefined + private awaitHookPointer: number | undefined + /** The innermost Lua driver. Installed only while entering or continuing Lua. */ + public activeRun: Thread | undefined + private asyncSteps = 0 + + /** Amortized fairness across all runs; the common step allocates nothing. */ + public scheduleAsync(): Promise | undefined { + if (++this.asyncSteps < 256) { + return undefined + } + this.asyncSteps = 0 + return yieldToEventLoop() + } + + /** + * Whether runs on this module go through JSPI. On unless the platform lacks it or `async` asked + * for the yielding engine, in which case an `:await()` can only park at a coroutine boundary. + */ + public readonly useJspi: boolean + /** `promising(lua_resume)`, built once. */ + private promisingResumeFunction: + | ((L: LuaAddress, from: LuaAddress | null, narg: number, nres: number) => Promise) + | undefined private stringBuffer = 0 /** Built on first use by {@link referenceGcFunction}, then kept for the module's lifetime. */ private referenceGcPointer: number | undefined @@ -599,11 +677,19 @@ export default class LuaModule { onWarn?: LuaWarnHandler, fs: LuaFileSystem = 'memory', mounts: readonly ResolvedMount[] = [], + asyncEngine: 'auto' | 'jspi' | 'yield' = 'auto', ) { this.emscripten = module this.onWarn = onWarn this.fs = fs this.mounts = [...mounts] + this.asyncEngine = asyncEngine + + if (asyncEngine === 'jspi' && !this.jspiSupported) { + throw new Error( + "async: 'jspi' needs a platform with the JavaScript Promise Integration API and a glue built with SUPPORT_LONGJMP=wasm", + ) + } this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) @@ -736,6 +822,8 @@ export default class LuaModule { this.lua_upvalueid = this.cwrap('lua_upvalueid', 'number', ['number', 'number', 'number']) this.lua_upvaluejoin = this.cwrap('lua_upvaluejoin', null, ['number', 'number', 'number', 'number', 'number']) this.lua_sethook = this.cwrap('lua_sethook', null, ['number', 'number', 'number', 'number']) + this.wasmoon_set_await_hook = this.cwrap('wasmoon_set_await_hook', null, ['number']) + this.wasmoon_push_jsfunction = this.cwrap('wasmoon_push_jsfunction', null, ['number', 'number']) this.lua_gethook = this.cwrap('lua_gethook', 'number', ['number']) this.lua_gethookmask = this.cwrap('lua_gethookmask', 'number', ['number']) this.lua_gethookcount = this.cwrap('lua_gethookcount', 'number', ['number']) @@ -774,6 +862,79 @@ export default class LuaModule { if (!this.sizeScratch || !this.resultCountScratch || !this.gcArgsScratch || !this.interruptToken) { throw new Error('failed to allocate the scratch buffers for C out parameters') } + + // Captured while no Lua is running, so it is the top of the C stack in JS land. + this.mainStackPointer = this.stackSave() + this.useJspi = this.jspiSupported && this.asyncEngine !== 'yield' + if (this.useJspi) { + this.installAwaitHook() + } + } + + /** {@link promising}-wrapped `lua_resume`, built on first use and kept. */ + public promisingResume(): (L: LuaAddress, from: LuaAddress | null, narg: number, nres: number) => Promise { + this.promisingResumeFunction ??= this.promising(this.lua_resume as (...args: any[]) => LuaReturn) + return this.promisingResumeFunction + } + + /** + * Installs the single C await hook every JS function closure suspends through under JSPI. It + * awaits the promise the {@link pendingSuspend} slot was left holding, having first freed this + * run's slice of the shared linear-memory C stack so other runs can use it while parked, then + * restores that slice and marshals the settled value back before returning into Lua. + */ + private installAwaitHook(): void { + this.awaitHookPointer = this.emscripten.addFunction( + // The C signature passes the running coroutine, but it is always the one the await + // parked on, which the pendingSuspend marshallers already hold; nothing here needs it. + this.suspending(async (): Promise => { + const suspend = this.pendingSuspend + this.pendingSuspend = undefined + if (suspend === undefined) { + throw new Error('the JSPI await hook ran with no pending suspend') + } + + const stackPointer = this.stackSave() + const savedStack = this.heap.slice(stackPointer, this.mainStackPointer) + this.stackRestore(this.mainStackPointer) + + this.activeRun = undefined + this.stackCanSuspend = false + const pause = this.scheduleAsync() + // Attach a rejection handler before yielding to the event loop. + const settling = settleOrInterrupt(suspend.promise, suspend.signal, suspend.deadline) + if (pause) { + await pause + } + const outcome = await settling + + // The state was closed while parked: lua_close has freed the stack this would + // resume into, so stay suspended forever rather than resume into freed memory. The + // run itself was already rejected by its close listener. + if (suspend.isClosed()) { + await new Promise(() => undefined) + } + + // Back on this run's stack: put its frames back before touching Lua, and mark the + // stack promising again for any further await this resume reaches. + this.heap.set(savedStack, stackPointer) + this.stackRestore(stackPointer) + this.stackCanSuspend = true + this.activeRun = suspend.run + + if (outcome.interrupted) { + return suspend.onInterrupt(outcome.error) + } + // A fairness pause can outlast the deadline even when the promise settled first. + const interrupt = limitError(suspend.signal, suspend.deadline) + if (interrupt) { + return suspend.onInterrupt(interrupt) + } + return outcome.resolved ? suspend.onResolve(outcome.value) : suspend.onReject(outcome.error) + }), + 'ii', + ) + this.wasmoon_set_await_hook(this.awaitHookPointer) } /** @@ -943,6 +1104,34 @@ export default class LuaModule { this.emscripten.HEAPU32[pointer >>> 2] = value } + /** + * Whether this runtime can run Lua under JSPI, so an `:await()` can suspend the wasm stack + * anywhere instead of only at a coroutine boundary. Needs the VM support and a glue built with + * `SUPPORT_LONGJMP=wasm`, without which a suspend trap fires on the `invoke_*` JS trampolines. + */ + public readonly jspiSupported: boolean = + typeof (WebAssembly as { Suspending?: unknown }).Suspending === 'function' && + typeof (WebAssembly as { promising?: unknown }).promising === 'function' + + /** The current wasm stack pointer, saved so a suspended run's C frames can be restored. */ + public stackSave(): number { + return this.emscripten.stackSave() + } + + public stackRestore(pointer: number): void { + this.emscripten.stackRestore(pointer) + } + + /** Wraps a wasm export so calling it runs Lua on a JSPI stack that can suspend. */ + public promising any>(fn: T): (...args: Parameters) => Promise> { + return (WebAssembly as unknown as { promising: (fn: T) => (...args: Parameters) => Promise> }).promising(fn) + } + + /** Wraps a JS callback so a Lua import can suspend the JSPI stack while it awaits. */ + public suspending(fn: (...args: any[]) => any): any { + return new (WebAssembly as unknown as { Suspending: new (fn: (...args: any[]) => any) => unknown }).Suspending(fn) + } + /** * Puts a JS callback in the indirect function table and returns the pointer Lua calls it * through. Release it with {@link removeFunction}. diff --git a/src/native/wasmoon.c b/src/native/wasmoon.c new file mode 100644 index 0000000..3261468 --- /dev/null +++ b/src/native/wasmoon.c @@ -0,0 +1,58 @@ +#include "lua.h" + +/* + * Twins of the lua_Integer entry points that take a double where the originals take a 64 bit + * integer. lua_Integer crosses the wasm boundary as a BigInt, so pushing a plain JS number would + * otherwise convert to BigInt on every call; these let a number that is a safe integer skip that. + */ + +void wasmoon_pushinteger(lua_State *L, double n) { + lua_pushinteger(L, (lua_Integer)n); +} + +int wasmoon_geti(lua_State *L, int idx, double n) { + return lua_geti(L, idx, (lua_Integer)n); +} + +int wasmoon_rawgeti(lua_State *L, int idx, double n) { + return lua_rawgeti(L, idx, (lua_Integer)n); +} + +void wasmoon_seti(lua_State *L, int idx, double n) { + lua_seti(L, idx, (lua_Integer)n); +} + +void wasmoon_rawseti(lua_State *L, int idx, double n) { + lua_rawseti(L, idx, (lua_Integer)n); +} + +/* + * The closure every JS function is pushed as. Its first upvalue is the reference box the function + * extension reads; its second is the call hook, a lua_CFunction stored as light userdata. + * + * The call hook runs the JS function. When it returns -1 the JS side has stashed a promise and + * asked to suspend, and the await hook is reached from this wasm frame with no JS frame in between, + * which is what the JSPI engine needs to switch the stack. The await hook is a plain never + * suspending stub under the fallback engine, where the call hook never returns -1. + */ + +static lua_CFunction await_hook; + +static int wasmoon_jsfunction(lua_State *L) { + lua_CFunction call_hook = (lua_CFunction)lua_touserdata(L, lua_upvalueindex(2)); + int n = call_hook(L); + if (n == -1) { + n = await_hook(L); + } + return n; +} + +void wasmoon_set_await_hook(lua_CFunction hook) { + await_hook = hook; +} + +/* Pops the reference box already on the stack and pushes the closure over it and call_hook. */ +void wasmoon_push_jsfunction(lua_State *L, lua_CFunction call_hook) { + lua_pushlightuserdata(L, (void *)call_hook); + lua_pushcclosure(L, wasmoon_jsfunction, 2); +} diff --git a/src/state.ts b/src/state.ts index aaad746..efd3d6f 100755 --- a/src/state.ts +++ b/src/state.ts @@ -259,9 +259,15 @@ export default class LuaState extends Thread { } } - /** Notified once when this state closes, so an owner can drop its reference. */ - public onClose(listener: () => void): void { + /** Notified once when this state closes, so an owner can drop its reference. Returns a function that removes the listener. */ + public onClose(listener: () => void): () => void { this.closeListeners.push(listener) + return () => { + const index = this.closeListeners.indexOf(listener) + if (index >= 0) { + this.closeListeners.splice(index, 1) + } + } } /** Closes the state and frees everything it owns. Safe to call more than once. */ @@ -286,10 +292,10 @@ export default class LuaState extends Thread { wrapper.extension.close() } - for (const listener of this.closeListeners) { - listener() + // Listeners may unsubscribe themselves while rejecting a parked run. + while (this.closeListeners.length > 0) { + this.closeListeners.pop()!() } - this.closeListeners.length = 0 } /** Folds the state wide budget in, so run() does the save and restore in one place. */ diff --git a/src/thread.ts b/src/thread.ts index 1fdcf58..85d13ce 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -6,7 +6,6 @@ import { defaultWarnHandler, LUA_MULTRET, LUA_REGISTRYINDEX, - LuaAbortError, type LuaAddress, LuaError, LuaEventMasks, @@ -19,11 +18,13 @@ import { LuaReturn, type LuaRunOptions, type LuaThreadLimits, - LuaTimeoutError, LuaType, type LuaWarnHandler, + PointerSize, } from './types' -import { isEmscriptenUnwind, isPromise, yieldToEventLoop } from './utils' +import { isEmscriptenUnwind } from './utils' +import { limitError, type PendingAwait, settleOrInterrupt } from './async' +import RawResult from './raw-result' export interface OrderedExtension { // Bigger is more important @@ -61,17 +62,43 @@ export default class Thread { */ protected readonly metatableNames: Map private closed = false + private running = false + private rejectRun: (() => void) | undefined + private removeCloseListener: (() => void) | undefined private hookFunctionPointer: number | undefined private hookCount = INSTRUCTION_HOOK_COUNT private limits: LuaThreadLimits = {} private instructionsUsed = 0 /** * The error the debug hook unwound the current run with. See {@link LuaModule.interruptToken} - * for why it is held here rather than pushed into Lua. Kept on the root thread, because the - * hook fires with whichever thread Lua is running -- a coroutine inherits it -- while the - * {@link assertOk} that reports it is the one the run was started on. + * for why it is held here rather than pushed into Lua. The hook can fire on a Lua-created + * coroutine, so it writes to the active driver, which also owns its limits and lifecycle. */ private pendingInterrupt: LuaInterruptError | undefined + // Only the root owns a map. Callback wrappers and run threads share its records. + private awaits: Map | undefined + + public getPendingAwait(address = this.address): PendingAwait | undefined { + return this.rootThread.awaits?.get(address) + } + + public setPendingAwait(address: LuaAddress, pending: PendingAwait): void { + ;(this.rootThread.awaits ??= new Map()).set(address, pending) + } + + public clearPendingAwait(address = this.address): void { + this.rootThread.awaits?.delete(address) + } + + /** Whether the host run loop owns this coroutine's yielded values. */ + public get isRunning(): boolean { + return this.running + } + + /** The driver itself is the run context; no separate context allocation is needed. */ + public get runLimits(): Readonly { + return this.limits + } public constructor(cmodule: LuaModule, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { this.module = cmodule @@ -105,6 +132,7 @@ export default class Thread { public resetThread(): void { this.assertNotClosed() + this.clearPendingAwait() this.assertOk(this.module.lua_resetthread(this.address)) } @@ -126,17 +154,39 @@ export default class Thread { this.assertOk(this.module.luaL_loadfilex(this.address, filename, options?.mode ?? 't')) } + /** + * Unwinds the currently running coroutine at `L` with a limit error, the same way the debug + * hook does: the token identifies it in {@link assertOk}, so a `pcall` in the script cannot + * swallow it. Used by the JSPI await hook to abandon a run parked past its deadline or on an + * aborted signal. Returns what `lua_error` returns, which never actually returns. + */ + public interruptWith(error: LuaInterruptError): number { + ;(this.module.activeRun ?? this).pendingInterrupt = error + this.module.lua_pushlightuserdata(this.address, this.module.interruptToken) + return this.module.lua_error(this.address) + } + public resume(argCount = 0): LuaResumeResult { // Also covers the resumes `run` makes after an await, where the state can have been closed // by anything else that got to run in the meantime. this.assertNotClosed() - this.rootThread.pendingInterrupt = undefined + this.pendingInterrupt = undefined // The shared slot is safe for the same reason the one behind it is: C writes the count as it // returns and it is read straight after, with nothing interleaved. A nested resume has // finished with the slot by the time this one's lua_resume writes to it. const dataPointer = this.module.resultCountScratch this.module.writePointer(dataPointer, 0) - const luaResult = this.module.lua_resume(this.address, null, argCount, dataPointer) + const previousRun = this.module.activeRun + const previousCanSuspend = this.module.stackCanSuspend + this.module.activeRun = this + this.module.stackCanSuspend = false + let luaResult: LuaReturn + try { + luaResult = this.module.lua_resume(this.address, null, argCount, dataPointer) + } finally { + this.module.activeRun = previousRun + this.module.stackCanSuspend = previousCanSuspend + } return { result: luaResult, resultCount: this.module.readPointer(dataPointer), @@ -163,59 +213,280 @@ export default class Thread { public async run(argCount = 0, options?: LuaRunOptions): Promise { this.assertNotClosed() + if (this.running) { + throw new Error('the Lua thread is already running') + } const restore = this.applyRunOptions(options) + this.running = true try { - let resumeResult: LuaResumeResult = this.resume(argCount) - while (resumeResult.result === LuaReturn.Yield) { - // If it's completed there's no need to needlessly discard the output. The hook - // only fires while Lua runs, so a parked thread is checked here instead. - const limitError = this.checkYieldLimits() - if (limitError) { - if (resumeResult.resultCount > 0) { - this.pop(resumeResult.resultCount) - } - throw limitError + if (!this.module.useJspi) { + const first = this.resumeYielding(argCount) + if (first.result !== LuaReturn.Yield) { + this.assertOk(first.result) + return this.getStackValues() } - if (resumeResult.resultCount > 0) { - const lastValue = this.getValue(-1) - this.pop(resumeResult.resultCount) - - // If there's a result and it's a promise, then wait for it. - if (isPromise(lastValue)) { - await lastValue - } else { - // If it's a non-promise, then skip a tick to yield for promises, timers, etc. - await yieldToEventLoop() - } + return await this.watchRun(this.driveYielding(first, options)) + } + const nresPointer = this.module.emscripten._malloc(PointerSize) + if (!nresPointer) { + throw new Error('failed to allocate the JSPI result count slot') + } + try { + return await this.watchRun(this.runJspi(argCount, nresPointer, options)) + } finally { + this.module.emscripten._free(nresPointer) + } + } finally { + this.finishRun() + restore() + } + } + + /** One close listener and promise per asynchronous entry, rather than one race per resume. */ + private watchRun(pending: Promise): Promise { + return new Promise((resolve, reject) => { + const root = this.rootThread as unknown as { onClose(listener: () => void): () => void } + this.rejectRun = () => reject(new Error('the Lua state is closed')) + this.removeCloseListener = root.onClose(this.rejectRun) + pending.then(resolve, reject) + if (this.isClosed()) { + this.rejectRun() + } + }) + } + + private finishRun(): void { + this.removeCloseListener?.() + this.removeCloseListener = undefined + this.rejectRun = undefined + this.running = false + this.clearPendingAwait() + } + + /** + * Runs `fn` with suspension forced off, so an `:await()` it reaches parks by yielding the + * coroutine rather than suspending the wasm stack. The synchronous `lua_pcallk` entry points + * use this scope; resume inlines it to avoid allocating a closure on the callback hot path. + */ + private withSuspensionDisabled(fn: () => T): T { + const previousCanSuspend = this.module.stackCanSuspend + const previousRun = this.module.activeRun + this.module.stackCanSuspend = false + this.module.activeRun = this + try { + return fn() + } finally { + this.module.stackCanSuspend = previousCanSuspend + this.module.activeRun = previousRun + } + } + + /** A resume of the yielding loop, forced onto the yield path rather than a wasm suspension. */ + public resumeYielding(argCount = 0): LuaResumeResult { + return this.resume(argCount) + } + + /** + * Throws the pending limit error if this parked thread has exceeded its deadline or been + * aborted, dropping the yielded values first. The hook only fires while Lua runs, so a parked + * thread is checked here instead. + */ + private throwIfLimitReached(resumeResult: LuaResumeResult): void { + const error = this.checkYieldLimits() + if (error) { + if (resumeResult.resultCount > 0) { + this.pop(resumeResult.resultCount) + } + throw error + } + } + + /** + * Handles a host yield -- a top level `coroutine.yield` that is not an await -- by handing its + * values to `onYield` and returning how many values the resume passes back, or dropping them + * when there is no handler. + */ + private async handleHostYield(resumeResult: LuaResumeResult, options?: LuaRunOptions): Promise { + if (options?.onYield) { + const values = this.getStackValues(this.getTop() - resumeResult.resultCount) + this.pop(resumeResult.resultCount) + const outcome = await settleOrInterrupt(options.onYield(values), this.limits.signal, this.limits.deadline) + this.assertNotClosed() + const interrupt = this.checkYieldLimits() + if (interrupt) { + throw interrupt + } + if (outcome.interrupted) { + throw outcome.error + } + if (!outcome.resolved) { + throw outcome.error + } + return this.pushReturnValues(outcome.value) + } + if (resumeResult.resultCount > 0) { + this.pop(resumeResult.resultCount) + } + return 0 + } + + /** The yielding run loop, continued from an already obtained resume result. */ + public async continueYielding(first: LuaResumeResult, options?: LuaRunOptions): Promise { + this.assertNotClosed() + if (this.running) { + throw new Error('the Lua thread is already running') + } + this.running = true + try { + return await this.watchRun(this.driveYielding(first, options)) + } finally { + this.finishRun() + } + } + + private async driveYielding(first: LuaResumeResult, options?: LuaRunOptions): Promise { + let resumeResult: LuaResumeResult = first + while (resumeResult.result === LuaReturn.Yield) { + this.throwIfLimitReached(resumeResult) + + const awaited = this.getPendingAwait() + let nextArgCount = 0 + if (awaited) { + this.pop(resumeResult.resultCount) + if (this.limits.signal === undefined && this.limits.deadline === undefined) { + // The continuation's promise already records rejection, so the usual await + // needs no extra promise, reaction, or outcome object. + await awaited.promise } else { - // If there's nothing to yield, then skip a tick to yield for promises, timers, etc. - await yieldToEventLoop() + const outcome = await settleOrInterrupt(awaited.promise, this.limits.signal, this.limits.deadline) + if (outcome.interrupted) { + throw outcome.error + } } + } else { + nextArgCount = await this.handleHostYield(resumeResult, options) + } + const pause = this.module.scheduleAsync() + if (pause) { + await pause + } - // The wait itself can outlast the deadline, and resuming would hand Lua another - // full slice before the hook noticed. - const waitError = this.checkYieldLimits() - if (waitError) { - throw waitError - } + // The wait itself can outlast the deadline, and resuming would hand Lua another full + // slice before the hook noticed. + const waitError = this.checkYieldLimits() + if (waitError) { + throw waitError + } - resumeResult = this.resume(0) + resumeResult = this.resumeYielding(nextArgCount) + } + + this.assertOk(resumeResult.result) + return this.getStackValues() + } + + /** + * Runs under JSPI, so an `:await()` suspends the wasm stack instead of yielding a promise. Only + * a host `coroutine.yield` returns control here, and it is handled the same as in the yielding + * engine; the awaits resolve inside the promising resume without the loop seeing them. + */ + private async runJspi(argCount: number, nresPointer: number, options?: LuaRunOptions): Promise { + const module = this.module + let resumeResult = await this.resumeJspi(argCount, nresPointer) + while (resumeResult.result === LuaReturn.Yield) { + this.throwIfLimitReached(resumeResult) + const nextArgCount = await this.handleHostYield(resumeResult, options) + const pause = module.scheduleAsync() + if (pause) { + await pause + } + const interrupt = this.checkYieldLimits() + if (interrupt) { + throw interrupt } + resumeResult = await this.resumeJspi(nextArgCount, nresPointer) + } + this.assertOk(resumeResult.result) + // A parked run interrupted by its deadline or signal raises the interrupt into Lua, so a + // pcall in the script could swallow it and let the run finish. As with the debug hook, + // the interrupt still ends the run rather than being catchable from Lua. + const interrupt = this.pendingInterrupt + if (interrupt) { + this.pendingInterrupt = undefined + throw interrupt + } + return this.getStackValues() + } - this.assertOk(resumeResult.result) - return this.getStackValues() + /** + * One promising `lua_resume`. The promise it returns resolves only once the resume runs to a + * host yield, a return or an error -- an `:await()` in between suspends and resumes the wasm + * stack invisibly. The stack pointer is put back after the synchronous portion so a sync call + * made while this run is parked reuses the C stack rather than growing past it. + */ + private async resumeJspi(argCount: number, nresPointer: number): Promise { + this.assertNotClosed() + this.pendingInterrupt = undefined + const module = this.module + const previousCanSuspend = module.stackCanSuspend + const previousRun = module.activeRun + module.stackCanSuspend = true + module.activeRun = this + const entryStackPointer = module.stackSave() + module.writePointer(nresPointer, 0) + try { + let pending: Promise + try { + pending = module.promisingResume()(this.address, null, argCount, nresPointer) + } finally { + // Restore the caller immediately, not when this suspended run eventually settles. + module.activeRun = previousRun + module.stackCanSuspend = previousCanSuspend + module.stackRestore(entryStackPointer) + } + const result: LuaReturn = await pending + this.assertNotClosed() + return { result, resultCount: module.readPointer(nresPointer) } } finally { - restore() + if (module.activeRun === this) { + module.activeRun = undefined + module.stackCanSuspend = false + } + module.stackRestore(entryStackPointer) } } + /** + * Pushes a JS value onto the stack as Lua results and returns how many: nothing for `undefined`, + * `count` for a {@link RawResult}, one per element for a {@link MultiReturn}, else the single + * value. Shared by the run loops and the JS function wrapper. + */ + public pushReturnValues(value: unknown): number { + if (value === undefined) { + return 0 + } + if (value instanceof RawResult) { + return value.count + } + if (value instanceof MultiReturn) { + for (const item of value) { + this.pushValue(item) + } + return value.length + } + this.pushValue(value) + return 1 + } + public runSync(argCount = 0, options?: LuaRunOptions): MultiReturn { this.assertNotClosed() - this.rootThread.pendingInterrupt = undefined + this.pendingInterrupt = undefined const restore = this.applyRunOptions(options) try { const base = this.getTop() - argCount - 1 // The 1 is for the function to run - this.assertOk(this.module.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null)) + // A synchronous call: an `:await()` reached through it must yield rather than suspend, + // and then fail because a pcalled thread cannot yield. + this.withSuspensionDisabled(() => this.assertOk(this.module.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null))) return this.getStackValues(base) } finally { restore() @@ -228,7 +499,7 @@ export default class Thread { public call(name: string, ...args: any[]): MultiReturn { this.assertNotClosed() - this.rootThread.pendingInterrupt = undefined + this.pendingInterrupt = undefined const type = this.module.lua_getglobal(this.address, name) if (type !== LuaType.Function) { throw new TypeError(`cannot call '${name}': expected a function, got ${LuaType[type]}`) @@ -239,7 +510,7 @@ export default class Thread { } const base = this.getTop() - args.length - 1 // The 1 is for the function to run - this.assertOk(this.module.lua_pcallk(this.address, args.length, LUA_MULTRET, 0, 0, null)) + this.withSuspensionDisabled(() => this.assertOk(this.module.lua_pcallk(this.address, args.length, LUA_MULTRET, 0, 0, null))) return this.getStackValues(base) } @@ -255,6 +526,10 @@ export default class Thread { } public stateToThread(L: LuaAddress): Thread { + const active = this.module.activeRun + if (active?.address === L) { + return active + } if (L === this.address) { return this } @@ -421,12 +696,17 @@ export default class Thread { return } + this.clearPendingAwait() + if (this === this.rootThread) { + this.awaits?.clear() + } if (this.hookFunctionPointer) { this.module.removeFunction(this.hookFunctionPointer) this.hookFunctionPointer = undefined } this.closed = true + this.rejectRun?.() } public [Symbol.dispose](): void { @@ -607,8 +887,7 @@ export default class Thread { * token nowhere on the stack, so whatever error did surface is still reported as itself. */ private takePendingInterrupt(stackTop: number): LuaInterruptError | undefined { - const root = this.rootThread - if (root.pendingInterrupt === undefined || stackTop === 0) { + if (this.pendingInterrupt === undefined || stackTop === 0) { return undefined } // The token is a heap address the module never hands to Lua, so nothing else can be at it. @@ -616,8 +895,8 @@ export default class Thread { return undefined } - const interrupt = root.pendingInterrupt - root.pendingInterrupt = undefined + const interrupt = this.pendingInterrupt + this.pendingInterrupt = undefined return interrupt } @@ -668,9 +947,9 @@ export default class Thread { this.hookFunctionPointer = this.module.addFunction((hookL: LuaAddress): void => { // Reads this.limits rather than closing over them, so a hook allocated for an // earlier configuration still honours the current one. - const error = this.checkHookLimits(hookL) + const error = (this.module.activeRun ?? this).checkHookLimits(hookL) if (error) { - this.rootThread.pendingInterrupt = error + ;(this.module.activeRun ?? this).pendingInterrupt = error this.module.lua_pushlightuserdata(hookL, this.module.interruptToken) this.module.lua_error(hookL) } @@ -697,14 +976,7 @@ export default class Thread { } private checkYieldLimits(): LuaInterruptError | undefined { - const { deadline, signal } = this.limits - if (signal?.aborted) { - return new LuaAbortError('thread aborted') - } - if (deadline !== undefined && Date.now() > deadline) { - return new LuaTimeoutError('thread timeout exceeded') - } - return undefined + return limitError(this.limits.signal, this.limits.deadline) } /** diff --git a/src/type-extension.ts b/src/type-extension.ts index 9b4af45..c8a1c6d 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -1,7 +1,7 @@ import type { Decoration } from './decoration' import type LuaState from './state' import type Thread from './thread' -import { LUA_REGISTRYINDEX, type LuaGetCache, type LuaPushCache, LuaType } from './types' +import { type LuaAddress, LUA_REGISTRYINDEX, type LuaGetCache, type LuaPushCache, LuaType } from './types' export default abstract class LuaTypeExtension { // Type name, for metatables and lookups. @@ -107,10 +107,10 @@ export default abstract class LuaTypeExtension { * in Lua. With `closure`, a C function pointer, what is pushed and cached is instead a closure * over the box, which it sees as its first upvalue. */ - protected pushReference(thread: Thread, referent: unknown, closure?: number): void { + protected pushReference(thread: Thread, referent: unknown, wrapClosure?: (L: LuaAddress) => void): void { const module = thread.module const L = thread.address - const cachedType = closure === undefined ? LuaType.Userdata : LuaType.Function + const cachedType = wrapClosure === undefined ? LuaType.Userdata : LuaType.Function // The cache table stays at the bottom for the whole push, so the probe and the store // below share the one registry fetch. @@ -140,9 +140,9 @@ export default abstract class LuaTypeExtension { // -1 is the metatable, -2 is the box. module.lua_setmetatable(L, -2) - if (closure !== undefined) { - // Pops the box and pushes the closure holding it as an upvalue. - module.lua_pushcclosure(L, closure, 1) + if (wrapClosure !== undefined) { + // Pops the box and pushes a closure holding it as an upvalue. + wrapClosure(L) } // Remember the value for the next push of the same referent, then drop the cache table diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index e78fe3d..1b82b3c 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -1,11 +1,10 @@ import { Decoration, type DecorationOptions } from '../decoration' import type LuaState from '../state' -import MultiReturn from '../multireturn' -import RawResult from '../raw-result' import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LUA_REGISTRYINDEX, LuaReturn, type LuaAddress, LuaType } from '../types' +import { LUA_REGISTRYINDEX, LuaReturn, type LuaAddress, type LuaResumeResult, LuaType } from '../types' import { isEmscriptenUnwind } from '../utils' +import { SUSPEND } from '../async' export type FunctionType = (...args: any[]) => Promise | any @@ -33,6 +32,9 @@ class FunctionTypeExtension extends TypeExtension { */ private readonly pooledCallThread: Thread private pooledCallThreadInUse = false + /** Registry reference anchoring each in-flight non-pooled call thread, so async calls that + * overlap can be released in any order rather than only as a stack. */ + private readonly callThreadReferences = new Map() /** Milliseconds a Lua function called from JS may run before being interrupted. */ private readonly functionTimeout: number | undefined @@ -96,19 +98,12 @@ class FunctionTypeExtension extends TypeExtension { try { const result = target.apply(decorationOptions?.self, args) - if (result === undefined) { - return 0 - } else if (result instanceof RawResult) { - return result.count - } else if (result instanceof MultiReturn) { - for (const item of result) { - calledThread.pushValue(item) - } - return result.length - } else { - calledThread.pushValue(result) - return 1 + if (result === SUSPEND) { + // The JS function stashed a promise and asked to suspend: -1 tells the C + // trampoline to reach the await hook, which is the only value it treats specially. + return -1 } + return calledThread.pushReturnValues(result) } catch (err) { if (isEmscriptenUnwind(err)) { throw err @@ -129,7 +124,11 @@ class FunctionTypeExtension extends TypeExtension { private acquireCallThread(): Thread { if (this.pooledCallThreadInUse) { - return this.callbackContext.newThread() + // Anchored rather than left on the callback context's stack: an async call keeps its + // thread past the return, so overlapping ones must release independently, not as a stack. + const { thread, reference } = this.callbackContext.newAnchoredThread() + this.callThreadReferences.set(thread, reference) + return thread } this.pooledCallThreadInUse = true @@ -137,10 +136,19 @@ class FunctionTypeExtension extends TypeExtension { } private releaseCallThread(callThread: Thread, failed: boolean): void { + callThread.clearPendingAwait() + if (this.state.isClosed()) { + this.callThreadReferences.delete(callThread) + this.pooledCallThreadInUse = false + return + } if (callThread !== this.pooledCallThread) { callThread.close() - // Pop thread used for function call. - this.callbackContext.pop() + const reference = this.callThreadReferences.get(callThread) + if (reference !== undefined) { + this.callThreadReferences.delete(callThread) + this.state.module.luaL_unref(this.callbackContext.address, LUA_REGISTRYINDEX, reference) + } return } @@ -179,7 +187,10 @@ class FunctionTypeExtension extends TypeExtension { // the same function under different options stays distinct, as its behaviour is. const referent: unknown = affectsCall(decoration.options) ? decoration : decoration.target - this.pushReference(thread, referent, this.functionWrapper) + // Pushed through the C trampoline rather than a bare closure, so an `:await()` inside the + // function can suspend the wasm stack under JSPI. The trampoline holds the reference box as + // its first upvalue and the wrapper as its second. + this.pushReference(thread, referent, (L) => this.state.module.wasmoon_push_jsfunction(L, this.functionWrapper)) return true } @@ -204,6 +215,7 @@ class FunctionTypeExtension extends TypeExtension { // A call can leave its thread in an inconsistent state, so each one gets a thread that // is either fresh or has been reset since the last call. const callThread = this.acquireCallThread() + let handedOff = false let failed = false try { const internalType = callThread.module.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, func) @@ -223,19 +235,23 @@ class FunctionTypeExtension extends TypeExtension { callThread.setDeadline(Date.now() + this.functionTimeout) } - const status = callThread.module.lua_pcallk(callThread.address, args.length, 1, 0, 0, null) - if (status === LuaReturn.Yield) { - throw new Error('cannot yield in callbacks from javascript') + // Run on the coroutine rather than through lua_pcallk, so an `:await()` reached + // inside can park by yielding. If it does, the call has become asynchronous and a + // promise is returned; the common case runs to completion in this one resume. + const first = callThread.resumeYielding(args.length) + if (first.result === LuaReturn.Yield) { + handedOff = true + return this.finishAsyncCall(callThread, first) } - callThread.assertOk(status) - - // Asking for one result leaves exactly one, nil included, so the top is where it is. - return callThread.getValue(1) + callThread.assertOk(first.result) + return callThread.getTop() >= 1 ? callThread.getValue(1) : null } catch (err) { failed = true throw err } finally { - this.releaseCallThread(callThread, failed) + if (!handedOff) { + this.releaseCallThread(callThread, failed) + } } } @@ -243,6 +259,20 @@ class FunctionTypeExtension extends TypeExtension { return jsFunc } + + /** Drives a callback that parked on an await to completion, resolving to its first result. */ + private async finishAsyncCall(callThread: Thread, first: LuaResumeResult): Promise { + let failed = false + try { + const values = await callThread.continueYielding(first) + return values.length >= 1 ? values[0] : null + } catch (err) { + failed = true + throw err + } finally { + this.releaseCallThread(callThread, failed) + } + } } export default function createTypeExtension(state: LuaState, functionTimeout?: number): TypeExtension { diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index 5d5e81c..d2f5e53 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -6,11 +6,7 @@ import type Thread from '../thread' import TypeExtension from '../type-extension' import type { LuaAddress } from '../types' import { isPromise } from '../utils' - -/** The half of an in flight `:await()` the continuation needs once the promise has settled. */ -interface PendingAwait { - result: { status: 'fulfilled' | 'rejected'; value: any } | undefined -} +import { SUSPEND, type PendingAwait } from '../async' /** * A bare thenable reaches here too, and only `then` is guaranteed on one. Adopting it into a real @@ -25,11 +21,6 @@ const asPromise = (self: unknown): Promise => { } class PromiseTypeExtension extends TypeExtension> { - /** - * Keyed by the address of the thread parked in the await. A thread suspended in `lua_yieldk` - * cannot reach another `:await()`, so at most one is ever in flight per thread. - */ - private readonly pendingAwaits = new Map() /** * One continuation for every await on this state. Building one per await meant compiling and * instantiating a wasm trampoline each time, and leaked the table slot whenever the coroutine @@ -41,7 +32,7 @@ class PromiseTypeExtension extends TypeExtension> { super(state, 'js_promise') this.continuancePointer = state.module.addFunction((continuanceState: LuaAddress): number => { - const pending = this.pendingAwaits.get(continuanceState) + const pending = state.getPendingAwait(continuanceState) if (!pending) { // Nothing sensible is left to resume with, and returning would hand Lua a stack it // does not expect. @@ -59,25 +50,13 @@ class PromiseTypeExtension extends TypeExtension> { } const { status, value } = pending.result - this.pendingAwaits.delete(continuanceState) + state.clearPendingAwait(continuanceState) const continuanceThread = state.stateToThread(continuanceState) if (status === 'rejected') { - continuanceThread.pushValue(value || new Error('promise rejected with no error')) - return state.module.lua_error(continuanceState) - } - - if (value instanceof RawResult) { - return value.count - } else if (value instanceof MultiReturn) { - for (const arg of value) { - continuanceThread.pushValue(arg) - } - return value.length - } else { - continuanceThread.pushValue(value) - return 1 + return this.marshalRejected(continuanceThread, value) } + return this.marshalResolved(continuanceThread, value) }, 'iiii') this.defineMetatable({ @@ -90,29 +69,49 @@ class PromiseTypeExtension extends TypeExtension> { await: decorate( (functionThread: Thread, rawSelf: unknown) => { const self = asPromise(rawSelf) + const module = state.module + + // Under JSPI an await can suspend the wasm stack from anywhere, including a + // C-call boundary a yield could not cross, so long as the run reached here + // through a promising resume rather than a synchronous entry point (which + // `stackCanSuspend` is exactly true for). + if (module.useJspi && module.stackCanSuspend) { + return this.suspend(functionThread, self) + } - // Asking Lua covers every non-resumable context, not just the main - // thread: anything entered through lua_pcall cannot yield either. - if (!state.module.lua_isyieldable(functionThread.address)) { - throw new Error('cannot await in a thread that cannot yield, use doString instead of doStringSync') + // Otherwise it can only park by yielding the coroutine, which a thread + // entered through lua_pcall (doStringSync, a JS→Lua callback) cannot do. + if (!module.lua_isyieldable(functionThread.address)) { + throw new Error( + module.useJspi + ? 'cannot await here: a synchronous call is on the stack, use doString instead of doStringSync' + : 'cannot await across a C-call boundary without JSPI; run this through doString', + ) } - const pending: PendingAwait = { result: undefined } - this.pendingAwaits.set(functionThread.address, pending) - - const awaitPromise = self - .then((res) => { - pending.result = { status: 'fulfilled', value: res } - return res - }) - .catch((err) => { - pending.result = { status: 'rejected', value: err } - }) - - // 1 result, because the yield hands the promise reference back so the - // resume that follows can wait on it. - functionThread.pushValue(awaitPromise) - return new RawResult(state.module.lua_yieldk(functionThread.address, 1, 0, this.continuancePointer)) + const pending: PendingAwait = { + result: undefined, + promise: self.then( + (value) => { + pending.result = { status: 'fulfilled', value } + return value + }, + (value) => { + pending.result = { status: 'rejected', value } + }, + ), + } + state.setPendingAwait(functionThread.address, pending) + + // Host-driven awaits are registered out of band: no promise userdata or + // Lua stack value is needed. A manually resumed coroutine still receives + // its promise, preserving the low-level coroutine.resume contract. + let resultCount = 0 + if (!functionThread.isRunning) { + functionThread.pushValue(pending.promise) + resultCount = 1 + } + return new RawResult(module.lua_yieldk(functionThread.address, resultCount, 0, this.continuancePointer)) }, { receiveThread: true }, ), @@ -139,10 +138,6 @@ class PromiseTypeExtension extends TypeExtension> { public override close(): void { super.close() this.state.module.removeFunction(this.continuancePointer) - // A coroutine abandoned mid await never reaches its continuation, so its record is still - // here holding whatever the promise settled with. Nothing tells us when Lua's own GC took - // that coroutine, so these are bounded by the state's lifetime rather than the await's. - this.pendingAwaits.clear() } public pushValue(thread: Thread, decoration: Decoration): boolean { @@ -151,6 +146,52 @@ class PromiseTypeExtension extends TypeExtension> { } return super.pushValue(thread, decoration) } + + /** + * The JSPI await. It hands the promise and how to marshal its result to the module's await + * hook, then returns the sentinel that makes the C trampoline reach that hook and suspend the + * wasm stack. The stack unwinds to the promising resume driving the run and resumes there once + * the promise settles, so nothing here yields the Lua coroutine. + */ + private suspend(thread: Thread, promise: Promise): typeof SUSPEND { + // Captured now so a deadline or abort observed while parked interrupts the run rather than + // waiting for the promise. Read from the resuming run, not this thread, whose JS wrapper is + // often a fresh object without the run's limits on it. + const run = this.state.module.activeRun! + const { deadline, signal } = run.runLimits + this.state.module.pendingSuspend = { + run, + promise, + signal, + deadline, + isClosed: () => run.isClosed(), + onResolve: (value: unknown) => this.marshalResolved(thread, value), + onReject: (error: unknown) => this.marshalRejected(thread, error), + onInterrupt: (error) => thread.interruptWith(error), + } + return SUSPEND + } + + /** Pushes a settled promise value as Lua results and returns how many; `undefined` becomes nil. */ + private marshalResolved(thread: Thread, value: unknown): number { + if (value instanceof RawResult) { + return value.count + } + if (value instanceof MultiReturn) { + for (const item of value) { + thread.pushValue(item) + } + return value.length + } + thread.pushValue(value) + return 1 + } + + /** Raises a rejected promise as a Lua error on `thread`. */ + private marshalRejected(thread: Thread, error: unknown): number { + thread.pushValue(error || new Error('promise rejected with no error')) + return this.state.module.lua_error(thread.address) + } } export default function createTypeExtension(state: LuaState, injectObject: boolean): TypeExtension> { diff --git a/src/types.ts b/src/types.ts index d733d9a..f1222ba 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type MultiReturn from './multireturn' + /** * An address in the wasm heap: a `lua_State`, or the storage behind a Lua value. Emscripten * function pointers are table indices rather than addresses, so those stay plain numbers. @@ -123,13 +125,19 @@ export interface LuaRunOptions { /** * Interrupts the run when the signal aborts. * - * The abort is observed at the debug hook and around every yield, which has two consequences. - * It cannot fire while the event loop is blocked, so a signal aborted from a timer will not - * interrupt a tight synchronous Lua loop; use `timeout` or `maxInstructions` for those, since - * the hook evaluates them on its own. And a run parked on a promise finishes awaiting that - * promise before the abort is seen, rather than abandoning it mid-flight. + * Interrupts parked awaits and onYield handlers without waiting for their promises to settle. + * A signal cannot fire while the event loop is blocked, so a timer cannot abort a tight Lua + * loop without async steps; use `timeout` or `maxInstructions` for those. Repeated awaits and + * host yields periodically give timers a turn. */ signal?: AbortSignal | undefined + /** + * Called with the values of a top level `coroutine.yield` that is not an `:await()`. Its return + * becomes the result of that yield when the run resumes; a returned promise is awaited first, a + * `LuaMultiReturn` becomes several values, and `undefined` resumes with none. Without a handler + * such a yield resumes with nothing. Both engines periodically yield to the event loop. + */ + onYield?: ((values: MultiReturn) => unknown | Promise) | undefined } export interface LuaLoadOptions { diff --git a/src/utils.ts b/src/utils.ts index 1e97d47..9cb46f8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,21 +13,14 @@ export const isPromise = (target: unknown): target is PromiseLike => { export const UNWIND_BRAND = '__emscriptenUnwind' /** - * Emscripten unwinds a Lua longjmp by throwing from its internal EmscriptenEH hierarchy. The wasm - * caller gates on `instanceof EmscriptenEH` to resume unwinding, so these have to be rethrown - * rather than raised as Lua errors. The class is module local, hence the brand. + * A Lua longjmp out of a C function that a JS callback is on the stack of, which has to be rethrown + * rather than turned into a Lua error. With `SUPPORT_LONGJMP=wasm` it is a `WebAssembly.Exception` + * the wasm caller resumes unwinding from; the branded `EmscriptenEH` covers the other unwinds + * Emscripten still throws from JS (its class is module local, hence the brand). */ export const isEmscriptenUnwind = (value: unknown): boolean => { + if (typeof WebAssembly.Exception === 'function' && value instanceof WebAssembly.Exception) { + return true + } return (value as Record | null | undefined)?.[UNWIND_BRAND] === true } - -// Browsers have no setImmediate. The 4ms clamp on nested timers is acceptable here. -const scheduleMacrotask = typeof setImmediate === 'function' ? setImmediate : (task: () => void) => setTimeout(task, 0) - -/** - * A macrotask, so pending promise callbacks *and* timers get a chance to run before Lua is - * resumed. A microtask would starve timer driven code such as setTimeout based sleeps. - */ -export const yieldToEventLoop = (): Promise => { - return new Promise((resolve) => scheduleMacrotask(() => resolve())) -} diff --git a/test/async.test.js b/test/async.test.js new file mode 100644 index 0000000..d9b6317 --- /dev/null +++ b/test/async.test.js @@ -0,0 +1,368 @@ +import { execFileSync } from 'node:child_process' +import { use } from 'chai' +import chaiAsPromised from 'chai-as-promised' + +use(chaiAsPromised) +import { expect } from 'chai' +import { LuaRuntime, LuaTimeoutError, LuaAbortError, LuaMultiReturn } from '../dist/index.js' +import { getState } from './utils.js' + +describe('Async engine', () => { + it('a top level coroutine.yield hands its values to onYield and resumes with its return', async () => { + using state = await getState() + const thread = state.newThread() + thread.loadString('local echoed = coroutine.yield(1, 2) return echoed') + + const seen = [] + const result = await thread.run(0, { + onYield: (values) => { + seen.push([...values]) + return values[0] + values[1] + }, + }) + + expect(seen).to.be.eql([[1, 2]]) + expect(result).to.be.eql([3]) + }) + + it('onYield can resume with several values', async () => { + using state = await getState() + const thread = state.newThread() + thread.loadString('local a, b = coroutine.yield() return a + b') + + const result = await thread.run(0, { onYield: () => LuaMultiReturn.of(4, 5) }) + expect(result).to.be.eql([9]) + }) + + it('an unrepresentable top level yield no longer crashes the run', async () => { + using state = await getState() + const result = await state.doString('coroutine.yield(io.stdout) return 7') + expect(result).to.be.equal(7) + }) + + it('a timeout interrupts a run parked on a promise promptly', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + + const started = Date.now() + await expect(state.doString('sleep(1000):await() return 1', { timeout: 10 })).to.eventually.be.rejectedWith(LuaTimeoutError) + expect(Date.now() - started, 'interrupted well before the promise settles').to.be.below(500) + }) + + it('an abort signal interrupts a parked run promptly', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + const controller = new AbortController() + setTimeout(() => controller.abort(), 10) + + const started = Date.now() + await expect(state.doString('sleep(1000):await() return 1', { signal: controller.signal })).to.eventually.be.rejectedWith( + LuaAbortError, + ) + expect(Date.now() - started).to.be.below(500) + }) + + it('interrupting a parked await is catchable by pcall on a fresh run', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + + // The interrupt unwinds like a limit error: a pcall inside the script cannot swallow it. + await expect( + state.doString('local ok = pcall(function() sleep(1000):await() end) return ok', { timeout: 10 }), + ).to.eventually.be.rejectedWith(LuaTimeoutError) + }) + + describe('across a C-call boundary', () => { + const itJspi = (name, fn) => { + it(name, async function () { + using state = await getState() + if (!state.module.useJspi) { + this.skip() + } + await fn(state) + }) + } + + itJspi('awaits inside a table.sort comparator', async (state) => { + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))) + const result = await state.doString(` + local t = {3, 1, 2} + table.sort(t, function(a, b) sleep(1):await() return a < b end) + return table.concat(t, ",") + `) + expect(result).to.be.equal('1,2,3') + }) + + itJspi('awaits inside a gsub callback', async (state) => { + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))) + const result = await state.doString(`return (("abc"):gsub(".", function(c) sleep(1):await() return c:upper() end))`) + expect(result).to.be.equal('ABC') + }) + + itJspi('awaits inside a promise:next callback', async (state) => { + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))) + const result = await state.doString(` + return sleep(1):next(function() sleep(1):await() return 15 end):await() + `) + expect(result).to.be.equal(15) + }) + + itJspi('awaits inside a coroutine nothing drives from the host', async (state) => { + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))) + const result = await state.doString(` + local co = coroutine.wrap(function() return sleep(1):await() + 1 end) + return co() + `) + expect(result).to.be.equal(2) + }) + }) + + it('a state closed while a JSPI run is parked rejects rather than resuming into freed memory', async () => { + using state = await getState() + if (!state.module.useJspi) { + return + } + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + + const running = state.doString('sleep(20):await() return 1') + state.close() + await expect(running).to.eventually.be.rejectedWith('the Lua state is closed') + }) +}) + +// Run with WASMOON_ASYNC=yield and WASMOON_ASYNC=jspi; the same contract must hold under both. +describe('Async run isolation', () => { + it('preserves automatic engine selection', async () => { + await using runtime = await LuaRuntime.load() + expect(runtime.module.useJspi).to.equal(runtime.module.jspiSupported) + }) + + it('keeps Node alive until queued continuations finish, then exits', () => { + const entry = new URL('../dist/index.js', import.meta.url).href + const stdout = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import { LuaRuntime } from ${JSON.stringify(entry)} + const runtime = await LuaRuntime.load({ async: 'yield' }) + const state = runtime.createState() + console.log(await state.doString('for i=1,1000 do coroutine.yield() end return 42')) + state.close() + `, + ], + { encoding: 'utf8', timeout: 5000 }, + ) + expect(stdout.trim()).to.equal('42') + }) + + it('a yielded promise is a host value, not an internal await', async () => { + using state = await getState() + const promise = new Promise(() => {}) + state.set('value', promise) + let seen + expect( + await state.doString('return coroutine.yield(1, value)', { + onYield: (values) => { + seen = values + return 42 + }, + }), + ).to.equal(42) + expect([...seen]).to.eql([1, promise]) + }) + + it('keeps host yield results off the stack between resumes', async () => { + using state = await getState() + expect( + await state.doString( + ` + for i = 1, 1000 do + local a, b = coroutine.yield(i, i + 1) + assert(a == i * 2 and b == i * 3) + end + return 42 + `, + { onYield: ([i]) => LuaMultiReturn.of(i * 2, i * 3) }, + ), + ).to.equal(42) + }) + + for (const expression of ['ready:await()', 'coroutine.yield()']) { + it(`lets timers run during repeated ${expression}`, async () => { + using state = await getState() + state.set('ready', Promise.resolve()) + let fired = false + state.set('fired', () => fired) + const timer = setTimeout(() => { + fired = true + }, 0) + try { + expect( + await state.doString(` + for i = 1, 10000 do + ${expression} + if fired() then return true end + end + return false + `), + ).to.equal(true) + } finally { + clearTimeout(timer) + } + }) + } + + it('keeps a deadline through a second await while another run is parked', async () => { + using state = await getState() + let releaseFirst, releaseOther + state.set( + 'first', + new Promise((resolve) => { + releaseFirst = resolve + }), + ) + state.set( + 'other', + new Promise((resolve) => { + releaseOther = resolve + }), + ) + state.set('never', new Promise(() => {})) + const timed = state.doString('first:await() never:await()', { timeout: 30 }) + const checked = expect(timed).to.eventually.be.rejectedWith(LuaTimeoutError) + const other = state.doString('other:await() return 42') + releaseFirst() + await checked + releaseOther() + expect(await other).to.equal(42) + }) + + it('keeps abort signals isolated across states sharing a module', async () => { + using state = await getState() + using other = new state.constructor(state.module) + let releaseFirst, reachedSecond + state.set( + 'first', + new Promise((resolve) => { + releaseFirst = resolve + }), + ) + state.set('never', new Promise(() => {})) + const second = new Promise((resolve) => { + reachedSecond = resolve + }) + state.set('second', reachedSecond) + const controller = new AbortController() + const checked = expect( + state.doString('first:await() second() never:await()', { + signal: controller.signal, + }), + ).to.eventually.be.rejectedWith(LuaAbortError) + const otherRun = other.doString('coroutine.yield() return 7') + releaseFirst() + await second + controller.abort() + await checked + expect(await otherRun).to.equal(7) + }) + + it('does not lose a caught interrupt when Lua starts another run', async () => { + using state = await getState() + if (!state.module.useJspi) return + state.set('never', new Promise(() => {})) + let nested + state.set('start', () => { + nested = state.doString('return 7') + }) + await expect( + state.doString( + ` + pcall(function() never:await() end) + start() + return 42 + `, + { timeout: 10 }, + ), + ).to.eventually.be.rejectedWith(LuaTimeoutError) + expect(await nested).to.equal(7) + }) + + it('interrupts a parked onYield handler', async () => { + using state = await getState() + await expect( + state.doString('coroutine.yield() return 42', { + timeout: 10, + onYield: () => new Promise(() => {}), + }), + ).to.eventually.be.rejectedWith(LuaTimeoutError) + }) + + it('does not overflow a long deadline while parked', async () => { + using state = await getState() + state.set('delayed', new Promise((resolve) => setTimeout(() => resolve(42), 10))) + expect(await state.doString('return delayed:await()', { timeout: 0x80000000 })).to.equal(42) + }) + + it('can abort a loop of immediately settled awaits', async () => { + using state = await getState() + state.set('ready', Promise.resolve()) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 0) + try { + await expect( + state.doString('while true do ready:await() end', { + signal: controller.signal, + timeout: 1000, + }), + ).to.eventually.be.rejectedWith(LuaAbortError) + } finally { + clearTimeout(timer) + } + }) + + it('rejects all parked runs and callbacks when the state closes', async () => { + using state = await getState() + state.set('never', new Promise(() => {})) + state.doStringSync('function callback() never:await() end') + const runs = [ + state.doString('never:await()'), + state.doString('never:await()'), + state.get('callback')(), + state.get('callback')(), + state.doString('coroutine.yield()', { onYield: () => new Promise(() => {}) }), + ] + const checked = runs.map((run) => expect(run).to.eventually.be.rejectedWith('the Lua state is closed')) + state.close() + await Promise.all(checked) + }) + + it('rejects a second run on an already running thread', async () => { + using state = await getState() + state.set('never', new Promise(() => {})) + const thread = state.newThread() + thread.loadString('never:await()') + const first = thread.run() + const checked = expect(first).to.eventually.be.rejectedWith('the Lua state is closed') + await expect(thread.run()).to.eventually.be.rejectedWith('already running') + thread.close() + await checked + }) + + it('can interleave many runs with different suspension depths and settlement orders', async () => { + using state = await getState() + state.set('pause', () => Promise.resolve()) + const runs = Array.from({ length: 32 }, (_, i) => + state.doString(` + local function recurse(n) + if n > 0 then return n + recurse(n - 1) end + for j = 1, 50 do pause():await() end + return ${i} + end + return recurse(${i}) + `), + ) + expect(await Promise.all(runs)).to.eql(Array.from({ length: 32 }, (_, i) => i + (i * (i + 1)) / 2)) + }) +}) diff --git a/test/browser.test.js b/test/browser.test.js index df3a134..74e9766 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -134,6 +134,36 @@ describe('Browser environment', () => { } } + for (const engine of ['yield', 'jspi']) { + it(`isolates async runs and lets timers run under ${engine}`, async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const runtime = await LuaRuntime.load({ wasmFile, async: '${engine}' }) + const state = runtime.createState() + try { + let fired = false + state.set('ready', Promise.resolve()) + state.set('fired', () => fired) + setTimeout(() => { fired = true }, 0) + const fair = await state.doString('for i=1,10000 do ready:await() if fired() then return true end end return false') + let releaseFirst, releaseOther + state.set('first', new Promise((resolve) => { releaseFirst = resolve })) + state.set('other', new Promise((resolve) => { releaseOther = resolve })) + state.set('never', new Promise(() => {})) + const timed = state.doString('first:await() never:await()', { timeout: 30 }).catch((error) => error.name) + const other = state.doString('other:await() return 42') + releaseFirst() + const error = await timed + releaseOther() + return { fair, error, other: await other } + } finally { + state.close() + } + `) + expect(result).to.eql({ fair: true, error: 'LuaTimeoutError', other: 42 }) + }) + } + it('load Lua engine in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` diff --git a/test/bundling.test.js b/test/bundling.test.js index 05010f6..f8cf7a9 100644 --- a/test/bundling.test.js +++ b/test/bundling.test.js @@ -47,7 +47,7 @@ describe('Bundling', () => { }) const getMinifiedState = async (config = {}) => { - const lua = await minified.LuaRuntime.load({ wasmFile: WASM_FILE }) + const lua = await minified.LuaRuntime.load({ wasmFile: WASM_FILE, async: process.env.WASMOON_ASYNC }) return lua.createState({ inject: true, ...config }) } @@ -76,7 +76,7 @@ describe('Bundling', () => { state.set('yield', () => new Promise((resolve) => emitter.once('resolve', resolve))) const resPromise = state.doString(` local res = yield():next(function () - coroutine.yield() + ("x"):gsub(".", function() coroutine.yield() end) return 15 end) print("res", res:await()) diff --git a/test/promises.test.js b/test/promises.test.js index c839f88..73e8244 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -294,7 +294,7 @@ describe('Promises', () => { expect(() => { state.doStringSync(`sleep(5):await()`) - }).to.throw('cannot await in a thread that cannot yield') + }).to.throw('cannot await') }) it('an await abandoned mid flight should not leak a function table slot', async function () { diff --git a/test/state.test.js b/test/state.test.js index 68b1170..1e9d6db 100644 --- a/test/state.test.js +++ b/test/state.test.js @@ -1177,16 +1177,16 @@ describe('State', () => { expect(await state.doString('return value')).to.be.equal(1e300) }) - it('yielding in a JS callback into Lua does not break lua state', async () => { - // When yielding within a callback the error 'attempt to yield across a C-call boundary'. - // This test just checks that throwing that error still allows the lua global to be - // re-used and doesn't cause JS to abort or some nonsense. + it('yielding across a C-call boundary in a JS callback into Lua does not break lua state', async () => { + // A callback may await, but a coroutine.yield across a C-call boundary (here gsub's) still + // raises 'attempt to yield across a C-call boundary'. This checks that surfacing that error + // still leaves the lua global re-usable and doesn't cause JS to abort or some nonsense. using state = await getState() const testEmitter = new EventEmitter() state.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) const resPromise = state.doString(` local res = yield():next(function () - coroutine.yield() + ("x"):gsub(".", function() coroutine.yield() end) return 15 end) print("res", res:await()) diff --git a/test/utils.js b/test/utils.js index 7650f16..ea1a149 100644 --- a/test/utils.js +++ b/test/utils.js @@ -3,12 +3,16 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { LuaRuntime } from '../dist/index.js' +// Lets the whole suite run under either async engine: WASMOON_ASYNC=yield forces the fallback, +// 'jspi' requires JSPI, and the default lets the platform choose. +const asyncEngine = process.env.WASMOON_ASYNC + export const getLua = (options) => { - return LuaRuntime.load(options) + return LuaRuntime.load(asyncEngine ? { async: asyncEngine, ...options } : options) } export const getState = async (config = {}) => { - const lua = await LuaRuntime.load() + const lua = await getLua() return lua.createState({ inject: true, ...config, diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index fbdd773..19255ea 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -2,10 +2,8 @@ cd $(dirname $0) mkdir -p ../build/host -LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") +LUA_SRC="$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") ../src/native/wasmoon.c" -# Do not add --closure here: it renames properties, which would strip the brand the JS build puts on -# the glue's longjmp unwind classes (see rolldown.config.ts) and turn every unwind into a Lua error. if [ "$1" == "dev" ]; then extension=(-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2) @@ -17,6 +15,8 @@ fi # `glue.wasm`. COMMON=( -s WASM=1 + -s SUPPORT_LONGJMP=wasm + -I../lua "${extension[@]}" -s EXPORTED_RUNTIME_METHODS="[ 'addFunction', \ @@ -207,7 +207,14 @@ COMMON=( '_luaopen_debug', \ '_luaopen_package', \ '_luaL_openselectedlibs', \ - '_lua_gc' \ + '_lua_gc', \ + '_wasmoon_pushinteger', \ + '_wasmoon_geti', \ + '_wasmoon_rawgeti', \ + '_wasmoon_seti', \ + '_wasmoon_rawseti', \ + '_wasmoon_set_await_hook', \ + '_wasmoon_push_jsfunction' \ ]" )