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