diff --git a/.agents/skills/compile-php-wasm/SKILL.md b/.agents/skills/compile-php-wasm/SKILL.md new file mode 100644 index 00000000000..3b31e538b91 --- /dev/null +++ b/.agents/skills/compile-php-wasm/SKILL.md @@ -0,0 +1,265 @@ +--- +name: compile-php-wasm +description: Compile PHP.wasm main modules and side modules (dynamic extensions) for Node.js and web platforms. Use when recompiling PHP, adding Emscripten flags, modifying Dockerfiles, building extensions as SIDE_MODULE, upgrading Emscripten, or troubleshooting compilation failures. +--- + +# Compiling PHP.wasm + +Patterns for compiling PHP.wasm main modules and dynamic extension side +modules in the WordPress Playground repository. + +**Requires:** Docker, Node.js (version from `.nvmrc`), npm + +## Quick Reference + +````bash +```bash +# Recompile a specific version for ALL platforms and modes (web+node, jspi+asyncify) +npx nx recompile-php:all php-wasm-web -- --PHP_VERSION=8.5 +npx nx recompile-php:all php-wasm-node -- --PHP_VERSION=8.5 + +# Recompile a specific version + platform + single mode +npx nx recompile-php:jspi php-wasm-web -- --PHP_VERSION=8.5 +npx nx recompile-php:asyncify php-wasm-web -- --PHP_VERSION=8.5 +npx nx recompile-php:jspi php-wasm-node -- --PHP_VERSION=8.5 +npx nx recompile-php:asyncify php-wasm-node -- --PHP_VERSION=8.5 + +# Recompile all PHP versions for a platform +npm run recompile:php:web +npm run recompile:php:node + +# Debug build (DWARF info) +# Use when you need to step through WASM in a debugger (Chrome DevTools DWARF +# support) or need better stack traces with C function names. Helpful for +# crashes like `RuntimeError: unreachable` where you need to identify which +# C function is involved. Produces much larger binaries. +npx nx recompile-php:all php-wasm-web -- --WITH_DEBUG=yes +npx nx recompile-php:all php-wasm-node -- --WITH_DEBUG=yes + +# Source maps +# Use when you want to map JS glue code back to its Emscripten-generated +# source locations. Lighter weight than DWARF but only covers the JS side, +# not the WASM internals. +npx nx recompile-php:all php-wasm-web -- --WITH_SOURCEMAPS=yes +npx nx recompile-php:all php-wasm-node -- --WITH_SOURCEMAPS=yes + +# Reset caches before rebuilding +node node_modules/.bin/nx reset +docker rmi php-wasm:latest + +## Build Pipeline Overview + +The build system lives in `packages/php-wasm/compile/`. The pipeline is: + +```` + +Dockerfile (Emscripten + PHP source + patches) +↓ docker build +.wasm binary + .js glue file +↓ post-link Dockerfile patches (replace.sh) +Patched .js glue file +↓ NX executor copies to dist/ +Final artifacts in package dist/ + +```` + +Key files: + +| File | Purpose | +|------|---------| +| `Dockerfile` | Main build — downloads PHP source, applies patches, runs `emcc` | +| `Makefile` | Orchestrates Docker builds per PHP version | +| `build.js` | Node script invoked by NX executors | +| `.emcc-php-wasm-flags` | Emscripten linker flags (generated during build) | +| `.emcc-php-wasm-sources` | Source/library paths for linking (generated during build) | +| `replace.sh` / Dockerfile `RUN sed` | Post-link patches to the compiled JS glue file | + +## Main Module Compilation + +### Emscripten flags that matter + +| Flag | Purpose | +|------|---------| +| `ASYNCIFY` | Enables Asyncify (unwind/rewind for async JS calls) | +| `ASYNCIFY_ONLY=[func1,func2,...]` | Whitelist of functions Asyncify instruments (critical for binary size) | +| `JSPI` | Enables JSPI (V8 native stack switching, replaces Asyncify) | +| `JSPI_IMPORTS=[func1,...]` | JS imports wrapped with `WebAssembly.Suspending` | +| `JSPI_EXPORTS=[func1,...]` | WASM exports wrapped with `WebAssembly.promising` | +| `MAIN_MODULE=1` | Enables `dlopen` — exports all symbols for dynamic linking | +| `MAIN_MODULE=2` | Like `=1` but only exports explicitly listed symbols | +| `EXPORTED_FUNCTIONS=[...]` | C functions accessible from JS | +| `EXPORTED_RUNTIME_METHODS=[...]` | Emscripten runtime helpers accessible from JS | +| `ENVIRONMENT=web,worker` | Target environments (affects code generation) | +| `INITIAL_MEMORY=256MB` | Starting linear memory size | + +### MAIN_MODULE for dynamic extensions + +When adding `dlopen` support (`MAIN_MODULE=1`): + +- **Never use `-l` flags for C libraries.** Library directories contain both + `.a` (static) and `.so` (WASM side module) files. With `MAIN_MODULE`, PIC + mode makes the linker prefer `.so`. When `wasm-ld` encounters a `.so` + under `--whole-archive`, it crashes with SIGSEGV. Fix: use explicit `.a` + paths in `.emcc-php-wasm-sources` instead of `-l` flags. + +- **`MAIN_MODULE=1` has linker limitations.** `wasm-ld` cannot handle + `--whole-archive` + `--experimental-pic` on archives as large as + `libphp.a`. This is a fundamental limitation. Start with `=1` (exports + all symbols), then consider `=2` for optimization. + +- **ENVIRONMENT=web,worker changes code generation.** With a single + environment, Emscripten hardcodes booleans (`ENVIRONMENT_IS_WEB = true`). + With multiple, it generates runtime detection. Post-processing regex + patterns in the Dockerfile must handle both forms. + +### Post-link Dockerfile patches + +The Dockerfile uses `sed` / `replace.sh` to modify the compiled JS glue +file after Emscripten runs. Common patches: + +- **Inject `_malloc` binding** when it's not auto-exposed: + ```js + PHPLoader['malloc'] = wasmExports['malloc']; + // Injected right after assignWasmExports() in the glue file +```` + +- **Cache Asyncify buffers** to prevent `memory.grow()` corruption during + `handleSleep()` (see debug-php-wasm-main-module skill for details) +- **Guard `ENVIRONMENT_IS_*` substitution** for multi-environment builds + +### Emscripten version upgrade checklist + +When upgrading Emscripten, expect these categories of breakage: + +1. **Removed/renamed APIs:** + - `setErrNo()` removed — use `HEAP32[___errno_location() >> 2] = code` + - `_malloc`/`_free` no longer auto-exposed — add to `EXPORTED_FUNCTIONS` + - `HEAPU8`/`HEAPU32` need explicit `EXPORTED_RUNTIME_METHODS` + +2. **Stricter Clang compiler:** + - `-Wincompatible-pointer-types` becomes an error. Fix: correct the + types, not the warning level. + - PHP version checks like `#if PHP_MAJOR_VERSION >= 8` may be too broad + (e.g. `zend_file_handle.filename` is `const char *` in 8.0 but + `zend_string *` in 8.1+) + +3. **JSPI behavioral changes:** + - WASI syscall wrappers (e.g. `fd_close`) may gain JS intermediate + frames, breaking JSPI suspension. Symptom: startup hangs silently. + Fix: remove from `JSPI_IMPORTS`/`JSPI_EXPORTS`. + - `exitRuntime()` → `__funcs_on_exit()` may trigger JSPI suspension. + Add to `JSPI_EXPORTS` and `EXPORTED_FUNCTIONS`. + +4. **Debugging approach:** + - Build PHP 8.4 first (fewest compatibility issues) + - Fix build errors, then run JSPI tests + - Once 8.4 passes, test 8.0 and 7.4 for version-specific issues + - Patch PHP source files (`php*.patch`) for older versions as needed + +## Side Module Compilation + +Side modules are PHP extensions (Xdebug, intl, GD, etc.) compiled as +WASM shared libraries loaded via `dlopen`. + +### Build requirements + +The extension build needs a minimal PHP installation (for `phpize` and +headers). Key Emscripten-specific requirements: + +| Requirement | Detail | +| -------------------------- | ----------------------------------------------------------------------------------------------------- | +| Inline assembly patches | `HAVE_ASM_GOTO`, `ZEND_USE_ASM_ARITHMETIC`, `__GNUC__`, `__clang__` — same patches as main Dockerfile | +| `--without-pcre-jit` | SLJIT uses x86 assembly, unavailable in WASM | +| PHP 8.4 flag change | `--disable-libxml` became `--without-libxml` | +| Remove `-lm` from Makefile | Math library is in the main module | +| EMCC_FLAGS | `-sSIDE_MODULE -D__x86_64__ -sWASM_BIGINT` | +| `wasm-opt` path | `/root/emsdk/upstream/bin/wasm-opt` (not on PATH) | + +### Asyncify side modules + +When the side module uses custom renamed imports (e.g. `-Drecv=wasm_recv`), +you MUST pass `-sASYNCIFY_IMPORTS=`: + +```dockerfile +export EMCC_FLAGS="-sSIDE_MODULE -sASYNCIFY -sASYNCIFY_IMPORTS=wasm_recv" +``` + +Without this, Binaryen won't instrument call sites for those imports — +locals won't be saved/restored, causing `table index is out of bounds` +during Asyncify rewind. + +### Libtool issues + +Libtool refuses to create WASM shared libraries. Two workarounds: + +1. **Patch libtool's `archive_cmds`** — replace `$CC` with + `emcc $EMCC_FLAGS -shared --whole-archive --no-whole-archive` +2. **Bypass libtool entirely** — manually link with `em++`, discovering all + `.o` files recursively (`find . -path '*/.libs/*.o'`) + +When using approach 2, check subdirectories — C++ libraries often produce +`.o` files in nested paths that build scripts miss. + +### Pre-compiled artifacts + +ICU `.a` archives and other pre-built artifacts committed to the repo may +not match current build flags. When `MAIN_MODULE=1` requires PIC, +pre-built non-PIC archives cause `R_WASM_MEMORY_ADDR_SLEB` relocation +errors. Rebuild from source if flags changed. + +## Cache Busting + +Docker BuildKit caches aggressively. Before rebuilding: + +```bash +# Remove the image to force a true rebuild +docker rmi php-wasm:latest + +# docker builder prune alone is NOT sufficient — BuildKit reuses +# intermediate layers from existing images + +# Also reset NX cache +node node_modules/.bin/nx reset +``` + +## WASM Binary Inspection + +When the build produces `.wasm` files that don't work correctly: + +```bash +# List exports and imports +wasm-objdump -x module.wasm + +# Disassemble +wasm-objdump -d module.wasm + +# Print WAT form (verify Asyncify instrumentation) +wasm-opt --print module.so + +# From JavaScript — inspect a side module +node -e " + const fs = require('fs'); + const mod = new WebAssembly.Module(fs.readFileSync('module.so')); + console.log('exports:', WebAssembly.Module.exports(mod).map(e => e.name)); + console.log('imports:', WebAssembly.Module.imports(mod).map(i => i.name)); +" +``` + +Cross-reference symbol lists with: + +- The `ASYNCIFY_ONLY` function list (main module) +- `EXPORTED_FUNCTIONS` in the Emscripten build flags +- `SIDE_MODULE` / `MAIN_MODULE` dynamic linking expectations + +## Diagnostic Cheat Sheet + +| Situation | Action | +| ---------------------------------- | ------------------------------------------------------------- | +| Build fails with compiler error | Read the error, fix C/Makefile, retry | +| Build succeeds but WASM won't load | List imports — runtime is missing something | +| Build succeeds but runtime crashes | List exports + check Asyncify/JSPI function lists | +| Behavior is wrong but no error | Add `printf` to C code, rebuild, trace | +| Extension fails as SIDE_MODULE | Check dynamic linking flags, verify symbol visibility | +| Linker SIGSEGV with MAIN_MODULE | Switch `-l` flags to explicit `.a` paths | +| `R_WASM_MEMORY_ADDR_SLEB` error | Pre-built archive not compiled with PIC — rebuild from source | +| Don't know what a build step does | Read the Dockerfile/Makefile line by line | diff --git a/.agents/skills/debug-php-wasm-main-module/SKILL.md b/.agents/skills/debug-php-wasm-main-module/SKILL.md new file mode 100644 index 00000000000..510f7e2efbe --- /dev/null +++ b/.agents/skills/debug-php-wasm-main-module/SKILL.md @@ -0,0 +1,299 @@ +--- +name: debug-php-wasm-main-module +description: 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. +--- + +# Debugging 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 Interpretation + +### Asyncify errors + +| 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. + +### JSPI errors + +| 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 | + +### Same root cause, different errors across PHP versions + +A single missing `ASYNCIFY_ONLY` function produces different WASM error +types depending on the PHP version: + +- PHP 5.6: `table index is out of bounds` +- PHP 7.0: `null function or function signature mismatch` +- PHP 7.2/8.2: `memory access out of bounds` +- PHP 7.4/8.0/8.1: `unreachable` or `memory access out of bounds` + +Each 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. + +## Asyncify Crash Debugging Strategy + +### Step-by-step process + +1. **Run the test** with `--stack-trace-limit=200` (default 10 is too + shallow for Asyncify crashes) +2. **Identify the async trigger** in the stack trace (e.g. + `_emscripten_sleep`, `_wasm_recv`) +3. **Work backwards** through the call chain from the trigger +4. **Start with the opcode handler** — it's usually the critical missing + function. Adding deeper utility functions first won't help if the + opcode handler isn't instrumented. +5. **Add one function at a time** to `ASYNCIFY_ONLY`. Recompile and + re-test after each addition. This reveals which function was actually + needed and whether deeper functions are now exposed. +6. **Repeat** until the crash is fixed or a new crash surfaces (often + deeper in the stack — fixing one crash reveals the next) + +### What needs ASYNCIFY_ONLY + +Every function on the call stack **at the moment of the async call** needs +Asyncify instrumentation. This includes: + +- **Opcode handlers** (`ZEND_*_SPEC_*_HANDLER`) — always check these first +- **Bridge functions** between the opcode and the async call (e.g. + `zend_user_it_get_new_iterator` for iterator creation) +- **Cleanup functions** that run in the same scope before the suspension + point (`var_destroy`, `_efree_large`, `php_var_unserialize_destroy`) — + these are NOT just post-crash artifacts + +### What does NOT need ASYNCIFY_ONLY + +- Functions that run **after** the async operation returns +- Error formatting functions (`xbuf_format_converter`, + `php_printf_to_smart_str`) that appear because the failed rewind + triggered `zend_error` — these are red herrings + +### Common function categories to instrument + +**Iterator operations** (spread, foreach, array unpack): +- `ZEND_ADD_ARRAY_UNPACK_SPEC_HANDLER`, `ZEND_FE_FETCH_R_SPEC_VAR_HANDLER` +- `zend_user_it_get_new_iterator`, `zend_user_it_move_forward` + +**Stream operations:** +- `_php_stream_make_seekable`, `_php_stream_copy_to_stream_ex` +- `_php_stream_flush`, `_php_stream_cast`, `zif_stream_select` + +**Object operations:** +- `zend_std_write_property`, `zend_std_cast_object_tostring` +- `zend_objects_clone_obj`, `zend_objects_clone_members` + +**Error/exception handling:** +- `zend_error`, `zend_error_zstr`, `zend_throw_exception` +- `zend_undefined_index` + +**Serialization:** +- `zif_serialize`, `zif_unserialize`, `php_var_unserialize_destroy` + +## JSPI Debugging + +### Vitest must have `--experimental-wasm-jspi` + +Node.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`: +```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. + +### Gate JSPI vs Asyncify paths + +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. + +### Synchronous JS imports must NOT be marked async + +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. + +### Check JSPI wrapping in compiled glue + +Search the compiled JS glue for: +- `instrumentWasmImports` → `importPattern` regex (imports wrapped with + `WebAssembly.Suspending`) +- `instrumentWasmExports` → `exportPattern` regex (exports wrapped with + `WebAssembly.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. + +## PHP Startup Lifecycle in WASM + +``` +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. + +## WASM Memory Growth 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). + +### Fix pattern + +Never expose raw typed arrays across module boundaries. Use accessor +objects: + +```js +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. + +### Asyncify allocation bug + +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). + +### Reproducing without recompilation + +`INITIAL_MEMORY` is baked into the WASM binary (typically 256MB). Force +growth from PHP: +```php +str_repeat('x', 300 * 1024 * 1024); +``` + +Or set a low `INITIAL_MEMORY` (64MB) during compilation to force earlier +growth. + +## Tracing the WASM-JS Boundary + +When a PHP.wasm feature silently fails (no crash, no error, just doesn't +work): + +### Instrument JS imports in the compiled glue + +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. + +### Inspect WASM module imports and exports + +```js +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. + +### Add printf to C source + +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: +- The error message is ambiguous +- You need to know what values are passed at SAPI/extension boundaries +- Execution diverges from expectation with no visible error + +## Test Infrastructure Gotchas + +- **`assertNoCrash` silently swallows errors** when `FIX_DOCKERFILE` is + not set. Always add a re-throw after the catch block. +- **Floating promises + `php.exit()` = unhandled rejections.** Always + `return` or `await` calls to `assertNoCrash()`. +- **Vitest misattributes unhandled rejections** to the wrong test (test N + rejection surfaces during test N+1). +- **PHP 8.4 deprecation notices** break `expect(result.text).toBe('')`. + Fix: add proper return types or wrap with `ob_start()`/`ob_end_clean()`. +- **WASM fires secondary crashes** from `sapi_send_headers` as uncaught + exceptions (not promise rejections). Tests must handle both + `unhandledRejection` and `uncaughtException`. +- **HTTPS tests need the CA cert in WASM FS.** Write the cert file and set + `openssl.cafile` via `setPhpIniEntries`. + +## Testing Commands + +```bash +# 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 +``` + +## Diagnostic Cheat Sheet + +| 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 | diff --git a/.agents/skills/debug-php-wasm-side-modules/SKILL.md b/.agents/skills/debug-php-wasm-side-modules/SKILL.md new file mode 100644 index 00000000000..e35977218fb --- /dev/null +++ b/.agents/skills/debug-php-wasm-side-modules/SKILL.md @@ -0,0 +1,252 @@ +--- +name: debug-php-wasm-side-modules +description: Debug WASM side modules (dynamic PHP extensions) including dlopen failures, SIDE_MODULE loading, JSPI suspension crashes in extensions, C++ weak symbol issues, and extension runtime errors. Use when working with dynamic extensions like Xdebug, intl, or GD built as WASM side modules. +--- + +# Debugging PHP.wasm Side Modules + +Patterns for diagnosing and fixing issues with dynamic PHP extensions +built as WASM side modules and loaded via `dlopen`. + +## `_dlopen_js` Must Be Synchronous + +Emscripten marks `_dlopen_js` as async by default (`isAsync = true`). +With JSPI, this wraps the import with `WebAssembly.Suspending`. Even when +the implementation returns a plain value (not a Promise), the JSPI wrapper +corrupts the WASM call stack — V8's native stack bookkeeping +desynchronizes `__stack_pointer` in linear memory, causing heap corruption +that manifests later as `zend_mm_panic` in `_efree`. + +**Fix:** Set `_dlopen_js__async: false` in the Emscripten JS library +(`phpwasm-emscripten-library-dynamic-linking.js`). + +**Symptoms:** `zend_extension=` in `php.ini` crashes PHP on ANY +subsequent code. `dl()` at runtime works fine. Neutering `_dlopen_js` to +`return 0` still crashes — the corruption is in the JSPI wrapping, not +the function's implementation. + +## Side Module JSPI Suspension Pattern + +Side modules import functions from the main module. When a side module +calls a blocking C function (e.g. `recv`), the call resolves to the main +module's compiled WASM implementation — NOT a JS import, so it cannot be +wrapped with `WebAssembly.Suspending`. The call returns immediately +(EAGAIN) instead of waiting for data. + +The general fix has three parts: + +1. **JS wrapper** in `phpwasm-emscripten-library.js`: + ```js + wasm_recv: function(sockfd, buf, len, flags) { + // Try synchronous recv; if EAGAIN, return Promise + }, + wasm_recv__async: true, + ``` + +2. **JSPI_IMPORTS** — add `wasm_recv` to the Dockerfile so the main + module imports it from JS. + +3. **Preprocessor redirect** — compile the side module with + `-Drecv=wasm_recv` so C `recv()` calls become `wasm_recv()`, resolving + to the JS import instead of the main module's WASM function. + +This pattern applies to ANY blocking C function a side module needs to +suspend on: `recv`, `select`, `sleep`, `read`, `connect`, etc. + +## Extension Loading Lifecycle + +Files (`.so` binary, ini config) must be written at a specific point: + +``` +loadNodeRuntime() → WASM loads, FS ready +new PHP(runtime) → initializeRuntime(), writes default php.ini + *** WRITE FILES HERE *** +php.run() → php_wasm_init() → php_module_startup() → reads ini +``` + +Two traps: +- **`loadNodeRuntime` overwrites `onRuntimeInitialized`** — it spreads + user options then sets its own callback AFTER the spread. Files written + in a user-provided callback are silently lost. No error, the extension + just never loads. +- **`preRun`** fires before `initRuntime()` / FS init. Writing files + there crashes. + +Use `php.writeFile()` / `php.readFileAsText()` after `new PHP(runtime)`. + +## Asyncify + SIDE_MODULE: `ASYNCIFY_IMPORTS` Is Required + +When compiling a side module with `-sASYNCIFY` and the module uses custom +renamed imports (e.g. `-Drecv=wasm_recv`), you MUST pass +`-sASYNCIFY_IMPORTS=`. + +Binaryen's Asyncify pass only knows about its default async imports +(`emscripten_sleep`, etc.). Custom import names are unknown to it. Without +`-sASYNCIFY_IMPORTS`, Binaryen won't instrument the call sites — no +save/restore of locals around the call. + +```dockerfile +export EMCC_FLAGS="-sSIDE_MODULE -sASYNCIFY -sASYNCIFY_IMPORTS=wasm_recv" +``` + +**Symptom:** `table index is out of bounds` during Asyncify **rewind** +(not unwind) at a side module offset that doesn't appear in the original +stack trace. Corrupt locals used as `call_indirect` table indices. + +Do NOT add `ASYNCIFY_EXPORTS` for imported functions — that flag is for +functions the module *exports*. + +### Verifying instrumentation + +Disassemble the `.so` before and after adding `ASYNCIFY_IMPORTS`: + +```bash +wasm-opt --print module.so | grep -c '__asyncify_state' +``` + +A correctly instrumented module has significantly more `__asyncify_state` +checks than an uninstrumented one. + +## Asyncify Shared Globals + +Side modules need to import `__asyncify_state` and `__asyncify_data` as +shared globals from the main module. The main module provides them as +`WebAssembly.Global` objects. Verify with: + +```js +WebAssembly.Module.imports(mod) + .filter(i => i.name.includes('asyncify')) +``` + +If these imports are missing, the side module's Asyncify instrumentation +has no shared state with the main module — unwind/rewind will silently +malfunction. + +## JSPI + C++ Side Modules: Weak Symbol Crashes + +C++ libraries may call syscalls like `close()` during internal operations +(e.g. after memory-mapping data files). When a side module makes such a +call, it can trigger JSPI suspension. This fails with +`SuspendError: trying to suspend JS frames` because C++ weak symbol `env` +imports are resolved through JS closure stubs in the dynamic linker, and +those JS frames block JSPI suspension. + +### Root cause + +When a side module imports C++ weak symbols (templates, inline functions, +virtual destructors) NOT present in the main module, Emscripten's dynamic +linker creates JS closure stubs that resolve lazily. Any JSPI suspension +in a call chain that includes these stubs fails. + +### Fix: two-pass instantiation (JSPI only) + +1. Instantiate the side module to get its exports +2. Add exports to `wasmImports` +3. Instantiate again with the enriched imports + +This pre-populates weak-symbol GOT entries, eliminating JS stubs. + +### Fix: patch C/C++ source (alternative) + +If the triggering syscall is non-essential (e.g. `close(fd)` on a file +descriptor only needed temporarily for `mmap`), patch the source to +remove it. Apply the patch in the extension's Dockerfile before +compilation. + +### Asyncify is NOT affected + +Asyncify's unwind/rewind mechanism operates within WASM code only — JS +closure stubs on the native call stack don't interfere. C++ side modules +with weak symbol `env` imports work correctly under Asyncify without +two-pass instantiation or source patching. + +## Web Platform: Dynamic Extension Loading + +On the web, `.so` files cannot be read from the filesystem. They must be +fetched via HTTP and written to the WASM virtual FS: + +```typescript +const extensionUrl = await getExtensionModule(version); +const extension = await (await fetch(extensionUrl)).arrayBuffer(); +phpRuntime.FS.writeFile( + '/internal/shared/extensions/extension.so', + new Uint8Array(extension) +); +``` + +Key differences from Node.js: +- **`fetch()` instead of `fs.readFileSync()`** for loading `.so` bytes +- **URL resolution via bundler** — use `assetsInclude: ['**/*.so']` in + Vite config so the bundler serves `.so` files +- **`MAIN_MODULE` required** — web builds need it too (was previously + node-only) +- **`ENVIRONMENT=web,worker`** — include `worker` so the PHP runtime + works in Web Workers + +## Verifying Extensions Actually Work + +`extension_loaded()` returning true only means MINIT succeeded. It does +NOT mean runtime features work. Always test actual functionality: + +- **Debugger (Xdebug):** set a breakpoint, verify it hits +- **intl:** run a collation or formatting operation +- **GD:** create an image, verify output bytes + +Simple operations may pass while complex ones crash. Different code paths +exercise different internal functions. + +## Version Coupling + +Each PHP version needs its own side module build. Zend API version +mismatch gives a clear error: "Extension requires Zend Engine API version +X, installed version is Y." + +The main module and side module MUST be compiled with the same Emscripten +version. Version mismatch causes function table corruption after `dlopen`. + +## Debugging Commands + +```bash +# Inspect side module symbols +wasm-objdump -x extension.so + +# Check what a side module imports/exports +node -e " + const fs = require('fs'); + const mod = new WebAssembly.Module(fs.readFileSync('extension.so')); + console.log('exports:', WebAssembly.Module.exports(mod).map(e => e.name)); + console.log('imports:', WebAssembly.Module.imports(mod).map(i => i.name)); +" + +# Verify Asyncify shared globals are imported +node -e " + const fs = require('fs'); + const mod = new WebAssembly.Module(fs.readFileSync('extension.so')); + console.log(WebAssembly.Module.imports(mod) + .filter(i => i.name.includes('asyncify'))); +" + +# Check object files in C++ library build (libtool often misses subdirs) +find . -path '*/.libs/*.o' -print + +# Verify the extension loads +node -e " + const { PHP } = require('@php-wasm/node'); + // ... load runtime, write .so, run php.run({ code: '' }) +" +``` + +## Diagnostic Cheat Sheet + +| Situation | Action | +|-----------|--------| +| `zend_mm_panic` after `zend_extension=` in ini | `_dlopen_js` is wrongly async — set `__async: false` | +| `dl()` works but ini loading crashes | Same cause — `_dlopen_js` async wrapping | +| `SuspendError: trying to suspend JS frames` from side module | C++ weak symbol stubs — use two-pass instantiation (JSPI) or patch source | +| `table index is out of bounds` during Asyncify rewind | Missing `ASYNCIFY_IMPORTS` in side module EMCC_FLAGS | +| `extension_loaded()` true but features crash | Test actual functionality, not just MINIT | +| `bad export type` during `dlopen` | Side module missing required symbol exports (`get_module`, `zif_*`) | +| Extension silently not loaded | Check file writing lifecycle — files may be written too early or overwritten | +| `Zend Engine API version mismatch` | Rebuild side module for the correct PHP version | +| Function table corruption after `dlopen` | Emscripten version mismatch between main and side module | +| `R_WASM_MEMORY_ADDR_SLEB` relocation error | Pre-built archive not PIC — rebuild from source | diff --git a/AGENTS.md b/AGENTS.md index caddf0d3f92..e6f6c084ee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,11 +52,9 @@ npm run typecheck # Type check all packages npm run format # Format code with Prettier npm run format:uncommitted # Format only uncommitted files -# PHP Recompilation (advanced) +# PHP Recompilation (see compile-php-wasm skill for details) npm run recompile:php:web # Recompile all PHP versions for web npm run recompile:php:node # Recompile all PHP versions for Node.js -npx nx recompile-php:jspi php-wasm-web -- --PHP_VERSION=8.4 -npx nx recompile-php:asyncify php-wasm-node -- --PHP_VERSION=8.3 # WordPress Builds npm run rebuild:wordpress-builds # Rebuild all WordPress versions @@ -236,21 +234,9 @@ npx nx dev playground-cli server --wp=6.8 --php=8.4 --auto-mount ### Working with PHP Binaries -PHP binaries are pre-compiled and committed to the repository. Recompilation is rarely needed but can be done with: - -```bash -# Recompile all PHP versions for web -npm run recompile:php:web - -# Recompile specific PHP version with JSPI -npx nx recompile-php:jspi php-wasm-web -- --PHP_VERSION=8.4 - -# Debug builds (with DWARF info) -npx nx recompile-php:all php-wasm-node -- --WITH_DEBUG=yes - -# Source maps for debugging -npx nx recompile-php:all php-wasm-node -- --WITH_SOURCEMAPS=yes -``` +PHP binaries are pre-compiled and committed to the repository. Recompilation is rarely needed. +For compilation commands, build flags, and troubleshooting, see the `compile-php-wasm` skill. +For debugging WASM crashes, see the `debug-php-wasm-main-module` and `debug-php-wasm-side-modules` skills. ### Custom NX Executors