Skip to content

Fix two WASM memory growth bugs in asyncify builds (Asyncify + JSPI)#3317

Draft
JanJakes wants to merge 4 commits intotrunkfrom
php-wasm-memory
Draft

Fix two WASM memory growth bugs in asyncify builds (Asyncify + JSPI)#3317
JanJakes wants to merge 4 commits intotrunkfrom
php-wasm-memory

Conversation

@JanJakes
Copy link
Member

@JanJakes JanJakes commented Feb 27, 2026

Motivation for the change, related issues

This PR fixes two bugs that involve WASM memory.grow(). Either bug alone can corrupt PHP state. They made WordPress installation fail with Asyncify at low INITIAL_MEMORY values (tested at 64 MB and 16 MB).

Bug 1: Stale HEAP references in file lock syscalls (Asyncify + JSPI)

After memory.grow(), Emscripten's updateMemoryViews() replaces the module-scoped HEAP8, HEAP16, … variables with new typed arrays backed by the grown buffer. Our file locking code captured these in two places:

  1. The memory object passed to bindUserSpace() — a plain object literal that snapshotted the values at construction time.
  2. bindUserSpace() destructured them into local variables (memory: { HEAP16, HEAP64, HEAP32 }), creating a second level of stale capture.

After any memory.grow(), both levels pointed to the old, detached ArrayBuffer. When SQLite called fcntl(F_GETLK) to check locks, updateFlockStruct() wrote "unlocked" to the detached buffer. The WASM code — reading from the actual (new) memory — never saw the update and treated the stale data as a lock conflict, resulting in SQLITE_IOERR (error code 10).

Bug 2: Asyncify allocates memory on every async operation (Asyncify only)

Emscripten's handleSleep() calls _malloc() on every async unwind and _free() on every rewind. If that _malloc() triggers memory.grow(), the ArrayBuffer detaches mid-operation, corrupting Asyncify's own state. The Asyncify source has a TODO comment acknowledging this: // TODO: reuse, don't alloc/free every sleep.

Related: emscripten-core/emscripten#21108

Why the bugs were invisible until now

At 256 MB INITIAL_MEMORY, WordPress fits without ever calling memory.grow(), so neither bug manifests. At lower values, growth is needed during WordPress installation, and both bugs trigger.

Implementation details

Bug 1 fix (phpwasm-emscripten-library.js + wasm-user-space.ts):

  • The memory object now uses getters (get HEAP16() { return HEAP16; }) so every property access returns the current typed array.
  • bindUserSpace() keeps the memory object reference instead of destructuring, accessing memory.HEAP16[...] etc. at call time.

Bug 2 fix (Dockerfile post-build patch, Asyncify only):

  • Asyncify.allocateData() result is cached in Asyncify._cachedData. Subsequent sleeps reinitialize the header and rewind function pointer but reuse the same buffer, avoiding _malloc() on every async operation.
  • The corresponding _free(Asyncify.currData) in the rewind path is removed.
  • Guarded by if [ "$WITH_JSPI" != "yes" ] — JSPI builds use a different suspend mechanism that doesn't have this problem.

Rebuilt binaries: All Asyncify and JSPI builds (node + web, PHP 7.4–8.5) are rebuilt. The wasm-user-space.ts half of the bug 1 fix takes effect at runtime without a rebuild, but the emscripten getter fix is baked in at compile time.

Testing Instructions (or ideally a Blueprint)

Verify with the CLI (uses the rebuilt binaries included in this PR):

npx nx dev playground-cli server --php=8.3
# Visit http://127.0.0.1:9400/ — should show WordPress homepage
# Visit http://127.0.0.1:9400/wp-admin/ — should show login/dashboard

To test at lower INITIAL_MEMORY (requires a local rebuild):

# In packages/php-wasm/compile/php/Dockerfile, change INITIAL_MEMORY to 16MB
node packages/php-wasm/compile/build.js --PLATFORM=node --PHP_VERSION=8.3
npx nx dev playground-cli server --php=8.3
# Should start successfully — previously crashed with:
# "Error connecting to the SQLite database."

