Skip to content

better async model - #153

Open
ceifa wants to merge 2 commits into
new-apis-2from
better-async-model
Open

better async model#153
ceifa wants to merge 2 commits into
new-apis-2from
better-async-model

Conversation

@ceifa

@ceifa ceifa commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Why

The async model had one primitive — yield the Lua coroutine and let a JS loop sniff the last yielded value — and everything (awaiting, time-slicing, cancellation, callbacks) was squeezed through it. That is why :await() failed inside table.sort/gsub/promise:next callbacks and in JS→Lua callbacks, why top-level coroutine.yield values were dropped, why a parked run couldn't be timed out or aborted, and why a loop of awaits starved the event loop.

Full design write-up and the measurements behind it: docs/async-redesign/README.md.

What changed

Two engines, chosen automatically at load (async: 'auto' | 'jspi' | 'yield').

  • JSPI (default where available — Chrome/Edge 137+, Firefox 153+, Node 25+). The interpreter runs under WebAssembly.promising, so an :await() suspends the whole wasm stack and works anywhere: inside a table.sort comparator, a gsub callback, a promise:next handler, a coroutine nothing drives from the host, or a Lua function called back from JS.
  • Coroutine yielding (fallback). Same observable behaviour everywhere it can express it; it just can't cross a C-call boundary, which now raises a clear error instead of a misleading one.

Highlights

  • :await() across C-call boundaries under JSPI (previously an error).
  • JS→Lua callbacks are sync-or-promise: a callback that awaits returns a Promise, one that doesn't stays synchronous with no promise allocated.
  • A run parked on a promise is interrupted promptly by timeout / AbortSignal instead of waiting for the promise to settle; the interrupt is not swallowable by a script pcall.
  • A top-level coroutine.yield that isn't an await is a host yield: new run(..., { onYield }) receives its values and decides the resume values (previously silently dropped); an unrepresentable yielded value no longer crashes the run.
  • Resumes go through a MessageChannel macrotask, so timer-driven code no longer starves and browsers avoid the 4 ms nested-setTimeout clamp.
  • Closing a state while a JSPI run is parked rejects the run rather than resuming into freed memory.

Build

  • glue.wasm is now built with -sSUPPORT_LONGJMP=wasm (required for JSPI; also ~30–45% faster on pcall/error/yield paths, size-neutral). src/native/wasmoon.c gains a small C trampoline that reaches the suspending import only from a wasm frame, plus the existing lua_Integer double-twins.

Cost / benefit (measured, Node 26)

  • Interop and CPU paths unchanged (heapsort, Call Lua from JS, Call JS from Lua flat; lua_resumelua_pcallk per callback).
  • An await of a settled promise drops from ~6 µs to ~0.3–1.6 µs under JSPI.
  • 1000 concurrently parked runs cost ~4 MB, same as before (per-suspension C-stack copy, not fixed regions).

ceifa and others added 2 commits September 5, 2026 19:06
Quality cleanups from review, no behavior change:
- collapse awaitInterruptible into settleOrInterrupt
- share one settled-value marshaller (Thread.pushReturnValues; marshalResolved/marshalRejected)
- centralize the abort/timeout classification in async.limitError
- remove the syncDepth field, derivable from stackCanSuspend via one withSuspensionDisabled helper
- extract the shared per-yield handling (throwIfLimitReached, handleHostYield) from the two run loops
- drop the dead await-hook L parameter and inline the single-use heap-slice wrappers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hBeqdFnHzBUQ7zLRVYQxx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant