Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/agent-task-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
86 changes: 86 additions & 0 deletions tests/php-wasm-runtime-rejection-any-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<never>(() => 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=<?php mysqli_poll();"] },
}
const startedAt = Date.now()

try {
const error = await Promise.race([
executeRecipeWorkflowStep(runtime, workflowStep, root, undefined, root).then(
() => assert.fail("wordpress.run-php unexpectedly completed after a php.wasm trap"),
(reason: unknown) => reason,
),
new Promise<never>((_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")
Loading