Summary
exec(), shell_exec(), system(), and popen(..., 'r') in @php-wasm/node leak one Emscripten FS stream per call for /tmp/popen_output. After enough calls, FS.nextfd() exhausts FS.MAX_OPEN_FDS and the Node process crashes with an uncaught ErrnoError(33).
This was found while validating a Studio/DMC workflow where a WP-CLI command repeatedly used PHP exec() for bootstrap work. The underlying bug reproduces without Studio by loading @php-wasm/node directly.
Related context: #3480 made process spawning usable; this issue is a separate leak once the spawn path is exercised repeatedly.
Reproduction: Studio WP-CLI
studio wp eval 'for ( $i = 0; $i < 5000; $i++ ) { exec( "true", $out, $code ); if ( 0 === $i % 500 ) { echo "i=$i\n"; } } echo "done\n";'
Observed with Studio-vendored @php-wasm/node-8-3@3.1.21:
i=0
i=500
i=1000
i=1500
i=2000
i=2500
i=3000
i=3500
i=4000
.../@php-wasm/node-8-3/jspi/php_8_3.js:2955
throw new FS.ErrnoError(33);
^
ErrnoError { name: 'ErrnoError', errno: 33 }
Native PHP on the same machine completes the same loop.
Direct php-wasm proof
Using Studio's vendored @php-wasm/node directly with a native spawn handler:
import { spawn } from 'node:child_process';
import { PHP, __private__dont__use } from '@php-wasm/universal';
import { loadNodeRuntime } from '@php-wasm/node';
const php = new PHP(await loadNodeRuntime('8.3', { emscriptenOptions: { processId: 131 } }));
await php.setSpawnHandler(spawn);
await php.run({ code: `<?php for ( $i = 0; $i < 100; $i++ ) { exec("true", $out, $code); }` });
const FS = php[__private__dont__use].FS;
const open = FS.streams.map((s, i) => s ? [i, s.path] : null).filter(Boolean);
console.log('exec_open_count=' + open.length);
console.log('popen_output_count=' + open.filter(([, path]) => path === '/tmp/popen_output').length);
Observed:
exec_open_count=105
popen_output_count=100
By contrast, 100 proc_open() calls with explicit pipe close/proc close leaves only the baseline streams:
Normal fopen() / fclose() also does not leak.
Suspected source
The leak appears to originate in packages/php-wasm/compile/php/php_wasm.c:
const outputPath = '/tmp/popen_output';
FS.writeFile(outputPath, outBytes);
wakeUp(allocateUTF8OnStack(outputPath));
Then:
char *file_path = js_popen_to_file(cmd, mode, &last_exit_code);
fp = fopen(file_path, mode);
Each popen() / exec() call opens /tmp/popen_output, but the corresponding Emscripten FS stream remains present after pclose() / php_stream_close().
Expected behavior
Repeated successful exec() / popen() calls should not grow FS.streams, and should not eventually crash with ErrnoError(33).
Summary
exec(),shell_exec(),system(), andpopen(..., 'r')in@php-wasm/nodeleak one Emscripten FS stream per call for/tmp/popen_output. After enough calls,FS.nextfd()exhaustsFS.MAX_OPEN_FDSand the Node process crashes with an uncaughtErrnoError(33).This was found while validating a Studio/DMC workflow where a WP-CLI command repeatedly used PHP
exec()for bootstrap work. The underlying bug reproduces without Studio by loading@php-wasm/nodedirectly.Related context: #3480 made process spawning usable; this issue is a separate leak once the spawn path is exercised repeatedly.
Reproduction: Studio WP-CLI
Observed with Studio-vendored
@php-wasm/node-8-3@3.1.21:Native PHP on the same machine completes the same loop.
Direct php-wasm proof
Using Studio's vendored
@php-wasm/nodedirectly with a native spawn handler:Observed:
By contrast, 100
proc_open()calls with explicit pipe close/proc close leaves only the baseline streams:Normal
fopen()/fclose()also does not leak.Suspected source
The leak appears to originate in
packages/php-wasm/compile/php/php_wasm.c:Then:
Each
popen()/exec()call opens/tmp/popen_output, but the corresponding Emscripten FS stream remains present afterpclose()/php_stream_close().Expected behavior
Repeated successful
exec()/popen()calls should not growFS.streams, and should not eventually crash withErrnoError(33).