Emscripten's handleSleep() calls _malloc() on every async operation and
_free() on every rewind. If _malloc triggers memory.grow(), the underlying
ArrayBuffer detaches and corrupts PHP state.

Fix: allocate the Asyncify data buffer once (on first sleep) and reuse it
on subsequent sleeps. On reuse, reinitialize the header and rewind func
without reallocating. Remove the _free() call in the rewind path.

This implements the optimization suggested by Emscripten's own TODO comment:
"TODO: reuse, don't alloc/free every sleep"

Only applied to asyncify builds (not JSPI) since JSPI doesn't use the
full handleSleep implementation.

emscripten-core/emscripten#21108
After memory.grow(), Emscripten's updateMemoryViews() reassigns the
module-scoped HEAP8/HEAP16/HEAP32/HEAP64 variables to new typed arrays
backed by the grown buffer. However, the memory object passed to
bindUserSpace() captured the old typed array references at construction
time, and bindUserSpace() further destructured them into local
variables — both levels producing stale references pointing to the old,
detached ArrayBuffer.

This caused SQLITE_IOERR (error 10) during WordPress installation when
WASM memory needed to grow: fcntl64(F_GETLK) would write "unlocked"
status to the detached buffer via updateFlockStruct(), so SQLite's C
code never saw the update and interpreted the stale data as a lock
conflict.

Fix both levels:
- Emscripten library: use getters on the memory object so each property
  access returns the current typed array
- wasm-user-space.ts: access HEAP arrays through the memory object
  reference instead of destructuring into local variables
Copilot AI review requested due to automatic review settings February 27, 2026 15:36
@JanJakes JanJakes changed the title Fix WASM memory growth corrupting file lock syscalls Fix two WASM memory growth bugs in asyncify builds Feb 27, 2026
@JanJakes JanJakes changed the title Fix two WASM memory growth bugs in asyncify builds Fix two WASM memory growth bugs in asyncify builds (Asyncify + JSPI) Feb 27, 2026
@JanJakes JanJakes marked this pull request as draft February 27, 2026 16:13
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Incorporates the Asyncify data caching and the HEAP getter fix into all
asyncify builds for both node and web platforms, PHP 7.4 through 8.5.
Recompile all JSPI builds (node + web, PHP 7.4–8.5) to include the
stale HEAP reference fix from phpwasm-emscripten-library.js.

The fix has runtime effect only in node builds where bindUserSpace()
is used, but web builds are rebuilt for consistency.
Comment on lines +95 to +104
get HEAP8() { return HEAP8; },
get HEAPU8() { return HEAPU8; },
get HEAP16() { return HEAP16; },
get HEAPU16() { return HEAPU16; },
get HEAP32() { return HEAP32; },
get HEAPU32() { return HEAPU32; },
get HEAPF32() { return HEAPF32; },
get HEAP64() { return HEAP64; },
get HEAPU64() { return HEAPU64; },
get HEAPF64() { return HEAPF64; },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for catching this! I didn't know these were reassigned.

Instead of getters, which can possibly lead to mistakenly saving the current HEAP references in wasm-user-space.ts, it would be good to convert these into a get/set interface like:

