diff --git a/package.json b/package.json index 600ef9396..c366d328c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/runtime-playground/src/playground-command-errors.ts b/packages/runtime-playground/src/playground-command-errors.ts index ae4a501aa..1127c1665 100644 --- a/packages/runtime-playground/src/playground-command-errors.ts +++ b/packages/runtime-playground/src/playground-command-errors.ts @@ -3,6 +3,9 @@ import { errorMessage, parseJsonObject } from "@automattic/wp-codebox-core/inter export { errorMessage } +type PhpWasmUnhandledRejectionListener = (reason: unknown, promise: Promise) => void +const phpWasmRuntimeRejectionListeners = new Set() + export interface PlaygroundRunResponse { bytes?: unknown cause?: unknown @@ -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(operation: () => Promise, abort: () => void): Promise { + let rejectRuntime: (error: PhpWasmRuntimeRejectionError) => void = () => undefined + const runtimeRejection = new Promise((_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" diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index 5c75ec4d0..4f39848c1 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -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" @@ -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({ diff --git a/tests/phpunit-runtime-rejection.test.ts b/tests/phpunit-runtime-rejection.test.ts new file mode 100644 index 000000000..8c35e9e53 --- /dev/null +++ b/tests/phpunit-runtime-rejection.test.ts @@ -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(() => 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((_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((_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")