wordpress/debug-php-wasm-main-module
Debug PHP.wasm main module crashes including Asyncify errors (unreachable, memory access out of bounds), JSPI errors (SuspendError, trying to suspend JS frames), WASM memory growth bugs, and runtime traps. Use when investigating RuntimeError, null function or signature mismatch, or other WASM-related crashes in the main PHP binary.
npx skills add https://github.com/WordPress/wordpress-playground --skill debug-php-wasm-main-module
Patterns for diagnosing and fixing crashes in the main PHP.wasm binary —
Asyncify unwind/rewind failures, JSPI suspension errors, memory growth
bugs, and runtime WASM traps.
| Error message | Likely cause |
|---------------|-------------|
| RuntimeError: unreachable | A function on the call stack is missing from ASYNCIFY_ONLY |
| memory access out of bounds | An opcode handler is missing — Asyncify corrupts the stack during rewind |
| null function or signature mismatch | Missing ASYNCIFY_ONLY function elsewhere on the stack — corrupted Asyncify state causes this to manifest in a *different* function than the one actually missing |
| table index is out of bounds | Missing opcode handler (variant of the above) |
Secondary errors (undefined variable, corrupted state) after any of these
are red herrings caused by the corrupted Asyncify rewind.
| Error message | Likely cause |
|---------------|-------------|
| SuspendError: trying to suspend JS frames | A JS frame sits between two WASM frames in the call stack. JSPI can only suspend pure WASM stacks. Common causes: (1) JS trampoline in the call chain; (2) C++ side module weak symbol env imports resolved through JS closure stubs |
| SuspendError: trying to suspend without WebAssembly.promising | The WASM function calling a suspending JS import is not in JSPI_EXPORTS |
| null function or function signature mismatch (after side module load) | Side module loading corrupted the function table — check Emscripten version match between main and side module |
A single missing ASYNCIFY_ONLY function produces different WASM error
types depending on the PHP version:
table index is out of boundsnull function or function signature mismatchmemory access out of boundsunreachable or memory access out of boundsEach PHP version compiles to different WASM code for the same opcode
handler. Don't assume different error messages mean different bugs — always
check the function at the top of the WASM stack trace.
--stack-trace-limit=200 (default 10 is tooshallow for Asyncify crashes)
_emscripten_sleep, _wasm_recv)
function. Adding deeper utility functions first won't help if the
opcode handler isn't instrumented.
ASYNCIFY_ONLY. Recompile andre-test after each addition. This reveals which function was actually
needed and whether deeper functions are now exposed.
deeper in the stack — fixing one crash reveals the next)
Every function on the call stack at the moment of the async call needs
Asyncify instrumentation. This includes:
ZEND_*_SPEC_*_HANDLER) — always check these firstzend_user_it_get_new_iterator for iterator creation)
point (var_destroy, _efree_large, php_var_unserialize_destroy) —
these are NOT just post-crash artifacts
xbuf_format_converter,php_printf_to_smart_str) that appear because the failed rewind
triggered zend_error — these are red herrings
Iterator operations (spread, foreach, array unpack):
ZEND_ADD_ARRAY_UNPACK_SPEC_HANDLER, ZEND_FE_FETCH_R_SPEC_VAR_HANDLERzend_user_it_get_new_iterator, zend_user_it_move_forwardStream operations:
_php_stream_make_seekable, _php_stream_copy_to_stream_ex_php_stream_flush, _php_stream_cast, zif_stream_selectObject operations:
zend_std_write_property, zend_std_cast_object_tostringzend_objects_clone_obj, zend_objects_clone_membersError/exception handling:
zend_error, zend_error_zstr, zend_throw_exceptionzend_undefined_indexSerialization:
zif_serialize, zif_unserialize, php_var_unserialize_destroy--experimental-wasm-jspiNode.js requires this flag for JSPI. Without it, wasm-feature-detect's
jspi() returns false and getPHPLoaderModule silently loads the
asyncify build. All JSPI bugs become invisible.
Add to vite.config.ts:
poolOptions: {
forks: {
execArgv: ['--expose-gc', '--experimental-wasm-jspi'],
},
},
Always verify which build is loaded by adding a console.log to the JS
glue file.
Use wasm-feature-detect's jspi() function to branch between JSPI
(dynamic extensions) and Asyncify (static extensions) code paths — both
in runtime loading and in test files.
When a WASM JS import has isAsync = true, JSPI wraps it with
WebAssembly.Suspending. Even if the implementation never suspends
(returns a value, not a Promise), the wrapper corrupts the WASM call
stack — V8's native stack bookkeeping desynchronizes __stack_pointer,
causing heap corruption that manifests later as zend_mm_panic in
_efree.
Symptoms: crash only during PHP startup (php_module_startup), heap
corruption in unrelated code (zend_hash_destroy, zend_file_handle_dtor).
Debugging strategy: neuter the JS import (return 0 immediately). If
the crash persists, the problem is the JSPI wrapping, not the import's
implementation. Check functionName.isAsync in the compiled JS glue. Fix
by setting functionName__async: false in the Emscripten JS library.
Search the compiled JS glue for:
instrumentWasmImports → importPattern regex (imports wrapped withWebAssembly.Suspending)
instrumentWasmExports → exportPattern regex (exports wrapped withWebAssembly.promising)
A function in the import pattern that shouldn't suspend causes heap
corruption. A function that needs to suspend but isn't in the pattern
returns immediately instead of waiting.
loadNodeRuntime() / loadWebRuntime() → WASM module loads, FS ready
new PHP(runtime) → initializeRuntime(), writes default php.ini
php.run() → php_wasm_init() → php_module_startup()
→ parses ini, initializes modules
Crashes only during step 3 (startup) but not at runtime point to
WASM-JS boundary issues (JSPI wrapping, calling conventions) rather than
PHP logic bugs.
memory.grow() detaches the old ArrayBuffer. Emscripten's
updateMemoryViews() replaces module-scoped HEAP variables, but any JS
code that captured a typed array reference (object literal, destructuring,
closure) now points to a detached buffer.
Symptoms: SQLITE_IOERR from file locking, reads return zero, writes
are silent no-ops — all appearing after the WASM module has been running
for a while (memory grew).
Never expose raw typed arrays across module boundaries. Use accessor
objects:
memory: {
HEAP16: {
get(offset) { return HEAP16[offset]; },
set(offset, value) { HEAP16[offset] = value; },
}
}
This makes stale capture structurally impossible. Property getters
(get HEAP16() { return HEAP16; }) still expose the typed array, which
callers can capture — accessor objects are safer.
Emscripten's handleSleep() calls _malloc() on every async unwind. If
that triggers memory.grow(), Asyncify state corrupts. Fix: cache
allocateData() result, reuse across sleeps. Apply via Dockerfile
replace.sh (Asyncify-only, not JSPI).
INITIAL_MEMORY is baked into the WASM binary (typically 256MB). Force
growth from PHP:
str_repeat('x', 300 * 1024 * 1024);
Or set a low INITIAL_MEMORY (64MB) during compilation to force earlier
growth.
When a PHP.wasm feature silently fails (no crash, no error, just doesn't
work):
Add console.log to JS functions in the compiled glue file
(php_8_4.js). Search for function ___ (triple underscore) to find
Emscripten's syscall wrappers. Log arguments to see what WASM is passing.
If a C function is called in the source but the corresponding JS wrapper
never fires, the symbol resolution is wrong.
const mod = new WebAssembly.Module(fs.readFileSync('path/to/module.wasm'));
console.log(WebAssembly.Module.imports(mod).map(i => i.name));
console.log(WebAssembly.Module.exports(mod).map(e => e.name));
A function in the C source but NOT in the module's imports list was
inlined, stubbed, or resolved statically — it won't call through to JS.
When the JS glue is not enough, add fprintf(stderr, ...) statements to
the PHP C source code and rebuild. This traces the actual execution path
through the WASM binary. Use this when:
assertNoCrash silently swallows errors when FIX_DOCKERFILE isnot set. Always add a re-throw after the catch block.
php.exit() = unhandled rejections. Alwaysreturn or await calls to assertNoCrash().
rejection surfaces during test N+1).
expect(result.text).toBe('').Fix: add proper return types or wrap with ob_start()/ob_end_clean().
sapi_send_headers as uncaughtexceptions (not promise rejections). Tests must handle both
unhandledRejection and uncaughtException.
openssl.cafile via setPhpIniEntries.
# Run tests for specific PHP version + mode
PHP=8.0 npm run test-group-3-asyncify
# Filter tests by name
npx nx test php-wasm-node --testFile=php.spec.ts -- --test-name-pattern='Magic Methods'
# Increase stack trace depth (critical for Asyncify crashes)
NODE_OPTIONS='--stack-trace-limit=200' npx nx test php-wasm-node
# Verbose output
npx nx test php-wasm-node -- --reporter=verbose
| Situation | Action |
|-----------|--------|
| unreachable / memory access out of bounds | Asyncify crash — find missing ASYNCIFY_ONLY function |
| SuspendError: trying to suspend JS frames | JS frame in WASM call stack — eliminate JS trampoline |
| SuspendError: ... without WebAssembly.promising | Add function to JSPI_EXPORTS |
| zend_mm_panic in _efree | Check for wrongly-async JS imports (JSPI wrapping issue) |
| Startup hang (all tests time out) | JSPI syscall wrapper gained JS frame — remove from JSPI lists |
| SQLITE_IOERR after running a while | Stale HEAP reference after memory.grow() |
| Different errors across PHP versions | Same root cause — check function at top of WASM stack |
| Silent failure (no crash, no error) | Trace WASM-JS boundary — instrument glue file |
| Test passes but shouldn't | Check for assertNoCrash swallowing errors |
Take wordpress/debug-php-wasm-main-module from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.