Suggested change
get HEAP8() { return HEAP8; },
get HEAPU8() { return HEAPU8; },
get HEAP16() { return HEAP16; },
get HEAPU16() { return HEAPU16; },
get HEAP32() { return HEAP32; },
get HEAPU32() { return HEAPU32; },
get HEAPF32() { return HEAPF32; },
get HEAP64() { return HEAP64; },
get HEAPU64() { return HEAPU64; },
get HEAPF64() { return HEAPF64; },
HEAP8: {
get(offset) { return HEAP8[offset]; },
set(offset, value) { HEAP8[offset] = value; },
},
HEAPU8: {
get(offset) { return HEAPU8[offset]; },
set(offset, value) { HEAPU8[offset] = value; },
},
HEAP16: {
get(offset) { return HEAP16[offset]; },
set(offset, value) { HEAP16[offset] = value; },
},
HEAPU16: {
get(offset) { return HEAPU16[offset]; },
set(offset, value) { HEAPU16[offset] = value; },
},
HEAP32: {
get(offset) { return HEAP32[offset]; },
set(offset, value) { HEAP32[offset] = value; },
},
HEAPU32: {
get(offset) { return HEAPU32[offset]; },
set(offset, value) { HEAPU32[offset] = value; },
},
HEAPF32: {
get(offset) { return HEAPF32[offset]; },
set(offset, value) { HEAPF32[offset] = value; },
},
HEAP64: {
get(offset) { return HEAP64[offset]; },
set(offset, value) { HEAP64[offset] = value; },
},
HEAPU64: {
get(offset) { return HEAPU64[offset]; },
set(offset, value) { HEAPU64[offset] = value; },
},
HEAPF64: {
get(offset) { return HEAPF64[offset]; },
set(offset, value) { HEAPF64[offset] = value; },
},

Or we could relay Proxy objects with get/set traps, so wasm-user-space.ts can continue accessing the HEAPs as regular ArrayBuffers.

Comment on lines +95 to +104
get HEAP8() { return HEAP8; },
get HEAPU8() { return HEAPU8; },
get HEAP16() { return HEAP16; },
get HEAPU16() { return HEAPU16; },
get HEAP32() { return HEAP32; },
get HEAPU32() { return HEAPU32; },
get HEAPF32() { return HEAPF32; },
get HEAP64() { return HEAP64; },
get HEAPU64() { return HEAPU64; },
get HEAPF64() { return HEAPF64; },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for catching this! I didn't know these were reassigned.

Instead of getters, which can possibly lead to mistakenly saving the current HEAP references in wasm-user-space.ts, it would be good to convert these into a get/set interface like:

Suggested change
get HEAP8() { return HEAP8; },
get HEAPU8() { return HEAPU8; },
get HEAP16() { return HEAP16; },
get HEAPU16() { return HEAPU16; },
get HEAP32() { return HEAP32; },
get HEAPU32() { return HEAPU32; },
get HEAPF32() { return HEAPF32; },
get HEAP64() { return HEAP64; },
get HEAPU64() { return HEAPU64; },
get HEAPF64() { return HEAPF64; },
HEAP8: {
get(offset) { return HEAP8[offset]; },
set(offset, value) { HEAP8[offset] = value; },
},
HEAPU8: {
get(offset) { return HEAPU8[offset]; },
set(offset, value) { HEAPU8[offset] = value; },
},
HEAP16: {
get(offset) { return HEAP16[offset]; },
set(offset, value) { HEAP16[offset] = value; },
},
HEAPU16: {
get(offset) { return HEAPU16[offset]; },
set(offset, value) { HEAPU16[offset] = value; },
},
HEAP32: {
get(offset) { return HEAP32[offset]; },
set(offset, value) { HEAP32[offset] = value; },
},
HEAPU32: {
get(offset) { return HEAPU32[offset]; },
set(offset, value) { HEAPU32[offset] = value; },
},
HEAPF32: {
get(offset) { return HEAPF32[offset]; },
set(offset, value) { HEAPF32[offset] = value; },
},
HEAP64: {
get(offset) { return HEAP64[offset]; },
set(offset, value) { HEAP64[offset] = value; },
},
HEAPU64: {
get(offset) { return HEAPU64[offset]; },
set(offset, value) { HEAPU64[offset] = value; },
},
HEAPF64: {
get(offset) { return HEAPF64[offset]; },
set(offset, value) { HEAPF64[offset] = value; },
},

Or we could relay Proxy objects with get/set traps, so wasm-user-space.ts can continue accessing the HEAPs as regular ArrayBuffers.

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.

3 participants