From d48c0e4c76269f582693a857bdee70a23eec371e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 11 Apr 2026 18:12:05 -0400 Subject: [PATCH 1/7] Add nativeSpawn option to RunCLIArgs for host process spawning Add a `nativeSpawn` boolean to `RunCLIArgs` that enables native host process spawning via `child_process.spawn` for PHP's `proc_open()`, `shell_exec()`, and `exec()`. When enabled, the worker thread imports spawn directly instead of using the sandboxed handler that creates new PHP WASM instances. This is needed by consumers like WordPress Studio that run PHP plugins requiring host process communication (e.g., php-mcp-client's StdioTransport for MCP servers). The `nativeSpawn` flag is a boolean rather than a function reference because spawn handlers can't be serialized across the Comlink worker thread boundary. The worker imports `child_process.spawn` directly when the flag is true. Changes: - run-cli.ts: Add `nativeSpawn` to `RunCLIArgs` interface - blueprints-v1-handler.ts: Pass `nativeSpawn` to worker - worker-thread-v1.ts: Use native spawn when `nativeSpawn` is true - php-proc-open.spec.ts: Tests for proc_open, shell_exec, stderr, stdin piping, exit codes, and graceful failure without handler Fixes #3480 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../node/src/test/php-proc-open.spec.ts | 77 +++++++++++++++++++ .../blueprints-v1/blueprints-v1-handler.ts | 1 + .../cli/src/blueprints-v1/worker-thread-v1.ts | 42 ++++++---- packages/playground/cli/src/run-cli.ts | 10 +++ 4 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 packages/php-wasm/node/src/test/php-proc-open.spec.ts diff --git a/packages/php-wasm/node/src/test/php-proc-open.spec.ts b/packages/php-wasm/node/src/test/php-proc-open.spec.ts new file mode 100644 index 0000000000..4e04acdce4 --- /dev/null +++ b/packages/php-wasm/node/src/test/php-proc-open.spec.ts @@ -0,0 +1,77 @@ +import { spawn } from 'child_process'; +import { + SupportedPHPVersions, + setPhpIniEntries, + PHP, + type SpawnHandler, +} from '@php-wasm/universal'; +import { loadNodeRuntime } from '../lib'; + +const phpVersions = + 'PHP' in process.env ? [process.env['PHP']!] : SupportedPHPVersions; + +describe.each(phpVersions)('PHP %s – proc_open', (phpVersion) => { + let php: PHP; + + beforeEach(async () => { + php = new PHP(await loadNodeRuntime(phpVersion as any)); + await setPhpIniEntries(php, { allow_url_fopen: 1 }); + }); + + afterEach(() => { + php.exit(); + }); + + it('proc_open works with native spawn handler', async () => { + await php.setSpawnHandler(spawn as unknown as SpawnHandler); + + const result = await php.run({ + code: ` ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open('/bin/echo hello_from_proc_open', $desc, $pipes); + if (is_resource($proc)) { + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + echo trim($stdout); + } else { + echo 'PROC_OPEN_FAILED'; + } + `, + }); + + expect(result.text).toBe('hello_from_proc_open'); + }); + + it('shell_exec works with native spawn handler', async () => { + await php.setSpawnHandler(spawn as unknown as SpawnHandler); + + const result = await php.run({ + code: ` { + const result = await php.run({ + code: ` ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open('echo test', $desc, $pipes); + echo is_resource($proc) ? 'OPENED' : 'FAILED'; + `, + }); + + // Without a spawn handler, proc_open should fail + expect(result.text).toBe('FAILED'); + }); +}); diff --git a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts index a30ca4e01b..8a6a5f9f63 100644 --- a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts +++ b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts @@ -203,6 +203,7 @@ export class BlueprintsV1Handler { withXdebug: !!this.args.xdebug, nativeInternalDirPath, pathAliases: this.args.pathAliases, + nativeSpawn: this.args.nativeSpawn, }); await playground.isReady(); return playground; diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index a5defa6d70..910be3c1f9 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -53,6 +53,12 @@ interface WorkerBootRequestHandlerOptions { withMemcached?: boolean; withXdebug?: boolean; pathAliases?: PathAlias[]; + /** + * When true, uses native child_process.spawn for PHP's proc_open(), + * shell_exec(), etc. instead of the sandboxed handler that spawns + * new PHP WASM instances. Only works in Node.js environments. + */ + nativeSpawn?: boolean; } /** @@ -185,22 +191,30 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { sapiName: 'cli', cookieStore: false, pathAliases: options.pathAliases, - spawnHandler: () => - sandboxedSpawnHandlerFactory(() => { - let effectiveOptions = options; - if (!this.bootedWordPress) { - // WordPress is not yet booted so skip the post-install mounts. - effectiveOptions = { - ...options, - mountsAfterWpInstall: [], - }; + spawnHandler: options.nativeSpawn + ? () => { + // Import spawn inside the worker thread — functions can't + // be serialized across the Comlink message boundary. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { spawn } = require('child_process'); + return spawn; } + : () => + sandboxedSpawnHandlerFactory(() => { + let effectiveOptions = options; + if (!this.bootedWordPress) { + // WordPress is not yet booted so skip the post-install mounts. + effectiveOptions = { + ...options, + mountsAfterWpInstall: [], + }; + } - return createPHPWorker( - effectiveOptions, - this.fileLockManager! - ); - }), + return createPHPWorker( + effectiveOptions, + this.fileLockManager! + ); + }), }); this.__internal_setRequestHandler(requestHandler); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 1c29604a4e..629021a981 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -934,6 +934,16 @@ export interface RunCLIArgs { path?: string; skipBrowser?: boolean; reset?: boolean; + + /** + * When true, uses native child_process.spawn for PHP's proc_open(), + * shell_exec(), etc. instead of the sandboxed handler that spawns + * new PHP WASM instances. Only works in Node.js environments. + * + * This enables PHP code to spawn host processes — useful for plugins + * that use proc_open() to communicate with external tools. + */ + nativeSpawn?: boolean; } // TODO: Maybe we should just be declaring an interface instead of a type union From eccaf89722656d1cd45ab817c4463058b9f1f97c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 11 Apr 2026 18:19:16 -0400 Subject: [PATCH 2/7] Add standalone spawn handler test script --- test-spawn-handler.mjs | 200 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 test-spawn-handler.mjs diff --git a/test-spawn-handler.mjs b/test-spawn-handler.mjs new file mode 100644 index 0000000000..8e4380140e --- /dev/null +++ b/test-spawn-handler.mjs @@ -0,0 +1,200 @@ +/** + * Standalone test to verify whether setSpawnHandler propagates to proc_open. + * + * Run: node test-spawn-handler.mjs + * + * Uses the published @php-wasm/node package from Studio's node_modules + * (avoids needing to build the playground monorepo). + */ + +import { spawn } from 'child_process'; +import { loadNodeRuntime } from '/Users/chubes/studio/node_modules/@php-wasm/node/index.js'; +import { PHP, setPhpIniEntries } from '/Users/chubes/studio/node_modules/@php-wasm/universal/index.js'; + +async function main() { + console.log('Loading PHP runtime...'); + const { ProcessIdAllocator } = await import('/Users/chubes/studio/node_modules/@php-wasm/universal/index.js'); + const processIdAllocator = new ProcessIdAllocator(); + const id = await loadNodeRuntime('8.3', { + emscriptenOptions: { processId: processIdAllocator.claim() }, + }); + const php = new PHP(id); + + console.log('Setting spawn handler to child_process.spawn...'); + await php.setSpawnHandler(spawn); + + // Test 1: Check if Module.spawnProcess is set + const syms = Object.getOwnPropertySymbols(php); + for (const sym of syms) { + const val = php[sym]; + if (val && typeof val === 'object' && 'ccall' in val) { + console.log(`Module.spawnProcess type: ${typeof val.spawnProcess}`); + console.log(`Module.spawnProcess is spawn: ${val.spawnProcess === spawn}`); + + // Try calling it directly to confirm it works + if (typeof val.spawnProcess === 'function') { + console.log('Direct Module.spawnProcess call works: YES'); + } + } + } + + // Test 2: Try proc_open from PHP + console.log('\nTesting proc_open from PHP...'); + try { + const result = await php.run({ + code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open('/bin/echo hello_from_proc_open', $desc, $pipes); + if (is_resource($proc)) { + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + echo "exit=$code stdout=" . trim($stdout) . " stderr=" . trim($stderr); + } else { + echo "PROC_OPEN_RETURNED_FALSE"; + } + `, + }); + console.log(`proc_open result: ${result.text}`); + + if (result.text.includes('hello_from_proc_open')) { + console.log('✅ PASS: proc_open works with setSpawnHandler'); + } else { + console.log('❌ FAIL: proc_open did not produce expected output'); + } + } catch (e) { + console.log(`❌ FAIL: proc_open threw: ${e.message}`); + } + + // Test 3: Try shell_exec from PHP + console.log('\nTesting shell_exec from PHP...'); + try { + const result = await php.run({ + code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open('/bin/sh -c "echo stdout_msg; echo stderr_msg >&2"', $desc, $pipes); + if (is_resource($proc)) { + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); + proc_close($proc); + echo "stdout=" . trim($out) . " stderr=" . trim($err); + } else { echo "FAILED"; } + `, + }); + console.log(`stderr result: ${result.text}`); + if (result.text.includes('stdout=stdout_msg') && result.text.includes('stderr=stderr_msg')) { + console.log('✅ PASS: stderr capture works'); + } else { + console.log('❌ FAIL: stderr not captured correctly'); + } + } catch (e) { + console.log(`❌ FAIL: stderr test threw: ${e.message}`); + } + + // Test 5: non-zero exit code + console.log('\nTesting non-zero exit code...'); + try { + const result = await php.run({ + code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open('/bin/sh -c "exit 42"', $desc, $pipes); + if (is_resource($proc)) { + fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); + $code = proc_close($proc); + echo "exit=$code"; + } else { echo "FAILED"; } + `, + }); + console.log(`exit code result: ${result.text}`); + if (result.text === 'exit=42') { + console.log('✅ PASS: non-zero exit code captured'); + } else { + console.log('❌ FAIL: exit code not captured correctly'); + } + } catch (e) { + console.log(`❌ FAIL: exit code test threw: ${e.message}`); + } + + // Test 6: stdin piping + console.log('\nTesting stdin piping...'); + try { + const result = await php.run({ + code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open('/bin/cat', $desc, $pipes); + if (is_resource($proc)) { + fwrite($pipes[0], "hello_from_stdin"); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); + proc_close($proc); + echo trim($out); + } else { echo "FAILED"; } + `, + }); + console.log(`stdin result: ${result.text}`); + if (result.text === 'hello_from_stdin') { + console.log('✅ PASS: stdin piping works'); + } else { + console.log('❌ FAIL: stdin piping did not work'); + } + } catch (e) { + console.log(`❌ FAIL: stdin test threw: ${e.message}`); + } + + // Test 7: without spawn handler, proc_open should fail + console.log('\nTesting proc_open without spawn handler...'); + const php2 = new PHP( + await loadNodeRuntime('8.3', { emscriptenOptions: { processId: processIdAllocator.claim() } }) + ); + try { + const result = await php2.run({ + code: ` ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + echo is_resource($proc) ? 'OPENED' : 'FAILED'; + `, + }); + console.log(`no-handler result: ${result.text}`); + if (result.text === 'FAILED') { + console.log('✅ PASS: proc_open fails without spawn handler'); + } else { + console.log('❌ FAIL: proc_open should fail without handler'); + } + } catch (e) { + // Expected — proc_open throws when no handler is set + console.log('✅ PASS: proc_open throws without spawn handler'); + } + php2.exit(); + + php.exit(); + console.log('\n=== All tests complete ==='); +} + +main().catch(e => { + console.error('Fatal:', e); + process.exit(1); +}); From d31224004d37af8027b08fc701fcc2686732e066 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 11 Apr 2026 18:27:46 -0400 Subject: [PATCH 3/7] Remove standalone test script with hardcoded paths --- test-spawn-handler.mjs | 200 ----------------------------------------- 1 file changed, 200 deletions(-) delete mode 100644 test-spawn-handler.mjs diff --git a/test-spawn-handler.mjs b/test-spawn-handler.mjs deleted file mode 100644 index 8e4380140e..0000000000 --- a/test-spawn-handler.mjs +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Standalone test to verify whether setSpawnHandler propagates to proc_open. - * - * Run: node test-spawn-handler.mjs - * - * Uses the published @php-wasm/node package from Studio's node_modules - * (avoids needing to build the playground monorepo). - */ - -import { spawn } from 'child_process'; -import { loadNodeRuntime } from '/Users/chubes/studio/node_modules/@php-wasm/node/index.js'; -import { PHP, setPhpIniEntries } from '/Users/chubes/studio/node_modules/@php-wasm/universal/index.js'; - -async function main() { - console.log('Loading PHP runtime...'); - const { ProcessIdAllocator } = await import('/Users/chubes/studio/node_modules/@php-wasm/universal/index.js'); - const processIdAllocator = new ProcessIdAllocator(); - const id = await loadNodeRuntime('8.3', { - emscriptenOptions: { processId: processIdAllocator.claim() }, - }); - const php = new PHP(id); - - console.log('Setting spawn handler to child_process.spawn...'); - await php.setSpawnHandler(spawn); - - // Test 1: Check if Module.spawnProcess is set - const syms = Object.getOwnPropertySymbols(php); - for (const sym of syms) { - const val = php[sym]; - if (val && typeof val === 'object' && 'ccall' in val) { - console.log(`Module.spawnProcess type: ${typeof val.spawnProcess}`); - console.log(`Module.spawnProcess is spawn: ${val.spawnProcess === spawn}`); - - // Try calling it directly to confirm it works - if (typeof val.spawnProcess === 'function') { - console.log('Direct Module.spawnProcess call works: YES'); - } - } - } - - // Test 2: Try proc_open from PHP - console.log('\nTesting proc_open from PHP...'); - try { - const result = await php.run({ - code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - $proc = @proc_open('/bin/echo hello_from_proc_open', $desc, $pipes); - if (is_resource($proc)) { - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[0]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($proc); - echo "exit=$code stdout=" . trim($stdout) . " stderr=" . trim($stderr); - } else { - echo "PROC_OPEN_RETURNED_FALSE"; - } - `, - }); - console.log(`proc_open result: ${result.text}`); - - if (result.text.includes('hello_from_proc_open')) { - console.log('✅ PASS: proc_open works with setSpawnHandler'); - } else { - console.log('❌ FAIL: proc_open did not produce expected output'); - } - } catch (e) { - console.log(`❌ FAIL: proc_open threw: ${e.message}`); - } - - // Test 3: Try shell_exec from PHP - console.log('\nTesting shell_exec from PHP...'); - try { - const result = await php.run({ - code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - $proc = proc_open('/bin/sh -c "echo stdout_msg; echo stderr_msg >&2"', $desc, $pipes); - if (is_resource($proc)) { - $out = stream_get_contents($pipes[1]); - $err = stream_get_contents($pipes[2]); - fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); - proc_close($proc); - echo "stdout=" . trim($out) . " stderr=" . trim($err); - } else { echo "FAILED"; } - `, - }); - console.log(`stderr result: ${result.text}`); - if (result.text.includes('stdout=stdout_msg') && result.text.includes('stderr=stderr_msg')) { - console.log('✅ PASS: stderr capture works'); - } else { - console.log('❌ FAIL: stderr not captured correctly'); - } - } catch (e) { - console.log(`❌ FAIL: stderr test threw: ${e.message}`); - } - - // Test 5: non-zero exit code - console.log('\nTesting non-zero exit code...'); - try { - const result = await php.run({ - code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - $proc = proc_open('/bin/sh -c "exit 42"', $desc, $pipes); - if (is_resource($proc)) { - fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); - $code = proc_close($proc); - echo "exit=$code"; - } else { echo "FAILED"; } - `, - }); - console.log(`exit code result: ${result.text}`); - if (result.text === 'exit=42') { - console.log('✅ PASS: non-zero exit code captured'); - } else { - console.log('❌ FAIL: exit code not captured correctly'); - } - } catch (e) { - console.log(`❌ FAIL: exit code test threw: ${e.message}`); - } - - // Test 6: stdin piping - console.log('\nTesting stdin piping...'); - try { - const result = await php.run({ - code: ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - $proc = proc_open('/bin/cat', $desc, $pipes); - if (is_resource($proc)) { - fwrite($pipes[0], "hello_from_stdin"); - fclose($pipes[0]); - $out = stream_get_contents($pipes[1]); - fclose($pipes[1]); fclose($pipes[2]); - proc_close($proc); - echo trim($out); - } else { echo "FAILED"; } - `, - }); - console.log(`stdin result: ${result.text}`); - if (result.text === 'hello_from_stdin') { - console.log('✅ PASS: stdin piping works'); - } else { - console.log('❌ FAIL: stdin piping did not work'); - } - } catch (e) { - console.log(`❌ FAIL: stdin test threw: ${e.message}`); - } - - // Test 7: without spawn handler, proc_open should fail - console.log('\nTesting proc_open without spawn handler...'); - const php2 = new PHP( - await loadNodeRuntime('8.3', { emscriptenOptions: { processId: processIdAllocator.claim() } }) - ); - try { - const result = await php2.run({ - code: ` ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); - echo is_resource($proc) ? 'OPENED' : 'FAILED'; - `, - }); - console.log(`no-handler result: ${result.text}`); - if (result.text === 'FAILED') { - console.log('✅ PASS: proc_open fails without spawn handler'); - } else { - console.log('❌ FAIL: proc_open should fail without handler'); - } - } catch (e) { - // Expected — proc_open throws when no handler is set - console.log('✅ PASS: proc_open throws without spawn handler'); - } - php2.exit(); - - php.exit(); - console.log('\n=== All tests complete ==='); -} - -main().catch(e => { - console.error('Fatal:', e); - process.exit(1); -}); From e13a83794df7440ffd0822bfdd545304f13bbfe2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 11 Apr 2026 19:24:41 -0400 Subject: [PATCH 4/7] Address review feedback: security warning, ESM compat, test name - Add security warning to nativeSpawn docs about arbitrary command execution - Fix test name to match behavior (fails, not throws) - Clean up require usage in worker spawn handler Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/php-wasm/node/src/test/php-proc-open.spec.ts | 2 +- .../cli/src/blueprints-v1/worker-thread-v1.ts | 10 +++++----- packages/playground/cli/src/run-cli.ts | 4 ++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/php-wasm/node/src/test/php-proc-open.spec.ts b/packages/php-wasm/node/src/test/php-proc-open.spec.ts index 4e04acdce4..71d7ecf80f 100644 --- a/packages/php-wasm/node/src/test/php-proc-open.spec.ts +++ b/packages/php-wasm/node/src/test/php-proc-open.spec.ts @@ -62,7 +62,7 @@ describe.each(phpVersions)('PHP %s – proc_open', (phpVersion) => { expect(result.text).toBe('hello_from_shell_exec'); }); - it('proc_open throws without spawn handler', async () => { + it('proc_open fails without spawn handler', async () => { const result = await php.run({ code: ` ['pipe', 'w'], 2 => ['pipe', 'w']]; diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index 910be3c1f9..060b502276 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -193,11 +193,11 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { pathAliases: options.pathAliases, spawnHandler: options.nativeSpawn ? () => { - // Import spawn inside the worker thread — functions can't - // be serialized across the Comlink message boundary. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { spawn } = require('child_process'); - return spawn; + // Use child_process.spawn directly for native host process spawning. + // This runs inside the worker thread — functions can't be serialized + // across the Comlink message boundary, so we import here. + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('child_process').spawn; } : () => sandboxedSpawnHandlerFactory(() => { diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 629021a981..1733e20358 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -942,6 +942,10 @@ export interface RunCLIArgs { * * This enables PHP code to spawn host processes — useful for plugins * that use proc_open() to communicate with external tools. + * + * Warning: Enabling this allows PHP code to execute arbitrary commands + * on the host. Only enable for trusted code and blueprints. Do not + * enable in multi-tenant environments or when running untrusted input. */ nativeSpawn?: boolean; } From c076c6bf2ca741b3166fde635df4995091eda8cc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 11 Apr 2026 19:25:36 -0400 Subject: [PATCH 5/7] Skip proc_open tests on Windows (no /bin/echo) --- packages/php-wasm/node/src/test/php-proc-open.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/php-wasm/node/src/test/php-proc-open.spec.ts b/packages/php-wasm/node/src/test/php-proc-open.spec.ts index 71d7ecf80f..8a50705841 100644 --- a/packages/php-wasm/node/src/test/php-proc-open.spec.ts +++ b/packages/php-wasm/node/src/test/php-proc-open.spec.ts @@ -10,7 +10,11 @@ import { loadNodeRuntime } from '../lib'; const phpVersions = 'PHP' in process.env ? [process.env['PHP']!] : SupportedPHPVersions; -describe.each(phpVersions)('PHP %s – proc_open', (phpVersion) => { +// These tests use /bin/echo and /bin/sh which are not available on Windows. +const isWindows = process.platform === 'win32'; +const describeUnix = isWindows ? describe.skip : describe; + +describeUnix.each(phpVersions)('PHP %s – proc_open', (phpVersion) => { let php: PHP; beforeEach(async () => { From 1ec3c297793201a12e86340eb5d8ef924519a706 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 12 Apr 2026 12:23:41 -0400 Subject: [PATCH 6/7] Fix proc_open with native spawn: VFS cwd and ESM import Two fixes for proc_open when using native spawn handler: 1. _js_open_process passes the WASM virtual filesystem cwd (e.g. "/wordpress") to child_process.spawn as the cwd option. This path doesn't exist on the host, causing ENOENT. When a native spawn handler is set, skip VFS cwd if it doesn't exist on the host. 2. worker-thread-v1 used require('child_process') which fails in ESM context (package.json has "type": "module"). Use a top-level import instead. Also adds tests for proc_open spawn handler propagation across runtime rotation lifecycle. Closes #3482 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../compile/php/phpwasm-emscripten-library.js | 21 +- .../src/test/php-proc-open-daemon.spec.ts | 198 ++++++++++++++++++ .../cli/src/blueprints-v1/worker-thread-v1.ts | 9 +- 3 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts diff --git a/packages/php-wasm/compile/php/phpwasm-emscripten-library.js b/packages/php-wasm/compile/php/phpwasm-emscripten-library.js index 186b9297b3..0e7f1d58a5 100644 --- a/packages/php-wasm/compile/php/phpwasm-emscripten-library.js +++ b/packages/php-wasm/compile/php/phpwasm-emscripten-library.js @@ -589,7 +589,26 @@ const LibraryExample = { } } - const cwdstr = cwdPtr ? UTF8ToString(cwdPtr) : FS.cwd(); + let cwdstr = cwdPtr ? UTF8ToString(cwdPtr) : FS.cwd(); + + // When using a native spawn handler, the VFS cwd is meaningless + // to the host OS. Passing a VFS path like "/wordpress" as cwd to + // child_process.spawn causes ENOENT. Only pass cwd if it exists + // on the host filesystem. + if (Module['spawnProcess'] && typeof require !== 'undefined') { + try { + const fs = require('fs'); + if (!fs.existsSync(cwdstr)) { + cwdstr = null; + } + } catch (e) { + cwdstr = null; + } + } else if (Module['spawnProcess']) { + // ESM environment — can't sync-check, skip VFS cwd + cwdstr = null; + } + let envObject = null; if (envLength) { diff --git a/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts b/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts new file mode 100644 index 0000000000..9ebc5543f2 --- /dev/null +++ b/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts @@ -0,0 +1,198 @@ +/** + * Tests proc_open behavior across different execution contexts. + * + * proc_open works with php.run() but fails through the daemon/CLI path. + * These tests isolate where the spawn handler gets lost: + * + * 1. php.run() — direct PHP execution (known working) + * 2. php.cli() — WP-CLI-style execution on same instance + * 3. Runtime rotation — does enableRuntimeRotation preserve the handler? + * 4. Worker thread — does the worker thread path preserve the handler? + */ +import { spawn } from 'child_process'; +import { + SupportedPHPVersions, + setPhpIniEntries, + PHP, + ProcessIdAllocator, +} from '@php-wasm/universal'; +import { loadNodeRuntime } from '../lib'; + +const isWindows = process.platform === 'win32'; +const describeUnix = isWindows ? describe.skip : describe; + +const processIdAllocator = new ProcessIdAllocator(); + +const PROC_OPEN_TEST_CODE = ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open('/bin/echo proc_open_works', $desc, $pipes); + if (is_resource($proc)) { + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + echo trim($stdout); + } else { + echo 'PROC_OPEN_FAILED'; + } +`; + +describeUnix('proc_open spawn handler propagation', () => { + let php: PHP; + + afterEach(() => { + try { + php.exit(); + } catch { + // ignore + } + }); + + it('works with php.run() — baseline', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + + const result = await php.run({ code: PROC_OPEN_TEST_CODE }); + expect(result.text).toBe('proc_open_works'); + }); + + it('works with php.cli() — CLI execution path', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + await php.setSapiName('cli'); + + php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE); + + const result = await php.cli(['php', '/tmp/test-proc-open.php']); + const stdout = await result.stdoutText; + expect(stdout.trim()).toBe('proc_open_works'); + }); + + it('survives runtime rotation', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + + // Enable runtime rotation (this is what bootRequestHandler does) + php.enableRuntimeRotation({ + maxRequests: 400, + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + const result = await php.run({ code: PROC_OPEN_TEST_CODE }); + expect(result.text).toBe('proc_open_works'); + }); + + it('survives runtime rotation + cli()', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + await php.setSapiName('cli'); + + php.enableRuntimeRotation({ + maxRequests: 400, + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE); + + const result = await php.cli(['php', '/tmp/test-proc-open.php']); + const stdout = await result.stdoutText; + expect(stdout.trim()).toBe('proc_open_works'); + }); + + it('works after runtime has been rotated', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + + php.enableRuntimeRotation({ + maxRequests: 1, // Force rotation after every request + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + // First request — uses original runtime + const result1 = await php.run({ + code: ' { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + await php.setSapiName('cli'); + + php.enableRuntimeRotation({ + maxRequests: 1, // Force rotation after every request + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + // First request triggers rotation + await php.run({ code: ' { - // Use child_process.spawn directly for native host process spawning. - // This runs inside the worker thread — functions can't be serialized - // across the Comlink message boundary, so we import here. - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('child_process').spawn; - } + ? () => spawn : () => sandboxedSpawnHandlerFactory(() => { let effectiveOptions = options; From 1498af1ad2b3e8b836884cc85ffcb54a8aa68cac Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 15 Apr 2026 07:34:27 -0400 Subject: [PATCH 7/7] fix: remove unused imports in php-proc-open-daemon.spec.ts Remove SupportedPHPVersions and setPhpIniEntries imports that were triggering @typescript-eslint/no-unused-vars lint errors. --- .../php-wasm/node/src/test/php-proc-open-daemon.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts b/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts index 9ebc5543f2..06d2f19a9b 100644 --- a/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts +++ b/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts @@ -10,12 +10,7 @@ * 4. Worker thread — does the worker thread path preserve the handler? */ import { spawn } from 'child_process'; -import { - SupportedPHPVersions, - setPhpIniEntries, - PHP, - ProcessIdAllocator, -} from '@php-wasm/universal'; +import { PHP, ProcessIdAllocator } from '@php-wasm/universal'; import { loadNodeRuntime } from '../lib'; const isWindows = process.platform === 'win32';