Skip to content

PHP: stream stdout and stderr via StreamedPHPResponse#2266

Merged
adamziel merged 5 commits intotrunkfrom
push-lzqtsuspwwoy
Jun 16, 2025
Merged

PHP: stream stdout and stderr via StreamedPHPResponse#2266
adamziel merged 5 commits intotrunkfrom
push-lzqtsuspwwoy

Conversation

@adamziel
Copy link
Collaborator

@adamziel adamziel commented Jun 12, 2025

Implements a php.runStream() method that returns a StreamedPHPResponse instance:

export class StreamedPHPResponse {
	/**
	 * Resolves once HTTP status code is available.
	 */
	httpStatusCode: Promise<number>;

	/**
	 * Resolves once HTTP headers are available.
	 */
	headers: Promise<Record<string, string[]>>;

	/**
	 * Exposes the stdout bytes as they're produced by the PHP instance
	 */
	stdoutText: Promise<string>;

	/**
	 * Exposes the stderr bytes as they're produced by the PHP instance
	 */
	stderrText: Promise<string>;

	/**
	 * Resolves when the response has finished processing – either successfully or not.
	 */
	finished(): Promise<void>;

	/**
	 * True if the response is successful (HTTP status code 200-399),
	 * false otherwise.
	 */
	async ok(): Promise<boolean>;
}

It exposes stdout and stderr as ReadableStream-s, allowing the caller to interact
with partial output data.

Before this PR, we only had php.run() that buffered
stdout and stderr data and returned it all at once after the PHP code was fully
executed.

Usage example

const streamed = await php.runStream({
    code: `<?php 
    echo "first chunk";
    sleep(1);
    echo "second chunk";
`,
});

const reader = streamed.stdout.getReader();
const decoder = new TextDecoder();

// Read the first chunk
console.log( await reader.read() );
// "first chunk"

// Wait about a second until the second chunk is available
console.log( await reader.read() );
// "second chunk"

API changes

  • Adds a new method: php.runStream(request: PHPRequest): StreamedPHPResponse
  • Changes the return type of php.cli() from integer exit code to StreamedPHPResponse

Implementation

php.js registers three FS devices at:

  • /internal/stdout
  • /internal/stderr
  • /internal/headers

They are private to every PHP instance and are never shared with other runtimes.

Then, in JavaScript, whenever a chunk of data is written to either of these devices, we propagate
it to consumer via a callback, e.g. PHPWASM.onStdout(chunk).

Consumers of the PHP class never have to interact with these devices or callbacks directly.
The PHP class creates the relevant ReadableStreams and pushes the data through them
– see php.#executeWithErrorHandling() for details.

Why not use Emscripten's stdout and stderr?

Emscripten's native stdout and stderr devices stop processing data when they encounter
the first null byte. However, null bytes are common when dealing with binary data.

Backwards Compatibility

  • php.cli() now returns a StreamedPHPResponse instance and not an integer exit code.
  • php.run() continues to work as before. Internally, it now creates a streamed response and buffers the output before returning a PHPResponse object.

Follow-up work

  • Stream the response bytes in the web/service worker

Implements a php.runStream() method that returns a StreamedPHPResponse instance.
It exposes stdout and stderr as ReadableStreams, allowing the caller to interact
with partial output data. Before this PR, we only had php.run() that buffered 
stdout and stderr data and returned it all at once after the PHP code was fully
executed.

## Implementation

We register three FS devices at:

* /internal/stdout
* /internal/stderr
* /internal/headers

They are private to every PHP instance and are never shared with other runtimes.

Then, in JavaScript, whenever a chunk of data is written to either of these devices, we propagate
it to consumer via a callback, e.g. `PHPWASM.onStdout(chunk)`.

Users of the `PHP` class never have to interact with these devices or callbacks directly.
The PHP class creates the relevant ReadableStreams and pushes the data through them
– see php.#executeWithErrorHandling() for details.

#### Why not use Emscripten's stdout and stderr?

Emscripten's native stdout and stderr devices stop processing data when they encounter
the first null byte. However, null bytes are common when dealing with binary data.

### Backwards Compatibility

php.run() continues to work. It creates a streamed response under the hood and buffers
the streamed output before returning a buffered `PHPResponse` object.

## Remaining work

* Add streaming-specific tests

## Follow-up work

* Stream the response bytes in the web/service worker

Remove old PHP CLI bindings
@adamziel adamziel force-pushed the push-lzqtsuspwwoy branch from 085a855 to 8b10520 Compare June 15, 2025 22:39
@adamziel adamziel changed the title PHP: Stream stdout and stderr PHP: StreamedPHPResponse that streams stdout and stderr Jun 16, 2025
@adamziel adamziel changed the title PHP: StreamedPHPResponse that streams stdout and stderr PHP: stream stdout and stderr via StreamedPHPResponse Jun 16, 2025
@adamziel adamziel merged commit 04791f7 into trunk Jun 16, 2025
22 of 24 checks passed
@adamziel adamziel deleted the push-lzqtsuspwwoy branch June 16, 2025 09:12
@brandonpayton
Copy link
Member

Cool work, @adamziel!

adamziel added a commit that referenced this pull request Aug 4, 2025
Guards against releasing the "request in progress" semaphore
too early in the php.runStream() call.

A single PHP runtime can only handle one request at a time.
The PHP class calls a `wasm_sapi_handle_request` C function that
initializes the PHP runtime and starts the request. That function is
asynchronous and may yield back to the event loop before the request
is fully handled, the exit code known, and the runtime is cleaned up
and prepared for another request.

The PHP class uses an async semaphore to protect against calling
`wasm_sapi_handle_request` again while a previous call is still
running.

However, PR 2266 [1] introduced a regression where the semaphore
was released too early. As a result, it opened the runtime to a
race condition where a subsequent runStream() call tried to run
PHP code on a runtime that was in a middle of handling a request.

This test ensures that two runStream() calls can be made without
crashing the runtime.

[1] #2266
adamziel added a commit that referenced this pull request Aug 4, 2025
Guards against releasing the "request in progress" semaphore too early
in the php.runStream() call.

A single PHP runtime can only handle one request at a time. The PHP
class calls a `wasm_sapi_handle_request` C function that initializes the
PHP runtime and starts the request. That function is asynchronous and
may yield back to the event loop before the request is fully handled,
the exit code known, and the runtime is cleaned up and prepared for
another request.

The PHP class uses an async semaphore to protect against calling
`wasm_sapi_handle_request` again while a previous call is still running.

However, PR 2266 [1] introduced a regression where the semaphore was
released too early. As a result, it opened the runtime to a race
condition where a subsequent runStream() call tried to run PHP code on a
runtime that was in a middle of handling a request.

This test ensures that two runStream() calls can be made without
crashing the runtime.

[1] #2266
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

No open projects
Status: Inbox

Development

Successfully merging this pull request may close these issues.

2 participants

Comments