Skip to content

php-wasm leaks popen output streams until FS.ErrnoError(33) #3678

@chubes4

Description

@chubes4

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:

proc_open_open_count=5

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions