Skip to content

Commit 1e9984e

Browse files
chubes4claude
andcommitted
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) <noreply@anthropic.com>
1 parent 6d37af6 commit 1e9984e

3 files changed

Lines changed: 220 additions & 8 deletions

File tree

packages/php-wasm/compile/php/phpwasm-emscripten-library.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,26 @@ const LibraryExample = {
589589
}
590590
}
591591

592-
const cwdstr = cwdPtr ? UTF8ToString(cwdPtr) : FS.cwd();
592+
let cwdstr = cwdPtr ? UTF8ToString(cwdPtr) : FS.cwd();
593+
594+
// When using a native spawn handler, the VFS cwd is meaningless
595+
// to the host OS. Passing a VFS path like "/wordpress" as cwd to
596+
// child_process.spawn causes ENOENT. Only pass cwd if it exists
597+
// on the host filesystem.
598+
if (Module['spawnProcess'] && typeof require !== 'undefined') {
599+
try {
600+
const fs = require('fs');
601+
if (!fs.existsSync(cwdstr)) {
602+
cwdstr = null;
603+
}
604+
} catch (e) {
605+
cwdstr = null;
606+
}
607+
} else if (Module['spawnProcess']) {
608+
// ESM environment — can't sync-check, skip VFS cwd
609+
cwdstr = null;
610+
}
611+
593612
let envObject = null;
594613

