Add SPX profiler extension support - #3255
Conversation
Add PHP-SPX (https://github.com/NoiseByNorthwest/php-spx) as an optional profiler extension for the Playground CLI. SPX provides full tracing profiling with a built-in web UI featuring flame charts, icicle charts, and function tables. The WASM build of SPX is cross-compiled from the upstream C source using Emscripten as a side module. Source and build infrastructure: https://github.com/mdrovdahl/spx-wasm Usage: npx wp-playground server --php=8.2 --spx # Profile: http://localhost:9400/?SPX_KEY=dev&SPX_ENABLED=1 # Web UI: http://localhost:9400/?SPX_KEY=dev&SPX_UI_URI=/ What works: - Full tracing profiler (wall time, Zend Engine memory metrics) - Flat profile and trace reporters - Built-in web UI served from the Emscripten VFS - Per-request report storage and data API WASM limitations: - Sampling profiler (requires pthreads, gracefully returns NULL) - RSS/IO tracking (no /proc filesystem) - CPU time (reports wall time in single-threaded WASM) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds optional PHP-SPX profiling support to the Playground CLI and php-wasm Node runtime, including a bundled SPX web UI served from the WASM VFS.
Changes:
- Introduces a
--spxCLI flag and threadswithSpxthrough v1/v2 workers. - Extends php-wasm runtime loader options with
withSpxand adds an SPX extension loader (module resolver + VFS/ini setup). - Adds prebuilt SPX artifacts for PHP 8.2 (binary + web UI assets for jspi/asyncify builds).
Reviewed changes
Copilot reviewed 31 out of 37 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/playground/cli/src/run-cli.ts | Adds --spx flag to CLI option parsing. |
| packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts | Threads withSpx through v2 worker boot args/options. |
| packages/playground/cli/src/blueprints-v2/blueprints-v2-handler.ts | Enables withSpx based on CLI args for v2 workers. |
| packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts | Threads withSpx into v1 worker runtime factory options. |
| packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts | Enables withSpx based on CLI args for v1 workers. |
| packages/php-wasm/node/src/lib/load-runtime.ts | Adds withSpx option and applies SPX extension setup during runtime load. |
| packages/php-wasm/node/src/lib/extensions/spx/with-spx.ts | Implements SPX extension loading + ini + VFS web UI/data dir setup. |
| packages/php-wasm/node/src/lib/extensions/spx/get-spx-extension-module.ts | Resolves SPX module artifacts by PHP version. |
| packages/php-wasm/node-builds/8-2/src/index.ts | Exposes paths for SPX .so and web UI assets (asyncify/jspi). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/report.html | Adds SPX report UI HTML (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/widget.js | Adds SPX UI widgets (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/utils.js | Adds SPX UI utilities (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/svg.js | Adds SPX UI SVG helpers (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/profileData.js | Adds SPX UI profile parsing/render model (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/math.js | Adds SPX UI math helpers (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/layoutSplitter.js | Adds SPX UI layout splitter logic (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/fmt.js | Adds SPX UI formatting helpers (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/js/dataTable.js | Adds SPX UI sortable table helper (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/index.html | Adds SPX control panel index UI (vendored). |
| packages/php-wasm/node-builds/8-2/jspi/extensions/spx/8_2/web-ui/css/main.css | Adds SPX UI styles (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/report.html | Adds SPX report UI HTML (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/utils.js | Adds SPX UI utilities (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/svg.js | Adds SPX UI SVG helpers (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/profileData.js | Adds SPX UI profile parsing/render model (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/math.js | Adds SPX UI math helpers (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/layoutSplitter.js | Adds SPX UI layout splitter logic (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/fmt.js | Adds SPX UI formatting helpers (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/dataTable.js | Adds SPX UI sortable table helper (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/index.html | Adds SPX control panel index UI (vendored). |
| packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/css/main.css | Adds SPX UI styles (vendored). |
Comments suppressed due to low confidence (2)
packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/math.js:1
Vec3.bound()currently callsbound(...)but discards the returned values, so vector components are never clamped. This breaks callers liketoHTMLColor()(especially aftermult(1.5)), leading to invalid/out-of-range RGB values. Assign the results back, e.g.this.x = bound(this.x, low, up)(same for y/z).
packages/php-wasm/node-builds/8-2/asyncify/extensions/spx/8_2/web-ui/js/profileData.js:1- Two issues: (1)
metricsInfois iterated as an array elsewhere, soObject.keys(this.metricsInfo)returns index strings (e.g.'0','1'), not metric keys—this should likely returnthis.metricsInfo.map((i) => i.key). (2) The thrown error references an undefined variablekey; it should usemetricso the error message is correct.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const pushTarget = Math.max(idx - 1, 0); | ||
| switch (elem.name) { | ||
| case 'push-down': | ||
| pushTarget = Math.min(idx + 1, categories.length - 1); |
There was a problem hiding this comment.
pushTarget is declared as const but then reassigned in the 'push-down' case, which will throw at runtime. Change it to let pushTarget = ...; if the fallthrough from 'push-down' to 'push-up' is intentional, consider adding an explicit comment to avoid it being “fixed” later, or add a break and duplicate the splice logic.
| const pushTarget = Math.max(idx - 1, 0); | |
| switch (elem.name) { | |
| case 'push-down': | |
| pushTarget = Math.min(idx + 1, categories.length - 1); | |
| let pushTarget = Math.max(idx - 1, 0); | |
| switch (elem.name) { | |
| case 'push-down': | |
| pushTarget = Math.min(idx + 1, categories.length - 1); | |
| // fall through to 'push-up' to apply splice with updated pushTarget |
There was a problem hiding this comment.
This is vendored upstream SPX code from NoiseByNorthwest/php-spx. These files are copied as-is from the SPX build output — not ours to modify here.
| bound(this.x, low, up); | ||
| bound(this.y, low, up); | ||
| bound(this.z, low, up); |
There was a problem hiding this comment.
Vec3.bound() currently calls bound(...) but discards the returned values, so vector components are never clamped. This breaks callers like toHTMLColor() (especially after mult(1.5)), leading to invalid/out-of-range RGB values. Assign the results back, e.g. this.x = bound(this.x, low, up) (same for y/z).
| bound(this.x, low, up); | |
| bound(this.y, low, up); | |
| bound(this.z, low, up); | |
| this.x = bound(this.x, low, up); | |
| this.y = bound(this.y, low, up); | |
| this.z = bound(this.z, low, up); |
There was a problem hiding this comment.
This is vendored upstream SPX code from NoiseByNorthwest/php-spx. These files are copied as-is from the SPX build output and are not ours to modify. Any fixes should go upstream to the php-spx project.
| getMetricKeys() { | ||
| return Object.keys(this.metricsInfo); | ||
| } | ||
|
|
||
| getMetricInfo(metric) { | ||
| for (let info of this.metricsInfo) { | ||
| if (info.key == metric) { | ||
| return info; | ||
| } | ||
| } | ||
|
|
||
| throw new Error('Unknown metric: ' + key); | ||
| } |
There was a problem hiding this comment.
Two issues: (1) metricsInfo is iterated as an array elsewhere, so Object.keys(this.metricsInfo) returns index strings (e.g. '0', '1'), not metric keys—this should likely return this.metricsInfo.map((i) => i.key). (2) The thrown error references an undefined variable key; it should use metric so the error message is correct.
There was a problem hiding this comment.
This is vendored upstream SPX code from NoiseByNorthwest/php-spx. These files are copied as-is from the SPX build output and are not ours to modify. Any fixes should go upstream to the php-spx project.
| return { | ||
| ...options, | ||
| ENV: { | ||
| ...options.ENV, | ||
| PHP_INI_SCAN_DIR: '/internal/shared/extensions', |
There was a problem hiding this comment.
Setting PHP_INI_SCAN_DIR to a fixed path can clobber an existing scan dir from options.ENV (and any upstream defaults), potentially preventing other .ini files from being discovered. Prefer appending the SPX extension ini directory to the existing PHP_INI_SCAN_DIR value (using : as PHP’s separator) rather than replacing it.
| return { | |
| ...options, | |
| ENV: { | |
| ...options.ENV, | |
| PHP_INI_SCAN_DIR: '/internal/shared/extensions', | |
| const spxIniDir = '/internal/shared/extensions'; | |
| const existingScanDir = | |
| options.ENV && typeof options.ENV.PHP_INI_SCAN_DIR === 'string' | |
| ? options.ENV.PHP_INI_SCAN_DIR | |
| : undefined; | |
| return { | |
| ...options, | |
| ENV: { | |
| ...options.ENV, | |
| PHP_INI_SCAN_DIR: existingScanDir | |
| ? `${existingScanDir}:${spxIniDir}` | |
| : spxIniDir, |
There was a problem hiding this comment.
This follows the same pattern used by all other Playground extensions — withXdebug, withRedis, withMemcached, and withIntl all set PHP_INI_SCAN_DIR to the same fixed path (/internal/shared/extensions) in the same way. See for example with-xdebug.ts. This is the established convention in the codebase for extension loading.
| [ | ||
| 'extension=/internal/shared/extensions/spx.so', | ||
| `spx.data_dir=${SPX_VFS_DATA_DIR}`, | ||
| 'spx.http_enabled=1', | ||
| 'spx.http_key=dev', | ||
| 'spx.http_ip_whitelist=*', | ||
| `spx.http_ui_assets_dir=${SPX_VFS_WEB_UI_DIR}`, | ||
| ].join('\n') |
There was a problem hiding this comment.
The default ini enables the HTTP UI with a fixed key (dev) and an unrestricted whitelist (*). If the Playground server is bound to a non-loopback interface, this exposes profiling data/UI to the local network by default when --spx is used. Consider defaulting spx.http_ip_whitelist to loopback only (e.g. 127.0.0.1) and generating a per-run random spx.http_key (or allowing it to be configured via CLI/env) to reduce exposure.
There was a problem hiding this comment.
This is a local development profiling tool, gated behind the --spx CLI flag (off by default). The spx.http_key=dev and spx.http_ip_whitelist=* settings follow SPX's own defaults for development use. The Playground CLI server binds to 127.0.0.1 by default. These settings are consistent with a dev-only profiler — there's no production deployment path for this.
| function copyDirToVFS(phpRuntime: PHPRuntime, hostDir: string, vfsDir: string) { | ||
| if (!FSHelpers.fileExists(phpRuntime.FS, vfsDir)) { | ||
| phpRuntime.FS.mkdirTree(vfsDir); | ||
| } | ||
| for (const entry of fs.readdirSync(hostDir, { withFileTypes: true })) { | ||
| const hostPath = path.join(hostDir, entry.name); | ||
| const vfsPath = `${vfsDir}/${entry.name}`; | ||
| if (entry.isDirectory()) { | ||
| copyDirToVFS(phpRuntime, hostPath, vfsPath); | ||
| } else if (entry.isFile()) { | ||
| if (!FSHelpers.fileExists(phpRuntime.FS, vfsPath)) { | ||
| phpRuntime.FS.writeFile( | ||
| vfsPath, | ||
| new Uint8Array(fs.readFileSync(hostPath)) | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This uses synchronous, recursive filesystem reads during onRuntimeInitialized, which can block the event loop on startup (especially if the UI asset directory grows). Consider switching to an async copy (preload assets earlier), batching large reads, or packaging assets into a single archive to reduce FS overhead during initialization.
There was a problem hiding this comment.
This follows the same pattern used by all other Playground extensions. See with-xdebug.ts, with-redis.ts, and with-memcached.ts — they all use synchronous reads in onRuntimeInitialized to load extension .so files and assets into the Emscripten VFS. The callback is synchronous by design.
| switch (version) { | ||
| case '8.2': { | ||
| // @ts-ignore | ||
| const mod = await import('@php-wasm/node-8-2'); | ||
| return { | ||
| extensionPath: await mod.getSpxExtensionPath(), | ||
| webUiPath: await mod.getSpxWebUiPath(), | ||
| }; | ||
| } | ||
| } | ||
| throw new Error(`SPX extension not available for PHP ${version}`); |
There was a problem hiding this comment.
Using // @ts-ignore for the dynamic import makes it easy to miss genuine typing issues and diverges from the pattern used elsewhere (e.g. the runtime loader module resolver). Prefer adding proper module typings (or a PHPLoaderModule-style interface) and importing with an explicit type annotation instead of suppressing TypeScript.
There was a problem hiding this comment.
This follows the same pattern used by all other extension module resolvers in the codebase. See get-xdebug-extension-module.ts, get-redis-extension-module.ts, etc. — they all use // @ts-ignore on the dynamic import because the path is resolved at build time from the node-builds package.
| spx: { | ||
| describe: 'Enable SPX profiler.', | ||
| type: 'boolean', | ||
| default: false, | ||
| }, |
There was a problem hiding this comment.
The spx option definition is duplicated in two option blocks. To avoid future drift (e.g. different description/default), consider defining it once (similar to other shared options) and referencing it in both places.
There was a problem hiding this comment.
This follows the same pattern used for all other extension CLI options in this file. The xdebug, intl, redis, and memcached options are also duplicated across the sharedOptions and serverOnlyOptions blocks. If this should be refactored, it would be a broader cleanup across all extensions, not specific to SPX.
| spx: { | ||
| describe: 'Enable SPX profiler.', | ||
| type: 'boolean', | ||
| default: false, | ||
| }, |
There was a problem hiding this comment.
The spx option definition is duplicated in two option blocks. To avoid future drift (e.g. different description/default), consider defining it once (similar to other shared options) and referencing it in both places.
| spx: { | |
| describe: 'Enable SPX profiler.', | |
| type: 'boolean', | |
| default: false, | |
| }, | |
| spx: sharedOptions.spx, |
There was a problem hiding this comment.
Same as above — this duplication mirrors the existing pattern for all other extension options (xdebug, intl, redis, memcached) in this file.
|
@mdrovdahl There's a new pull request in review here which let you load your custom PHP.wasm extension. It is specifically built for use cases like yours. We still need to check if it works for your use case with extra files but I think you can give it a try when it is merged. By the way, thank you for your work! It helped us investigate in external custom extensions support. |
## What it does Adds manifest-declared sidecar files to the PHP extension loading API introduced in #3566, and folds in #3590's generated AJV validator for extension manifests. A manifest can now describe files and directories that must be staged before PHP starts, alongside the selected `.so` artifact. Each node's final VFS path is `vfsRoot + vfsPath`: ```json { "name": "spx", "artifacts": [ { "phpVersion": "8.4", "sourcePath": "spx.so", "extraFiles": { "vfsRoot": "/internal/shared/spx", "nodes": [ { "vfsPath": "data", "type": "directory" }, { "vfsPath": "ui/index.html", "sourcePath": "ui/index.html" } ] } } ] } ``` ## Rationale #3255 needs SPX to load more than a WASM side module. The extension also expects a writable data directory and web UI assets such as HTML, CSS, and JS files. This PR keeps that work focused on the loader contract. It does **not** rebuild PHP 8.2, ship SPX artifacts, or claim that SPX loads today. A follow-up can use this manifest shape when adding or rebuilding SPX artifacts. ## Integration For most consumers nothing changes structurally — manifest sources slot into the same `extensions` array as bundled extension names: ```ts await loadNodeRuntime('8.4', { extensions: [ 'intl', 'xdebug', { source: { format: 'manifest', manifestUrl: 'https://example.com/spx/manifest.json' } }, ], }); ``` For hand-rolled Emscripten setups, `resolvePHPExtension` + `withResolvedPHPExtensions` in `@php-wasm/universal` are the lower-level pair. ## Implementation `PHPExtensionManifest` accepts `extraFiles` at two levels: - manifest-level `extraFiles` for files shared by every artifact - artifact-level `extraFiles` for PHP-version-specific files Each group has a `vfsRoot` (the absolute VFS prefix) and an array of `nodes` with a `vfsPath`, an optional `type: 'file' | 'directory'`, and a `sourcePath` (the URL to fetch, only required for files). The resolver joins `vfsRoot + vfsPath` into absolute VFS paths and returns: ```ts { files: { '/internal/shared/spx/ui/index.html': bytes }, directories: ['/internal/shared/spx/data'], } ``` The resolver validates untrusted manifest JSON with a standalone AJV validator generated from the public `PHPExtensionManifest` TypeScript type. Sidecar files are fetched relative to the manifest URL or inline manifest `baseUrl`, with manifest-level and selected artifact-level nodes fetched in parallel through a shared concurrency limit. Caller-supplied `extraFiles` on `PHPExtensionInstallOptions` use the same absolute-path shape (`{ files, directories? }`) — no `targetPath` indirection. External extension manifests remain JSPI-only. ## Breaking changes - Manifest field renames: `artifact.file` → `artifact.sourcePath`; `extraFiles.targetPath` → `vfsRoot`; `extraFiles.files`/`extraFiles.directories` → unified `nodes` array with `vfsPath`/`type`/`sourcePath`. - `PHPExtensionExtraFiles` (caller-supplied sidecar files) is now flat absolute VFS paths under `files` and `directories`. The previous `targetPath + relative paths` shape is gone. - `sha256` fields removed from sources and artifacts. - `ResolvePHPExtensionOptions` is no longer exported (use `PHPExtensionInstallOptions & { phpVersion: string }`). ## Testing instructions \`\`\`bash npm exec nx -- test php-wasm-universal --testFile=load-extension.spec.ts npm exec nx -- typecheck php-wasm-universal npm exec nx -- lint php-wasm-universal npm exec nx -- build php-wasm-universal cd packages/php-wasm/node && npx vitest run src/test/with-php-extensions.spec.ts --config vite.jspi.config.ts npm exec nx -- typecheck php-wasm-node npm exec nx -- lint php-wasm-node npm exec nx -- typecheck php-wasm-web \`\`\`
|
Closing this pull request now that the |
Note- this is exploratory, all code was written by Claude and has not been reviewed by a human. Experiment is to make PHP-SPX run in Playground.
Summary
Usage
What works
WASM limitations
Changes
packages/php-wasm/node-builds/8-2/— pre-builtspx.sobinary (72KB) and web UI assets for asyncify and jspipackages/php-wasm/node/src/lib/extensions/spx/—with-spx.ts(VFS loader) andget-spx-extension-module.ts(version resolver)packages/php-wasm/node/src/lib/load-runtime.ts—withSpxoption inPHPLoaderOptionspackages/playground/cli/src/run-cli.ts—--spxCLI flagpackages/playground/cli/src/blueprints-v1/*— passwithSpxthrough v1 workerspackages/playground/cli/src/blueprints-v2/*— passwithSpxthrough v2 workersTest plan
npx nx dev playground-cli server --php=8.2 --spx --loginstarts without errors/?SPX_KEY=dev&SPX_ENABLED=1profiles the request/?SPX_KEY=dev&SPX_UI_URI=/shows the SPX web UI--spxis unaffectedDisclaimer
This work was produced with Claude Code (Claude Opus 4.6) and Gas Town. The codebase has not been fully reviewed by a human.
🤖 Generated with Claude Code