From 19b21c8ad79f8a7b212a7901d0f243806823822b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 13:38:18 -0400 Subject: [PATCH 1/2] Terminate poisoned Playground workers --- package.json | 1 + patches/@wp-playground+cli+3.1.46.patch | 24 ++++++++ patches/README.md | 6 ++ ...layground-worker-runtime-rejection.test.ts | 58 +++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 patches/@wp-playground+cli+3.1.46.patch create mode 100644 tests/playground-worker-runtime-rejection.test.ts 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..28247aea --- /dev/null +++ b/patches/@wp-playground+cli+3.1.46.patch @@ -0,0 +1,24 @@ +diff --git a/node_modules/@wp-playground/cli/worker-thread-v1.js b/node_modules/@wp-playground/cli/worker-thread-v1.js +index 93b7099..63e96bc 100644 +--- a/node_modules/@wp-playground/cli/worker-thread-v1.js ++++ b/node_modules/@wp-playground/cli/worker-thread-v1.js +@@ -157,6 +157,7 @@ async function C(r, e) { + } + process.on("unhandledRejection", (r) => { + S.error("Unhandled rejection:", r); ++ 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..5d09b1f 100644 +--- a/node_modules/@wp-playground/cli/worker-thread-v2.js ++++ b/node_modules/@wp-playground/cli/worker-thread-v2.js +@@ -168,6 +168,7 @@ async function j(r, e) { + } + process.on("unhandledRejection", (r) => { + E.error("Unhandled rejection:", r); ++ 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..525a5587 100644 --- a/patches/README.md +++ b/patches/README.md @@ -8,3 +8,9 @@ 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 +unhandled runtime rejections after logging them. The released CLI otherwise +keeps a poisoned PHP-WASM worker 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..45913c0d --- /dev/null +++ b/tests/playground-worker-runtime-rejection.test.ts @@ -0,0 +1,58 @@ +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-runtime-rejection") return + 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 + const exitCode = await Promise.race([ + new Promise((resolveExit) => { + worker.once("message", (message: { command?: unknown; phpPort?: { close?: () => void } }) => { + if (message?.command !== "worker-script-initialized") return + message.phpPort?.close?.() + worker.postMessage("wp-codebox-trigger-runtime-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 an unhandled runtime rejection`)), 3_000).unref() + }), + ]).finally(() => worker.terminate()) + + assert.notEqual(exitCode, 0, `Playground ${version} worker must exit nonzero after an unhandled runtime rejection`) + assert.ok(workerError, `Playground ${version} worker must propagate its unhandled runtime rejection`) + assert.match(workerError.message, /null function or function signature mismatch/) +} + +console.log("playground worker runtime rejection terminalization ok") From 72029eaf77dac53b0ebfb9e6cc12b2f8ce78be69 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 13:45:15 -0400 Subject: [PATCH 2/2] Narrow Playground worker terminalization --- patches/@wp-playground+cli+3.1.46.patch | 14 ++++---- patches/README.md | 11 ++++--- ...layground-worker-runtime-rejection.test.ts | 33 ++++++++++++++----- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/patches/@wp-playground+cli+3.1.46.patch b/patches/@wp-playground+cli+3.1.46.patch index 28247aea..606ec121 100644 --- a/patches/@wp-playground+cli+3.1.46.patch +++ b/patches/@wp-playground+cli+3.1.46.patch @@ -1,24 +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..63e96bc 100644 +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,7 @@ async function C(r, e) { +@@ -157,6 +157,8 @@ async function C(r, e) { } process.on("unhandledRejection", (r) => { S.error("Unhandled rejection:", r); -+ throw 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..5d09b1f 100644 +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,7 @@ async function j(r, e) { +@@ -168,6 +168,8 @@ async function j(r, e) { } process.on("unhandledRejection", (r) => { E.error("Unhandled rejection:", r); -+ throw 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 525a5587..1f03abd9 100644 --- a/patches/README.md +++ b/patches/README.md @@ -9,8 +9,9 @@ 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 -unhandled runtime rejections after logging them. The released CLI otherwise -keeps a poisoned PHP-WASM worker alive and leaves the parent request pending -until the recipe timeout. Remove the patch after the Playground CLI ships the -same worker terminalization behavior. +`@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 index 45913c0d..09a98edd 100644 --- a/tests/playground-worker-runtime-rejection.test.ts +++ b/tests/playground-worker-runtime-rejection.test.ts @@ -11,10 +11,15 @@ 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-runtime-rejection") return - 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) + 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") @@ -33,12 +38,20 @@ async function assertWorkerTerminates(version: string): Promise { }) 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-runtime-rejection") + 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 @@ -46,13 +59,15 @@ async function assertWorkerTerminates(version: string): Promise { worker.once("exit", resolveExit) }), new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error(`Playground ${version} worker remained alive after an unhandled runtime rejection`)), 3_000).unref() + setTimeout(() => reject(new Error(`Playground ${version} worker remained alive after a fatal PHP-WASM rejection`)), 3_000).unref() }), ]).finally(() => worker.terminate()) - assert.notEqual(exitCode, 0, `Playground ${version} worker must exit nonzero after an unhandled runtime rejection`) - assert.ok(workerError, `Playground ${version} worker must propagate its unhandled runtime rejection`) + 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 worker runtime rejection terminalization ok") +console.log("playground PHP-WASM worker rejection terminalization ok")