Skip to content

Asyncify fixes for sequential awaited host calls, and drop CI - #1

Merged
Braedon-Wooding-Displayr merged 3 commits into
mainfrom
displayr
Sep 16, 2026
Merged

Braedon-Wooding-Displayr merged 3 commits into
mainfrom
displayr

Conversation

@Braedon-Wooding-Displayr

Copy link
Copy Markdown
Collaborator

Brings in the two unreleased Asyncify fixes this fork exists for, plus one defect found while verifying them, and drops CI.

1. Runtime disposal ordering (upstream justjake#256 — already on main)

No change needed: 4ccfbf5 is already here. Worth stating because npm latest is still 0.32.0, published 2026-02-16, five months before that merge — so the registry has no build containing it. That alone is a reason to publish this fork.

2. Sequential awaited host calls (upstream justjake#258 / PR justjake#265)

Applied PR justjake#265, with two corrections:

  • It didn't regenerate the bindings. The PR changes scripts/generate.ts but each variant's src/ffi.ts is generated and committed, so merging it alone changes nothing at runtime. Regenerated via pnpm run build:codegen — five release asyncify variants.
  • executePendingJobsAsync read a detached heap view. It allocated an Int32Array over HEAPU8.buffer, awaited, then read typedArray[0]. WASM memory growth during the suspension detaches that buffer, so the read yields undefined, which was then used as a context pointer. Added ModuleMemory.readPointer(), which builds the view after the await.

One correction to the PR's stated cause: on Emscripten 5.0.1 the { async: true } change is not what fixes justjake#258executePendingJobsAsync() is. Measured both ways against a local build. Kept { async: true } regardless, since it matches the debug builds and Emscripten's documented contract.

3. The synchronous job driver returned a value it never produced

Found while verifying the above. runtime.executePendingJobs() on an Asyncify runtime corrupted WASM memory (RuntimeError: memory access out of bounds) from the second sequential awaited host call in one guest execution.

Cause is in cwrap's fast path:

return returnType !== "string" && allNumeric && !opts
  ? d["_" + name]                                  // raw export — ccall never runs
  : (...a) => ccall(name, returnType, argTypes, a, opts)

QTS_ExecutePendingJob returns "number", takes three numbers, and passed no options, so it got the raw export — skipping ccall and its suspension check, which is ordinary control flow in release builds, not an assertion:

if (Asyncify.currData != previousAsync) return Asyncify.whenDone().then(onDone)

So a suspension went unnoticed and the caller dereferenced a pointer the unwound call never wrote. assertSync is already wrapped around exactly these bindings for this purpose and could never fire, because the raw export returns a number, never a Promise.

Passing {} as options restores the check. Applied to every synchronous sibling of a MaybeAsync binding on asyncify builds.

before after
1 await works works (unchanged)
2+ awaits, release memory access out of bounds Error: Function unexpectedly returned a Promise
2+ awaits, debug silent corruption Emscripten assertion, aborts the module

4. Removed the CI workflow

This fork is built and published manually from the consuming repo, and holds no credentials to publish or deploy.

Verification

Full suite on a local native build (Emscripten 5.0.1, all 22 variants): 7 files, 610 passed, 49 skipped. The new regression test fails without the fix and passes with it.

asyncify-sync-driver.test.ts is deliberately its own file: an unresumed suspension outlives the context it happened in, and a debug build aborts the module outright, so a shared-module test block sees every later test fail — which reads exactly like the fix caused a regression when it did not.

Items 2 and 3 are worth sending upstream; neither the cwrap fast-path diagnosis nor the detached-view bug is in the justjake#258 thread.

A pending job that calls an asyncified host function suspends, but the only
pending-job driver was synchronous, so the second sequential await inside one
guest execution left the runtime corrupted. Release builds also omitted
Emscripten's {async: true} on bindings that may suspend.
This fork exists to build packages published manually into a private scope from
the consuming repo, and nothing here holds credentials to publish or deploy.
Emscripten's cwrap returns the raw WebAssembly export whenever the return type is
not a string, every argument is numeric and no options object is passed, which
skips ccall and its check for a call that suspended. That check is what turns a
suspension into a Promise, so assertSync — already wrapped around these bindings
for exactly this — could never fire, and a pending job that suspended into a host
function left the caller reading a pointer the unwound call never wrote.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Multiple sequential await calls in evalCodeAsync fail with "Lifetime not alive"

1 participant