Skip to content

fix: terminalize php.wasm runtime rejections for every command - #2271

Merged
chubes4 merged 1 commit into
mainfrom
fix/php-wasm-rejection-all-commands
Aug 16, 2026
Merged

fix: terminalize php.wasm runtime rejections for every command#2271
chubes4 merged 1 commit into
mainfrom
fix/php-wasm-rejection-all-commands

Conversation

@chubes4

@chubes4 chubes4 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

A php.wasm trap is a property of the runtime, not of the command that happened to be running. It leaves the interpreter unusable, and the rejection arrives out of band on the process rather than through the command's own promise — so the command hangs until something else times it out.

851f0aa ("Fix PHPUnit WASM runtime rejection handling") introduced terminalizeOnPhpWasmRuntimeRejection and wired it to a single command:

return spec.command === "wordpress.phpunit"
  ? await terminalizeOnPhpWasmRuntimeRejection(executeCommand, () => abortController.abort())
  : await executeCommand()

Every other command was left able to hang forever on the identical fault.

Evidence

Hit in the field on wordpress.run-php. The recipe-run step ledger recorded the command as completed with exit 0 while the run never returned, and the whole 1500s budget was consumed — four test shards deep, ~24 minutes each:

"execution": { "count": 1, "count_complete": false,
  "last": { "command": "wordpress.run-php", "status": "ok", "exit_code": 0,
            "stdout": { "bytes": 0 }, "stderr": { "bytes": 0 } } },
"termination": { "result": "timeout", "signal": "SIGTERM" }
Unhandled rejection: RuntimeError: null function or function signature mismatch
    at php.wasm._php_stream_write_filtered
    at php.wasm.mysqlnd_stream_array_from_fd_set
    at php.wasm.zif_mysqli_poll

Same zif_mysqli_poll call site the original fix used in its own test fixture — the fault reappeared through a different command.

Change

Apply the terminalizer to every execution.

The guard is already narrow where it matters: isPhpWasmRuntimeRejection only claims a RuntimeError whose stack names php.wasm, so nothing else is intercepted, and non-matching rejections keep their existing rethrow path. Widening the call site does not widen what gets caught — it only stops the interception from depending on which command was in flight.

Tests

  • tests/php-wasm-runtime-rejection-any-command.test.ts — a wordpress.run-php step raising the observed trap must fail terminally in under 500ms, carry wp-codebox-php-wasm-runtime-rejection / infrastructure-failure, and not be reported as a timeout.

    Verified red before the change: it fails with "php.wasm runtime rejection did not terminalize a non-PHPUnit command within 500ms" — i.e. it hangs, exactly as the field failure did.

  • Both rejection tests are now wired into agent-task-contracts.yml. test:phpunit-runtime-rejection was an npm script no workflow ran, so the guard it protects had no gate — which is part of why the scoping gap went unnoticed.

Verification

npm run build                                        passed (tsc -b)
npm run test:php-wasm-runtime-rejection-any-command  passed
npm run test:phpunit-runtime-rejection               passed
npm run test:playground-worker-runtime-rejection     passed
npm run test:recipe-step-continuation                passed
npm run test:redaction                               passed
npm run test:bounded-runtime-plan                    passed
npm run test:bounded-recipe-plan                     passed
npm run test:recipe-declared-artifacts               passed
npm run test:runtime-command-artifact-bounds         passed

Note on the underlying trap

This makes the failure terminal and correctly classified; it does not make mysqli_poll work under PHP-WASM. That trap is still worth chasing separately — but it should cost a runtime rejection, not a silent hang for the full budget.

A php.wasm trap is a property of the runtime, not of the command that happened
to be running. It leaves the interpreter unusable, and the rejection arrives out
of band on the process rather than through the command's own promise, so the
command hangs until something else times it out.

851f0aa wired terminalizeOnPhpWasmRuntimeRejection to wordpress.phpunit only,
which left every other command able to hang forever on the identical fault. A
trap raised during wordpress.run-php wedged a recipe-run for its whole 1500s
budget, four shards deep, and surfaced only as a timeout — the step ledger
showed the command completed with exit 0 while the run never returned:

    at php.wasm._php_stream_write_filtered
    at php.wasm.mysqlnd_stream_array_from_fd_set
    at php.wasm.zif_mysqli_poll

Apply the terminalizer to every execution. The guard is already narrow where it
matters: isPhpWasmRuntimeRejection only claims a RuntimeError whose stack names
php.wasm, so nothing else is intercepted, and non-matching rejections keep their
existing rethrow path.

Add a regression test covering a non-PHPUnit command, and run both rejection
tests in CI — the phpunit one was an unwired npm script, so the guard it
protects had no gate.
@chubes4
chubes4 merged commit 0ddc82d into main Aug 16, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant