From bb0311f0240d9ecc7fe6b0c2376fb129bfef34fb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 16 Aug 2026 22:36:17 +0000 Subject: [PATCH] fix: terminalize php.wasm runtime rejections for every command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/agent-task-contracts.yml | 2 + package.json | 1 + .../src/playground-runtime.ts | 11 ++- ...wasm-runtime-rejection-any-command.test.ts | 86 +++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/php-wasm-runtime-rejection-any-command.test.ts diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index aa93f301..3e3d5cd1 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -131,6 +131,8 @@ jobs: - run: npm run test:bounded-recipe-plan - run: npm run test:bounded-recipe-plan-integration - run: npm run test:recipe-step-continuation + - run: npm run test:phpunit-runtime-rejection + - run: npm run test:php-wasm-runtime-rejection-any-command - run: npm run test:disposable-mysql-mysqli-e2e - run: npm run test:runtime-sources-playground-integration - run: npm run test:playground-phpunit-readonly-cache-integration diff --git a/package.json b/package.json index cbadb166..dafae140 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "test:host-node-heap": "tsx tests/host-node-heap.test.ts", "test:phpunit-structured-evidence": "tsx tests/phpunit-structured-evidence.test.ts", "test:phpunit-runtime-rejection": "tsx tests/phpunit-runtime-rejection.test.ts", + "test:php-wasm-runtime-rejection-any-command": "tsx tests/php-wasm-runtime-rejection-any-command.test.ts", "test:playground-worker-runtime-rejection": "tsx tests/playground-worker-runtime-rejection.test.ts", "test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts", "test:browser-task-builder": "tsx tests/browser-task-builder.test.ts", diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index cf6f0c38..a309b47c 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -386,9 +386,14 @@ class PlaygroundRuntime implements Runtime { const executeCommand = async () => spec.processIdentity ? await this.requestWorkerExecutions.run(spec.environment ?? {}, async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)) : await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController) - return spec.command === "wordpress.phpunit" - ? await terminalizeOnPhpWasmRuntimeRejection(executeCommand, () => abortController.abort()) - : await executeCommand() + // 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. Scoping this to wordpress.phpunit left every + // other command able to hang forever on the identical fault — a trap in + // wordpress.run-php wedged a recipe-run for its whole 1500s budget and + // reported only a timeout. + return await terminalizeOnPhpWasmRuntimeRejection(executeCommand, () => abortController.abort()) }) const finishedAt = now() const envelope = typeof output === "string" diff --git a/tests/php-wasm-runtime-rejection-any-command.test.ts b/tests/php-wasm-runtime-rejection-any-command.test.ts new file mode 100644 index 00000000..01ac9572 --- /dev/null +++ b/tests/php-wasm-runtime-rejection-any-command.test.ts @@ -0,0 +1,86 @@ +/** + * A php.wasm trap is a property of the runtime, not of the command that + * happened to be running. The terminalizer was originally wired only to + * `wordpress.phpunit` (851f0aa), 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 budget and reported only a timeout. + * + * This pins the generalization — a non-PHPUnit command must terminalize too. + */ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createRuntime } from "../packages/runtime-core/src/index.js" +import { createPlaygroundRuntimeBackend } from "../packages/runtime-playground/src/index.js" +import type { PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js" +import { executeRecipeWorkflowStep, recipeStepFailure } from "../packages/cli/src/commands/recipe-run-workflow-evidence.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-php-wasm-rejection-any-command-")) + +// The fault observed in the field: mysqli async polling traps inside the +// PHP-WASM build and the rejection surfaces out of band on the process, while +// the command's own promise never settles. +const phpWasmFailure = new WebAssembly.RuntimeError("null function or function signature mismatch") +phpWasmFailure.stack = [ + `RuntimeError: ${phpWasmFailure.message}`, + " at php.wasm._php_stream_write_filtered (wasm://wasm/php.wasm-05996276:wasm-function[3039]:0x26c31e)", + " at php.wasm.mysqlnd_stream_array_from_fd_set (wasm://wasm/php.wasm-05996276:wasm-function[8606]:0x69ad75)", + " at php.wasm.zif_mysqli_poll (wasm://wasm/php.wasm-05996276:wasm-function[12986]:0x9949a8)", +].join("\n") + +const cliModule: PlaygroundCliModule = { + runCLI: async () => ({ + serverUrl: "http://127.0.0.1:9404", + playground: { + run: async () => { + queueMicrotask(() => process.emit("unhandledRejection", phpWasmFailure, Promise.resolve())) + return await new Promise(() => undefined) + }, + readFileAsText: async () => "", + }, + [Symbol.asyncDispose]: async () => undefined, + }), +} + +const runtime = await createRuntime({ + backend: "wordpress-playground", + artifactsDirectory: root, + environment: { kind: "wordpress", name: "php-wasm-rejection-any-command", version: "7.0", phpVersion: "8.3", blueprint: { steps: [] } }, + policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.run-php"], secrets: "none", approvals: "never" }, +}, createPlaygroundRuntimeBackend({ cliModule })) + +const workflowStep = { + phase: "steps" as const, + index: 0, + step: { command: "wordpress.run-php", args: ["code= assert.fail("wordpress.run-php unexpectedly completed after a php.wasm trap"), + (reason: unknown) => reason, + ), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("php.wasm runtime rejection did not terminalize a non-PHPUnit command within 500ms")), 500)), + ]) + const failure = recipeStepFailure(workflowStep, error, startedAt) + const serialized = JSON.stringify(failure) + + assert.equal(failure.schema, "wp-codebox/recipe-step-failure/v1") + assert.equal(failure.classification, "error") + // The whole point: a trapped runtime must not be paid for at budget rates. + assert.ok(failure.durationMs < 500, `expected immediate terminal failure, received ${failure.durationMs}ms`) + assert.match(serialized, /wp-codebox-php-wasm-runtime-rejection/) + assert.match(serialized, /infrastructure-failure/) + assert.match(serialized, /php-wasm/) + assert.match(serialized, /null function or function signature mismatch/) + // The failure must name the trap, not the clock. + assert.doesNotMatch(serialized, /recipe-run-timeout/) +} finally { + await runtime.destroy() + await rm(root, { recursive: true, force: true }) +} + +console.log("php.wasm runtime rejection terminalizes non-phpunit commands ok")