diff --git a/package.json b/package.json index c366d328..cc9ff780 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "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: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", "test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts", diff --git a/patches/@wp-playground+cli+3.1.46.patch b/patches/@wp-playground+cli+3.1.46.patch new file mode 100644 index 00000000..606ec121 --- /dev/null +++ b/patches/@wp-playground+cli+3.1.46.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/@wp-playground/cli/worker-thread-v1.js b/node_modules/@wp-playground/cli/worker-thread-v1.js +index 93b7099..65f76be 100644 +--- a/node_modules/@wp-playground/cli/worker-thread-v1.js ++++ b/node_modules/@wp-playground/cli/worker-thread-v1.js +@@ -157,6 +157,8 @@ async function C(r, e) { + } + process.on("unhandledRejection", (r) => { + S.error("Unhandled rejection:", r); ++ if (r instanceof WebAssembly.RuntimeError && String(r.stack).includes("php.wasm")) ++ throw r; + }); + const i = new _(), [c, p] = y( + new F(new f()), +diff --git a/node_modules/@wp-playground/cli/worker-thread-v2.js b/node_modules/@wp-playground/cli/worker-thread-v2.js +index fb79a59..77ddb67 100644 +--- a/node_modules/@wp-playground/cli/worker-thread-v2.js ++++ b/node_modules/@wp-playground/cli/worker-thread-v2.js +@@ -168,6 +168,8 @@ async function j(r, e) { + } + process.on("unhandledRejection", (r) => { + E.error("Unhandled rejection:", r); ++ if (r instanceof WebAssembly.RuntimeError && String(r.stack).includes("php.wasm")) ++ throw r; + }); + const i = new m(), [h, u] = k( + new L(new b()), diff --git a/patches/README.md b/patches/README.md index 0a32038c..1f03abd9 100644 --- a/patches/README.md +++ b/patches/README.md @@ -8,3 +8,10 @@ WP Codebox needs the repaired bounded Range decoder for the Cloudflare dev rig and browser asset path before that upstream change can be merged and released. Remove this patch and the package override after a published Playground package contains the same change. + +`@wp-playground+cli+3.1.46.patch` makes blueprint worker threads rethrow fatal +`WebAssembly.RuntimeError` rejections originating from `php.wasm` after logging +them. Other unhandled rejections retain the released CLI's log-only behavior. +Without this narrow terminalization, a poisoned PHP-WASM worker remains alive +and leaves the parent request pending until the recipe timeout. Remove the patch +after the Playground CLI ships the same worker terminalization behavior. diff --git a/tests/playground-worker-runtime-rejection.test.ts b/tests/playground-worker-runtime-rejection.test.ts new file mode 100644 index 00000000..09a98edd --- /dev/null +++ b/tests/playground-worker-runtime-rejection.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { pathToFileURL } from "node:url" +import { Worker } from "node:worker_threads" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-playground-worker-rejection-")) +const preloadPath = join(root, "reject-on-command.mjs") + +await writeFile(preloadPath, ` +import { parentPort } from "node:worker_threads" +parentPort?.on("message", (message) => { + if (message === "wp-codebox-trigger-non-wasm-rejection") { + Promise.reject(new Error("ordinary worker rejection")) + setTimeout(() => parentPort?.postMessage("wp-codebox-worker-survived"), 25) + } + if (message === "wp-codebox-trigger-php-wasm-rejection") { + const error = new WebAssembly.RuntimeError("null function or function signature mismatch") + error.stack = "RuntimeError: null function or function signature mismatch\\n at php.wasm.zif_mysqli_poll (wasm://wasm/php.wasm-05996276:wasm-function[12986]:0x9949a8)" + Promise.reject(error) + } +}) +`, "utf8") + +try { + for (const version of ["v1", "v2"]) { + await assertWorkerTerminates(version) + } +} finally { + await rm(root, { recursive: true, force: true }) +} + +async function assertWorkerTerminates(version: string): Promise { + const workerPath = resolve(`node_modules/@wp-playground/cli/worker-thread-${version}.js`) + const worker = new Worker(pathToFileURL(workerPath), { + execArgv: ["--import", pathToFileURL(preloadPath).href], + }) + + let workerError: Error | undefined + let initialized = false + let survivedOrdinaryRejection = false + const exitCode = await Promise.race([ + new Promise((resolveExit) => { + worker.once("message", (message: { command?: unknown; phpPort?: { close?: () => void } }) => { + if (message?.command !== "worker-script-initialized") return + initialized = true + message.phpPort?.close?.() + worker.postMessage("wp-codebox-trigger-non-wasm-rejection") + }) + worker.on("message", (message: unknown) => { + if (message !== "wp-codebox-worker-survived") return + survivedOrdinaryRejection = true + worker.postMessage("wp-codebox-trigger-php-wasm-rejection") + }) + worker.once("error", (error) => { + workerError = error + }) + worker.once("exit", resolveExit) + }), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(`Playground ${version} worker remained alive after a fatal PHP-WASM rejection`)), 3_000).unref() + }), + ]).finally(() => worker.terminate()) + + assert.ok(initialized, `Playground ${version} worker must initialize before the rejection checks`) + assert.ok(survivedOrdinaryRejection, `Playground ${version} worker must preserve ordinary unhandled rejection behavior`) + assert.notEqual(exitCode, 0, `Playground ${version} worker must exit nonzero after a fatal PHP-WASM rejection`) + assert.ok(workerError, `Playground ${version} worker must propagate its fatal PHP-WASM rejection`) + assert.match(workerError.message, /null function or function signature mismatch/) +} + +console.log("playground PHP-WASM worker rejection terminalization ok")