595614
if (envLength) {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* Tests proc_open behavior across different execution contexts.
3+
*
4+
* proc_open works with php.run() but fails through the daemon/CLI path.
5+
* These tests isolate where the spawn handler gets lost:
6+
*
7+
* 1. php.run() — direct PHP execution (known working)
8+
* 2. php.cli() — WP-CLI-style execution on same instance
9+
* 3. Runtime rotation — does enableRuntimeRotation preserve the handler?
10+
* 4. Worker thread — does the worker thread path preserve the handler?
11+
*/
12+
import { spawn } from 'child_process';
13+
import {
14+
SupportedPHPVersions,
15+
setPhpIniEntries,
16+
PHP,
17+
ProcessIdAllocator,
18+
} from '@php-wasm/universal';
19+
import { loadNodeRuntime } from '../lib';
20+
21+
const isWindows = process.platform === 'win32';
22+
const describeUnix = isWindows ? describe.skip : describe;
23+
24+
const processIdAllocator = new ProcessIdAllocator();
25+
26+
const PROC_OPEN_TEST_CODE = `<?php
27+
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
28+
$proc = proc_open('/bin/echo proc_open_works', $desc, $pipes);
29+
if (is_resource($proc)) {
30+
$stdout = stream_get_contents($pipes[1]);
31+
fclose($pipes[0]);
32+
fclose($pipes[1]);
33+
fclose($pipes[2]);
34+
proc_close($proc);
35+
echo trim($stdout);
36+
} else {
37+
echo 'PROC_OPEN_FAILED';
38+
}
39+
`;
40+
41+
describeUnix('proc_open spawn handler propagation', () => {
42+
let php: PHP;
43+
44+
afterEach(() => {
45+
try {
46+
php.exit();
47+
} catch {
48+
// ignore
49+
}
50+
});
51+
52+
it('works with php.run() — baseline', async () => {
53+
php = new PHP(
54+
await loadNodeRuntime('8.3', {
55+
emscriptenOptions: {
56+
processId: processIdAllocator.claim(),
57+
},
58+
})
59+
);
60+
await php.setSpawnHandler(spawn as any);
61+
62+
const result = await php.run({ code: PROC_OPEN_TEST_CODE });
63+
expect(result.text).toBe('proc_open_works');
64+
});
65+
66+
it('works with php.cli() — CLI execution path', async () => {
67+
php = new PHP(
68+
await loadNodeRuntime('8.3', {
69+
emscriptenOptions: {
70+
processId: processIdAllocator.claim(),
71+
},
72+
})
73+
);
74+
await php.setSpawnHandler(spawn as any);
75+
await php.setSapiName('cli');
76+
77+
php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE);
78+
79+
const result = await php.cli(['php', '/tmp/test-proc-open.php']);
80+
const stdout = await result.stdoutText;
81+
expect(stdout.trim()).toBe('proc_open_works');
82+
});
83+
84+
it('survives runtime rotation', async () => {
85+
php = new PHP(
86+
await loadNodeRuntime('8.3', {
87+
emscriptenOptions: {
88+
processId: processIdAllocator.claim(),
89+
},
90+
})
91+
);
92+
await php.setSpawnHandler(spawn as any);
93+
94+
// Enable runtime rotation (this is what bootRequestHandler does)
95+
php.enableRuntimeRotation({
96+
maxRequests: 400,
97+
recreateRuntime: () =>
98+
loadNodeRuntime('8.3', {
99+
emscriptenOptions: {
100+
processId: processIdAllocator.claim(),
101+
},
102+
}),
103+
});
104+
105+
const result = await php.run({ code: PROC_OPEN_TEST_CODE });
106+
expect(result.text).toBe('proc_open_works');
107+
});
108+
109+
it('survives runtime rotation + cli()', async () => {
110+
php = new PHP(
111+
await loadNodeRuntime('8.3', {
112+
emscriptenOptions: {
113+
processId: processIdAllocator.claim(),
114+
},
115+
})
116+
);
117+
await php.setSpawnHandler(spawn as any);
118+
await php.setSapiName('cli');
119+
120+
php.enableRuntimeRotation({
121+
maxRequests: 400,
122+
recreateRuntime: () =>
123+
loadNodeRuntime('8.3', {
124+
emscriptenOptions: {
125+
processId: processIdAllocator.claim(),
126+
},
127+
}),
128+
});
129+
130+
php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE);
131+
132+
const result = await php.cli(['php', '/tmp/test-proc-open.php']);
133+
const stdout = await result.stdoutText;
134+
expect(stdout.trim()).toBe('proc_open_works');
135+
});
136+
137+
it('works after runtime has been rotated', async () => {
138+
php = new PHP(
139+
await loadNodeRuntime('8.3', {
140+
emscriptenOptions: {
141+
processId: processIdAllocator.claim(),
142+
},
143+
})
144+
);
145+
await php.setSpawnHandler(spawn as any);
146+
147+
php.enableRuntimeRotation({
148+
maxRequests: 1, // Force rotation after every request
149+
recreateRuntime: () =>
150+
loadNodeRuntime('8.3', {
151+
emscriptenOptions: {
152+
processId: processIdAllocator.claim(),
153+
},
154+
}),
155+
});
156+
157+
// First request — uses original runtime
158+
const result1 = await php.run({
159+
code: '<?php echo "first";',
160+
});
161+
expect(result1.text).toBe('first');
162+
163+
// Second request — runtime has been rotated
164+
const result2 = await php.run({ code: PROC_OPEN_TEST_CODE });
165+
expect(result2.text).toBe('proc_open_works');
166+
});
167+
168+
it('works after runtime rotation via cli()', async () => {
169+
php = new PHP(
170+
await loadNodeRuntime('8.3', {
171+
emscriptenOptions: {
172+
processId: processIdAllocator.claim(),
173+
},
174+
})
175+
);
176+
await php.setSpawnHandler(spawn as any);
177+
await php.setSapiName('cli');
178+
179+
php.enableRuntimeRotation({
180+
maxRequests: 1, // Force rotation after every request
181+
recreateRuntime: () =>
182+
loadNodeRuntime('8.3', {
183+
emscriptenOptions: {
184+
processId: processIdAllocator.claim(),
185+
},
186+
}),
187+
});
188+
189+
// First request triggers rotation
190+
await php.run({ code: '<?php echo "warmup";' });
191+
192+
// Second request on rotated runtime, via cli()
193+
php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE);
194+
const result = await php.cli(['php', '/tmp/test-proc-open.php']);
195+
const stdout = await result.stdoutText;
196+
expect(stdout.trim()).toBe('proc_open_works');
197+
});
198+
});

packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FileLockManager } from '@php-wasm/universal';
2+
import { spawn } from 'child_process';
23
import { loadNodeRuntime } from '@php-wasm/node';
34
import { EmscriptenDownloadMonitor } from '@php-wasm/progress';
45
import type { PathAlias, SupportedPHPVersion } from '@php-wasm/universal';
@@ -188,13 +189,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker {
188189
cookieStore: false,
189190
pathAliases: options.pathAliases,
190191
spawnHandler: options.nativeSpawn
191-
? () => {
192-
// Use child_process.spawn directly for native host process spawning.
193-
// This runs inside the worker thread — functions can't be serialized
194-
// across the Comlink message boundary, so we import here.
195-
// eslint-disable-next-line @typescript-eslint/no-var-requires
196-
return require('child_process').spawn;
197-
}
192+
? () => spawn
198193
: () =>
199194
sandboxedSpawnHandlerFactory(() => {
200195
let effectiveOptions = options;

0 commit comments

Comments
 (0)