Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"printWidth": 140,
"tabWidth": 4,
"sortPackageJson": false,
"ignorePatterns": ["rolldown.config.*.js"]
"ignorePatterns": ["rolldown.config.*.js", "docs/**"]
}
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
96 changes: 51 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.<anonymous> (/home/tstableford/projects/wasmoon/dist/index.js:142:22)
at Generator.throw (<anonymous>)
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.
73 changes: 73 additions & 0 deletions bench/async.js
Original file line number Diff line number Diff line change
@@ -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()
}
Loading