Skip to content

Commit 851f0aa

Browse files
committed
Fix PHPUnit WASM runtime rejection handling
1 parent b790df0 commit 851f0aa

4 files changed

Lines changed: 149 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"test:playground-phpunit-bootstrap-failure-integration": "tsx tests/playground-phpunit-bootstrap-failure.integration.test.ts",
125125
"test:playground-custom-archive-cache": "tsx tests/playground-custom-archive-cache.test.ts && tsx tests/playground-custom-archive-cache-process.test.ts && tsx tests/playground-custom-archive-cache.integration.test.ts",
126126
"test:phpunit-runtime-failure-diagnostics": "tsx tests/phpunit-runtime-failure-diagnostics.test.ts",
127+
"test:phpunit-runtime-rejection": "tsx tests/phpunit-runtime-rejection.test.ts",
127128
"test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts",
128129
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
129130
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",

packages/runtime-playground/src/playground-command-errors.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { errorMessage, parseJsonObject } from "@automattic/wp-codebox-core/inter
33

44
export { errorMessage }
55

6+
type PhpWasmUnhandledRejectionListener = (reason: unknown, promise: Promise<unknown>) => void
7+
const phpWasmRuntimeRejectionListeners = new Set<PhpWasmUnhandledRejectionListener>()
8+
69
export interface PlaygroundRunResponse {
710
bytes?: unknown
811
cause?: unknown
@@ -47,6 +50,56 @@ export class PlaygroundCommandCrashError extends Error {
4750
}
4851
}
4952

53+
export class PhpWasmRuntimeRejectionError extends Error {
54+
readonly code = "wp-codebox-php-wasm-runtime-rejection"
55+
readonly failureClassification = "infrastructure-failure"
56+
readonly runtime: { kind: "php-wasm"; signal: "unhandledRejection"; errorName: string; message: string }
57+
58+
constructor(cause: unknown) {
59+
const message = redactDiagnosticText(errorMessage(cause))
60+
super(`PHP WASM runtime rejected while a command was active: ${message}`)
61+
this.name = "PhpWasmRuntimeRejectionError"
62+
this.runtime = {
63+
kind: "php-wasm",
64+
signal: "unhandledRejection",
65+
errorName: cause instanceof Error ? cause.name : "Error",
66+
message,
67+
}
68+
}
69+
}
70+
71+
export async function terminalizeOnPhpWasmRuntimeRejection<T>(operation: () => Promise<T>, abort: () => void): Promise<T> {
72+
let rejectRuntime: (error: PhpWasmRuntimeRejectionError) => void = () => undefined
73+
const runtimeRejection = new Promise<never>((_resolve, reject) => {
74+
rejectRuntime = reject
75+
})
76+
const onUnhandledRejection: PhpWasmUnhandledRejectionListener = (reason) => {
77+
if (isPhpWasmRuntimeRejection(reason)) {
78+
rejectRuntime(new PhpWasmRuntimeRejectionError(reason))
79+
abort()
80+
} else if (!process.listeners("unhandledRejection").some((listener) => !phpWasmRuntimeRejectionListeners.has(listener as PhpWasmUnhandledRejectionListener))) {
81+
queueMicrotask(() => { throw reason })
82+
}
83+
}
84+
85+
phpWasmRuntimeRejectionListeners.add(onUnhandledRejection)
86+
process.on("unhandledRejection", onUnhandledRejection)
87+
try {
88+
return await Promise.race([Promise.resolve().then(operation), runtimeRejection])
89+
} finally {
90+
process.off("unhandledRejection", onUnhandledRejection)
91+
phpWasmRuntimeRejectionListeners.delete(onUnhandledRejection)
92+
}
93+
}
94+
95+
function isPhpWasmRuntimeRejection(reason: unknown): boolean {
96+
if (!(reason instanceof Error) || reason.name !== "RuntimeError") {
97+
return false
98+
}
99+
100+
return typeof reason.stack === "string" && /(?:wasm:\/\/wasm\/)?php\.wasm[.:]/.test(reason.stack)
101+
}
102+
50103
export class PlaygroundCliExitError extends Error {
51104
readonly code = "wp-codebox-playground-cli-exited"
52105

packages/runtime-playground/src/playground-runtime.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { firstCommandWordPressAdminAuthRequirement } from "./command-auth-requir
1717
import { argValue, cleanWpCliOutput, runWithTemporaryWpCliScript, shellArgv, wordpressBlockExerciseInputFromArgs, wordpressBlockExercisePhpCode, wordpressCrudOperationFromArgs, wordpressCrudOperationPhpCode, wordpressDbOperationFromArgs, wordpressDbOperationPhpCode, wpCliCommandFromArgs } from "./commands.js"
1818
import { bootstrapPhpCode } from "./php-bootstrap.js"
1919
import { observeHttpResponse as observeHttpResponseArtifact, observeWordPressState as observeWordPressStateArtifact } from "./observation-artifacts.js"
20-
import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, type PlaygroundRunResponse } from "./playground-command-errors.js"
20+
import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, terminalizeOnPhpWasmRuntimeRejection, type PlaygroundRunResponse } from "./playground-command-errors.js"
2121
import { startPlaygroundCliServer, type PlaygroundCliModule } from "./playground-cli-runner.js"
2222
import type { PlaygroundCliServer } from "./preview-server.js"
2323
import { collectPlaygroundArtifacts } from "./runtime-artifact-helpers.js"
@@ -365,9 +365,14 @@ class PlaygroundRuntime implements Runtime {
365365
this.activeExecutionAbortControllers.add(abortController)
366366
try {
367367
const executionSpec = executionSpecWithEnvironment(spec)
368-
const output = await this.executionSignals.run(abortController.signal, async () => spec.processIdentity
369-
? await this.requestWorkerExecutions.run(spec.environment ?? {}, async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController))
370-
: await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController))
368+
const output = await this.executionSignals.run(abortController.signal, async () => {
369+
const executeCommand = async () => spec.processIdentity
370+
? await this.requestWorkerExecutions.run(spec.environment ?? {}, async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController))
371+
: await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)
372+
return spec.command === "wordpress.phpunit"
373+
? await terminalizeOnPhpWasmRuntimeRejection(executeCommand, () => abortController.abort())
374+
: await executeCommand()
375+
})
371376
const finishedAt = now()
372377
const envelope = typeof output === "string"
373378
? runtimeCommandResultEnvelopeFromOutput({
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from "node:assert/strict"
2+
import { mkdtemp, rm } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import { createRuntime } from "../packages/runtime-core/src/index.js"
6+
import { createPlaygroundRuntimeBackend } from "../packages/runtime-playground/src/index.js"
7+
import { terminalizeOnPhpWasmRuntimeRejection } from "../packages/runtime-playground/src/playground-command-errors.js"
8+
import type { PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"
9+
import { executeRecipeWorkflowStep, recipeStepFailure } from "../packages/cli/src/commands/recipe-run-workflow-evidence.js"
10+
11+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-runtime-rejection-"))
12+
const secret = "sk-abcdefghijklmnopqrstuvwxyz"
13+
const phpWasmFailure = new WebAssembly.RuntimeError(`null function or function signature mismatch ${secret}`)
14+
phpWasmFailure.stack = `RuntimeError: ${phpWasmFailure.message}\n at php.wasm.zif_mysqli_poll (wasm://wasm/php.wasm-05996276:wasm-function[12986]:0x9949a8)`
15+
16+
const cliModule: PlaygroundCliModule = {
17+
runCLI: async () => ({
18+
serverUrl: "http://127.0.0.1:9403",
19+
playground: {
20+
run: async () => {
21+
queueMicrotask(() => process.emit("unhandledRejection", phpWasmFailure, Promise.resolve()))
22+
return await new Promise<never>(() => undefined)
23+
},
24+
readFileAsText: async () => "",
25+
},
26+
[Symbol.asyncDispose]: async () => undefined,
27+
}),
28+
}
29+
30+
const runtime = await createRuntime({
31+
backend: "wordpress-playground",
32+
artifactsDirectory: root,
33+
environment: { kind: "wordpress", name: "phpunit-runtime-rejection", version: "7.0", phpVersion: "8.3", blueprint: { steps: [] } },
34+
policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.phpunit"], secrets: "none", approvals: "never" },
35+
}, createPlaygroundRuntimeBackend({ cliModule }))
36+
37+
const workflowStep = {
38+
phase: "steps" as const,
39+
index: 0,
40+
step: { command: "wordpress.phpunit", args: ["plugin-slug=demo"] },
41+
}
42+
const startedAt = Date.now()
43+
44+
try {
45+
let rejectOperation: (reason: Error) => void = () => undefined
46+
const operation = new Promise<never>((_resolve, reject) => {
47+
rejectOperation = reject
48+
})
49+
const racedFailure = terminalizeOnPhpWasmRuntimeRejection(
50+
() => operation,
51+
() => rejectOperation(new Error("generic abort won the race")),
52+
).then(
53+
() => assert.fail("PHPUnit runtime rejection unexpectedly completed"),
54+
(reason: unknown) => reason,
55+
)
56+
process.emit("unhandledRejection", phpWasmFailure, Promise.resolve())
57+
const rejection = await racedFailure
58+
assert.equal((rejection as Error & { code?: string }).code, "wp-codebox-php-wasm-runtime-rejection")
59+
60+
const error = await Promise.race([
61+
executeRecipeWorkflowStep(runtime, workflowStep, root, undefined, root).then(
62+
() => assert.fail("PHPUnit step unexpectedly completed"),
63+
(reason: unknown) => reason,
64+
),
65+
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error("PHPUnit runtime rejection did not terminalize within 500ms")), 500)),
66+
])
67+
const failure = recipeStepFailure(workflowStep, error, startedAt)
68+
const serialized = JSON.stringify(failure)
69+
70+
assert.equal(failure.schema, "wp-codebox/recipe-step-failure/v1")
71+
assert.equal(failure.classification, "error")
72+
assert.ok(failure.durationMs < 500, `expected immediate terminal failure, received ${failure.durationMs}ms`)
73+
assert.match(failure.error.message, /Recipe workflow steps\[0\] failed/)
74+
assert.match(serialized, /wp-codebox-php-wasm-runtime-rejection/)
75+
assert.match(serialized, /infrastructure-failure/)
76+
assert.match(serialized, /php-wasm/)
77+
assert.match(serialized, /null function or function signature mismatch/)
78+
assert.match(serialized, /\[redacted\]/)
79+
assert.doesNotMatch(serialized, new RegExp(secret))
80+
assert.doesNotMatch(serialized, /recipe-run-timeout/)
81+
} finally {
82+
await runtime.destroy()
83+
await rm(root, { recursive: true, force: true })
84+
}
85+
86+
console.log("phpunit runtime rejection terminalization ok")

0 commit comments

Comments
 (0)