Skip to content

[PHP] Fix "Controller is already closed" crash in CLI server#3441

Merged
adamziel merged 8 commits intotrunkfrom
adamziel/cli-controller-crash
Mar 31, 2026
Merged

[PHP] Fix "Controller is already closed" crash in CLI server#3441
adamziel merged 8 commits intotrunkfrom
adamziel/cli-controller-crash

Conversation

@adamziel
Copy link
Copy Markdown
Collaborator

@adamziel adamziel commented Mar 28, 2026

Summary

This PR fixes two related Node-side crash paths in PHP-WASM that could bring down the Playground CLI server instead of failing a single request cleanly.

The first crash happened when PHP had already produced output, which closes the headers stream, and then execution failed afterwards. In that case, Node could still try to error() a ReadableStream controller that had already been closed, which threw:

TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed

The validated repro for that path is:

  1. PHP writes output
  2. closeHeadersStream() closes the headers controller
  3. a later WASM/runtime error occurs
  4. error handling tries to signal the already-closed controller

A simple echo ...; exit; by itself is not the exact repro.

The second crash happened when a stream consumer cancelled the response stream before PHP finished, for example because the HTTP pipeline completed or the client disconnected. In that case, the runtime could still try to close() an already-cancelled controller and hit the same Controller is already closed failure.

The third crash path was in the proc_open() bridge used by the Node PHP runtime. If a spawned process timed out or exited while Node still had buffered stdout/stderr chunks to forward, the generated php.js bridge could try to write into a PIPEFS stream that PHP had already closed. That crashed the host with an error like:

TypeError: Cannot read properties of null (reading 'length')

Example:

<?php
$res = proc_open(
    "hanging_command",
    [
        ["pipe", "r"],
        ["pipe", "w"],
        ["pipe", "w"],
    ],
    $pipes
);
fread($pipes[1], 1024);

What changed

  • Added defensive stream-controller handling in the universal runtime and stream bridge so already-closed controllers no longer crash the host process.
  • Fixed child stdout/stderr teardown in the Emscripten Node bridge by detaching listeners on exit and ignoring late chunks after PHP has already closed the child-side pipe.
  • Added off() to the shared EventEmitter interface so the child-process typing matches the APIs used by the runtime cleanup logic.
  • Regenerated all checked-in Node PHP binaries (asyncify and jspi, PHP 7.4 through 8.5) from the updated build source.

Test plan

CI

When an HTTP client disconnects while a PHP request is still in
progress, the stream pipeline cancels the ReadableStream. This
cancellation propagates to the controller, putting it in a closed
state. When PHP then finishes executing, the finally block in
#executeWithErrorHandling tries to call controller.close() on the
already-cancelled controller, which throws "Invalid state:
Controller is already closed" and crashes the Node host process.

The fix wraps all stream controller operations (close, error,
enqueue) in try-catch so that a cancelled stream never crashes the
host. This applies to three locations: the main request handler's
finally block, the WASM crash error path, and the subprocess data
callbacks. The portToStream fallback in api.ts gets the same
treatment for environments that don't support transferable streams.
@adamziel adamziel requested review from a team, bgrgicak and Copilot March 28, 2026 09:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a Node CLI server crash caused by attempting to operate on ReadableStreamDefaultControllers after the consumer has cancelled the stream (e.g., client disconnect), resulting in "Invalid state: Controller is already closed".

Changes:

  • Add safeStreamError() / safeStreamClose() helpers and use them in PHP execution error/finally paths.
  • Guard child-process stdout/stderr stream enqueues and errors against cancellation.
  • Wrap portToStream() controller operations in a try/catch and add a regression test reproducing the cancellation-before-exit scenario.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/php-wasm/universal/src/lib/php.ts Adds safe controller close/error helpers and applies them to crash/finally and child process stream handling.
packages/php-wasm/universal/src/lib/api.ts Wraps ReadableStream controller operations in portToStream() to avoid exceptions after cancellation.
packages/php-wasm/node/src/test/php-crash.spec.ts Adds a regression test for cancellation before PHP exits.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +149 to +161
spy.mockImplementation((c_func) => {
if (c_func === 'wasm_sapi_handle_request') {
// Simulate PHP writing some output
php[__private__dont__use].onStdout?.(
new Uint8Array([72, 101, 108, 108, 111])
);
// Return a promise we control so we can cancel
// the stream before PHP "exits".
return new Promise((resolve) => {
resolveExecution = resolve;
});
}
});
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mock returns undefined for any ccall other than wasm_sapi_handle_request. That can make the test brittle (or accidentally change behavior) if runStream() relies on other ccall results during setup/teardown. Prefer delegating to the original ccall for all other c_func values (store the original before spying), and only override the one function needed for the scenario.

Copilot uses AI. Check for mistakes.
When a mu-plugin writes output (triggering closeHeadersStream via
onStdout) and then a WASM crash occurs, the error handler tried to
call headers.controller.error() on the already-closed headers
controller. The safeStreamError helper now handles this gracefully.
This test verifies the crash surfaces through the stream as a
catchable error rather than crashing the Node host.
@adamziel adamziel marked this pull request as draft March 29, 2026 22:26
When enqueue() fails on a child process stream, detach the data
listener instead of silently dropping chunks in a loop. In
portToStream, use safeStreamClose/safeStreamError for the close
and error message types so we don't lose error info in a blanket
catch. Document why we swallow errors in each case: the consumer
already has the terminal state, and re-throwing would crash the
Node process for no benefit.

Also fix TS7030 in test mocks by adding explicit return values.
@adamziel adamziel added [Type] Bug An existing feature does not function as intended [Feature] PHP.wasm labels Mar 30, 2026
@adamziel adamziel marked this pull request as ready for review March 30, 2026 21:55
@adamziel adamziel changed the title Fix "Controller is already closed" crash in CLI server [PHP] Fix "Controller is already closed" crash in CLI server Mar 31, 2026
@adamziel adamziel merged commit 62ae031 into trunk Mar 31, 2026
47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] PHP.wasm [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants