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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
"test:playground-phpunit-bootstrap-failure-integration": "tsx tests/playground-phpunit-bootstrap-failure.integration.test.ts",
"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",
"test:phpunit-runtime-failure-diagnostics": "tsx tests/phpunit-runtime-failure-diagnostics.test.ts",
"test:phpunit-runtime-rejection": "tsx tests/phpunit-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",
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",
Expand Down
53 changes: 53 additions & 0 deletions packages/runtime-playground/src/playground-command-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { errorMessage, parseJsonObject } from "@automattic/wp-codebox-core/inter

export { errorMessage }

type PhpWasmUnhandledRejectionListener = (reason: unknown, promise: Promise<unknown>) => void
const phpWasmRuntimeRejectionListeners = new Set<PhpWasmUnhandledRejectionListener>()

export interface PlaygroundRunResponse {
bytes?: unknown
cause?: unknown
Expand Down Expand Up @@ -47,6 +50,56 @@ export class PlaygroundCommandCrashError extends Error {
}
}

export class PhpWasmRuntimeRejectionError extends Error {
readonly code = "wp-codebox-php-wasm-runtime-rejection"
readonly failureClassification = "infrastructure-failure"
readonly runtime: { kind: "php-wasm"; signal: "unhandledRejection"; errorName: string; message: string }

constructor(cause: unknown) {
const message = redactDiagnosticText(errorMessage(cause))
super(`PHP WASM runtime rejected while a command was active: ${message}`)
this.name = "PhpWasmRuntimeRejectionError"
this.runtime = {
kind: "php-wasm",
signal: "unhandledRejection",
errorName: cause instanceof Error ? cause.name : "Error",
message,
}
}
}

export async function terminalizeOnPhpWasmRuntimeRejection<T>(operation: () => Promise<T>, abort: () => void): Promise<T> {
let rejectRuntime: (error: PhpWasmRuntimeRejectionError) => void = () => undefined
const runtimeRejection = new Promise<never>((_resolve, reject) => {
rejectRuntime = reject
})
const onUnhandledRejection: PhpWasmUnhandledRejectionListener = (reason) => {
if (isPhpWasmRuntimeRejection(reason)) {
rejectRuntime(new PhpWasmRuntimeRejectionError(reason))
abort()
} else if (!process.listeners("unhandledRejection").some((listener) => !phpWasmRuntimeRejectionListeners.has(listener as PhpWasmUnhandledRejectionListener))) {
queueMicrotask(() => { throw reason })
}
}

phpWasmRuntimeRejectionListeners.add(onUnhandledRejection)
process.on("unhandledRejection", onUnhandledRejection)
try {
return await Promise.race([Promise.resolve().then(operation), runtimeRejection])
} finally {
process.off("unhandledRejection", onUnhandledRejection)
phpWasmRuntimeRejectionListeners.delete(onUnhandledRejection)
}
}

function isPhpWasmRuntimeRejection(reason: unknown): boolean {
if (!(reason instanceof Error) || reason.name !== "RuntimeError") {
return false
}

return typeof reason.stack === "string" && /(?:wasm:\/\/wasm\/)?php\.wasm[.:]/.test(reason.stack)
}

export class PlaygroundCliExitError extends Error {
readonly code = "wp-codebox-playground-cli-exited"

Expand Down
13 changes: 9 additions & 4 deletions packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { firstCommandWordPressAdminAuthRequirement } from "./command-auth-requir
import { argValue, cleanWpCliOutput, runWithTemporaryWpCliScript, shellArgv, wordpressBlockExerciseInputFromArgs, wordpressBlockExercisePhpCode, wordpressCrudOperationFromArgs, wordpressCrudOperationPhpCode, wordpressDbOperationFromArgs, wordpressDbOperationPhpCode, wpCliCommandFromArgs } from "./commands.js"
import { bootstrapPhpCode } from "./php-bootstrap.js"
import { observeHttpResponse as observeHttpResponseArtifact, observeWordPressState as observeWordPressStateArtifact } from "./observation-artifacts.js"
import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, type PlaygroundRunResponse } from "./playground-command-errors.js"
import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, terminalizeOnPhpWasmRuntimeRejection, type PlaygroundRunResponse } from "./playground-command-errors.js"
import { startPlaygroundCliServer, type PlaygroundCliModule } from "./playground-cli-runner.js"
import type { PlaygroundCliServer } from "./preview-server.js"
import { collectPlaygroundArtifacts } from "./runtime-artifact-helpers.js"
Expand Down Expand Up @@ -365,9 +365,14 @@ class PlaygroundRuntime implements Runtime {
this.activeExecutionAbortControllers.add(abortController)
try {
const executionSpec = executionSpecWithEnvironment(spec)
const output = await this.executionSignals.run(abortController.signal, 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))
const output = await this.executionSignals.run(abortController.signal, async () => {
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()
})
const finishedAt = now()
const envelope = typeof output === "string"
? runtimeCommandResultEnvelopeFromOutput({
Expand Down
86 changes: 86 additions & 0 deletions tests/phpunit-runtime-rejection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 { terminalizeOnPhpWasmRuntimeRejection } from "../packages/runtime-playground/src/playground-command-errors.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-phpunit-runtime-rejection-"))
const secret = "sk-abcdefghijklmnopqrstuvwxyz"
const phpWasmFailure = new WebAssembly.RuntimeError(`null function or function signature mismatch ${secret}`)
phpWasmFailure.stack = `RuntimeError: ${phpWasmFailure.message}\n at php.wasm.zif_mysqli_poll (wasm://wasm/php.wasm-05996276:wasm-function[12986]:0x9949a8)`

const cliModule: PlaygroundCliModule = {
runCLI: async () => ({
serverUrl: "http://127.0.0.1:9403",
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: "phpunit-runtime-rejection", version: "7.0", phpVersion: "8.3", blueprint: { steps: [] } },
policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.phpunit"], secrets: "none", approvals: "never" },
}, createPlaygroundRuntimeBackend({ cliModule }))

const workflowStep = {
phase: "steps" as const,
index: 0,
step: { command: "wordpress.phpunit", args: ["plugin-slug=demo"] },
}
const startedAt = Date.now()

try {
let rejectOperation: (reason: Error) => void = () => undefined
const operation = new Promise<never>((_resolve, reject) => {
rejectOperation = reject
})
const racedFailure = terminalizeOnPhpWasmRuntimeRejection(
() => operation,
() => rejectOperation(new Error("generic abort won the race")),
).then(
() => assert.fail("PHPUnit runtime rejection unexpectedly completed"),
(reason: unknown) => reason,
)
process.emit("unhandledRejection", phpWasmFailure, Promise.resolve())
const rejection = await racedFailure
assert.equal((rejection as Error & { code?: string }).code, "wp-codebox-php-wasm-runtime-rejection")

const error = await Promise.race([
executeRecipeWorkflowStep(runtime, workflowStep, root, undefined, root).then(
() => assert.fail("PHPUnit step unexpectedly completed"),
(reason: unknown) => reason,
),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error("PHPUnit runtime rejection did not terminalize 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")
assert.ok(failure.durationMs < 500, `expected immediate terminal failure, received ${failure.durationMs}ms`)
assert.match(failure.error.message, /Recipe workflow steps\[0\] failed/)
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/)
assert.match(serialized, /\[redacted\]/)
assert.doesNotMatch(serialized, new RegExp(secret))
assert.doesNotMatch(serialized, /recipe-run-timeout/)
} finally {
await runtime.destroy()
await rm(root, { recursive: true, force: true })
}

console.log("phpunit runtime rejection terminalization ok")
Loading