From 7ff41ea52cb64dac35343767b7a75ea12fc6fda8 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Wed, 29 Jul 2026 21:46:05 +0900 Subject: [PATCH 1/4] fix(wrangler): preserve serialized error details in castErrorCause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Errors reported by the ProxyWorker cross a JSON channel and arrive as SerializedError plain objects. castErrorCause wrapped them in a message-less new Error(), so the fatal log from emitErrorEvent was an empty '✘ [ERROR]' with no indication of the actual failure (see #14641, e.g. transient 'Network connection lost.' keep-alive races killing the whole dev server with an empty error). Rehydrate the serialized message/name/stack instead. --- .changeset/fix-cast-error-cause-message.md | 7 +++ .../api/startDevWorker/events.test.ts | 53 +++++++++++++++++++ .../wrangler/src/api/startDevWorker/events.ts | 24 +++++++++ 3 files changed, 84 insertions(+) create mode 100644 .changeset/fix-cast-error-cause-message.md create mode 100644 packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts diff --git a/.changeset/fix-cast-error-cause-message.md b/.changeset/fix-cast-error-cause-message.md new file mode 100644 index 00000000000..e14238ba3f0 --- /dev/null +++ b/.changeset/fix-cast-error-cause-message.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +fix: surface the original error message, name and stack when the dev server reports an internal error + +Errors reported by the ProxyWorker cross a JSON channel, so they arrived as plain objects and were previously wrapped in a message-less `Error`, causing `wrangler dev` to exit with an empty `✘ [ERROR]` log. Such errors (e.g. `Network connection lost.`, see #14641) are now rehydrated with their original message, name and stack so the failure is actually diagnosable. diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts new file mode 100644 index 00000000000..eaeb62df74b --- /dev/null +++ b/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "vitest"; +import { + castErrorCause, + serialiseError, +} from "../../../api/startDevWorker/events"; + +describe("castErrorCause", () => { + it("returns Error instances unchanged", ({ expect }) => { + const cause = new TypeError("boom"); + expect(castErrorCause(cause)).toBe(cause); + }); + + it("rehydrates a SerializedError, preserving message/name/stack", ({ + expect, + }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14641: + // the ProxyWorker's error reports cross a JSON channel, so they arrive as + // plain objects. castErrorCause used to wrap them in a message-less + // `new Error()`, making the fatal log an empty `✘ [ERROR]` with no clue + // about the actual failure (e.g. "Network connection lost."). + const original = new Error("Network connection lost."); + const serialized = JSON.parse( + JSON.stringify(serialiseError(original)) + ) as unknown; + + const error = castErrorCause(serialized); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Network connection lost."); + expect(error.name).toBe("Error"); + expect(error.stack).toBe(original.stack); + }); + + it("preserves the serialized nested cause", ({ expect }) => { + const original = new Error("outer", { cause: new Error("inner") }); + const serialized = JSON.parse( + JSON.stringify(serialiseError(original)) + ) as unknown; + + const error = castErrorCause(serialized); + + expect(error.message).toBe("outer"); + expect(error.cause).toMatchObject({ message: "inner" }); + }); + + it("wraps other non-Error causes, keeping them as `cause`", ({ expect }) => { + const error = castErrorCause("string cause"); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe(""); + expect(error.cause).toBe("string cause"); + }); +}); diff --git a/packages/wrangler/src/api/startDevWorker/events.ts b/packages/wrangler/src/api/startDevWorker/events.ts index 79ce57502f5..5c7599a3c43 100644 --- a/packages/wrangler/src/api/startDevWorker/events.ts +++ b/packages/wrangler/src/api/startDevWorker/events.ts @@ -34,12 +34,36 @@ export function castErrorCause(cause: unknown) { return cause; } + // Errors that cross a JSON channel (e.g. the ProxyWorker's error reports) + // arrive as SerializedError plain objects — rehydrate them so their + // message/name/stack are surfaced instead of an empty `new Error()`. + if (isSerializedError(cause)) { + const error = new Error(cause.message); + if (cause.name !== undefined) { + error.name = cause.name; + } + if (cause.stack !== undefined) { + error.stack = cause.stack; + } + error.cause = cause.cause; + return error; + } + const error = new Error(); error.cause = cause; return error; } +function isSerializedError(value: unknown): value is SerializedError { + return ( + typeof value === "object" && + value !== null && + "message" in value && + typeof value.message === "string" + ); +} + // ConfigController export type ConfigUpdateEvent = { type: "configUpdate"; From 5a3fa4d8be1cfef872e1a7d5f458e722570ce8b8 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Wed, 29 Jul 2026 21:52:47 +0900 Subject: [PATCH 2/4] test(wrangler): cover ProxyWorker error reports end-to-end through onProxyWorkerMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the emitted error event carries the original message/stack after crossing the JSON channel, guarding against the empty '✘ [ERROR]' regression from #14641. --- .../startDevWorker/ProxyController.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts index 3af59552994..f5a08b65136 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts @@ -1,11 +1,38 @@ import { describe, test } from "vitest"; +import { serialiseError } from "../../../api/startDevWorker/events"; import { ProxyController } from "../../../api/startDevWorker/ProxyController"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; +import type { SerializedError } from "../../../api/startDevWorker/events"; describe("ProxyController", () => { mockConsoleMethods(); + test("ProxyWorker error reports preserve message/name/stack across the JSON channel", async ({ + expect, + }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14641: + // the ProxyWorker's error reports arrive as JSON-serialized plain objects, + // and used to be re-wrapped in a message-less Error, so the resulting + // fatal log was an empty `✘ [ERROR]` with no clue about the failure. + const bus = new FakeBus(); + const controller = new ProxyController(bus); + const waited = bus.waitFor("error"); + + const original = new Error("Network connection lost."); + const serialized = JSON.parse( + JSON.stringify(serialiseError(original)) + ) as SerializedError; + controller.onProxyWorkerMessage({ type: "error", error: serialized }); + + const event = await waited; + expect(event.source).toBe("ProxyController"); + expect(event.reason).toBe("Error inside ProxyWorker"); + expect(event.cause).toBeInstanceOf(Error); + expect(event.cause.message).toBe("Network connection lost."); + expect(event.cause.stack).toBe(original.stack); + }); + test("Runtime.exceptionThrown dispatches a typed runtimeError event", async ({ expect, }) => { From 92fea3d7c961a3c605e53002eb517fd4f2bb30c3 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Wed, 29 Jul 2026 21:59:41 +0900 Subject: [PATCH 3/4] chore: drop conventional-commit prefix from changeset title per REVIEW.md --- .changeset/fix-cast-error-cause-message.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-cast-error-cause-message.md b/.changeset/fix-cast-error-cause-message.md index e14238ba3f0..453a243509e 100644 --- a/.changeset/fix-cast-error-cause-message.md +++ b/.changeset/fix-cast-error-cause-message.md @@ -2,6 +2,6 @@ "wrangler": patch --- -fix: surface the original error message, name and stack when the dev server reports an internal error +Surface the original error message, name and stack when the dev server reports an internal error Errors reported by the ProxyWorker cross a JSON channel, so they arrived as plain objects and were previously wrapped in a message-less `Error`, causing `wrangler dev` to exit with an empty `✘ [ERROR]` log. Such errors (e.g. `Network connection lost.`, see #14641) are now rehydrated with their original message, name and stack so the failure is actually diagnosable. From 107040362d261cdf9aef9140358d296d09715f3d Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Wed, 29 Jul 2026 22:23:03 +0900 Subject: [PATCH 4/4] fix(wrangler): harden SerializedError rehydration in castErrorCause Review findings: tighten isSerializedError to the exact shape produced by serialiseError/ProxyWorker (no extra keys) so arbitrary message-bearing objects keep the wrap-as-cause behavior; skip the own 'cause: undefined' property that polluted util.format debug logs; recursively rehydrate serialized cause chains so instanceof-Error cause classifiers work across the JSON channel. --- .../api/startDevWorker/events.test.ts | 27 +++++++++++++-- .../wrangler/src/api/startDevWorker/events.ts | 34 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts index eaeb62df74b..1d5cd3ce65b 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts @@ -29,10 +29,15 @@ describe("castErrorCause", () => { expect(error.message).toBe("Network connection lost."); expect(error.name).toBe("Error"); expect(error.stack).toBe(original.stack); + // No own `cause: undefined` property that would render as + // `{ cause: undefined }` in util.format-based debug logs + expect(Object.hasOwn(error, "cause")).toBe(false); }); - it("preserves the serialized nested cause", ({ expect }) => { - const original = new Error("outer", { cause: new Error("inner") }); + it("rehydrates the serialized nested cause chain into Error instances", ({ + expect, + }) => { + const original = new Error("outer", { cause: new TypeError("inner") }); const serialized = JSON.parse( JSON.stringify(serialiseError(original)) ) as unknown; @@ -40,7 +45,10 @@ describe("castErrorCause", () => { const error = castErrorCause(serialized); expect(error.message).toBe("outer"); - expect(error.cause).toMatchObject({ message: "inner" }); + expect(error.cause).toBeInstanceOf(Error); + const inner = error.cause as Error; + expect(inner.message).toBe("inner"); + expect(inner.name).toBe("TypeError"); }); it("wraps other non-Error causes, keeping them as `cause`", ({ expect }) => { @@ -50,4 +58,17 @@ describe("castErrorCause", () => { expect(error.message).toBe(""); expect(error.cause).toBe("string cause"); }); + + it("preserves message-bearing objects with extra properties verbatim", ({ + expect, + }) => { + // Not a SerializedError (extra `code` key): rehydrating would silently + // drop `code`, so the object must be kept whole as `cause` instead + const cause = { message: "failed", code: "E_CUSTOM" }; + + const error = castErrorCause(cause); + + expect(error.message).toBe(""); + expect(error.cause).toBe(cause); + }); }); diff --git a/packages/wrangler/src/api/startDevWorker/events.ts b/packages/wrangler/src/api/startDevWorker/events.ts index 5c7599a3c43..876b4244858 100644 --- a/packages/wrangler/src/api/startDevWorker/events.ts +++ b/packages/wrangler/src/api/startDevWorker/events.ts @@ -34,9 +34,10 @@ export function castErrorCause(cause: unknown) { return cause; } - // Errors that cross a JSON channel (e.g. the ProxyWorker's error reports) - // arrive as SerializedError plain objects — rehydrate them so their - // message/name/stack are surfaced instead of an empty `new Error()`. + // Errors that cross a JSON channel (e.g. the ProxyWorker's and + // InspectorProxyWorker's error reports) arrive as SerializedError plain + // objects — rehydrate them so their message/name/stack are surfaced + // instead of an empty `new Error()`. if (isSerializedError(cause)) { const error = new Error(cause.message); if (cause.name !== undefined) { @@ -45,7 +46,14 @@ export function castErrorCause(cause: unknown) { if (cause.stack !== undefined) { error.stack = cause.stack; } - error.cause = cause.cause; + if (cause.cause !== undefined) { + // Rehydrate serialized cause chains (serialiseError recurses) so the + // `instanceof Error` cause-chain classifiers in handle-errors.ts work + // across the channel; leave non-SerializedError causes untouched. + error.cause = isSerializedError(cause.cause) + ? castErrorCause(cause.cause) + : cause.cause; + } return error; } @@ -55,12 +63,22 @@ export function castErrorCause(cause: unknown) { return error; } +const serializedErrorKeys = new Set(["message", "name", "stack", "cause"]); + +// Matches exactly the shape produced by serialiseError() and the +// ProxyWorker's error reports. Kept strict (no extra keys) so that arbitrary +// message-bearing objects passed to castErrorCause() are still preserved +// verbatim as `cause` rather than lossily rehydrated. function isSerializedError(value: unknown): value is SerializedError { + if (typeof value !== "object" || value === null) { + return false; + } + const record = value as Record; return ( - typeof value === "object" && - value !== null && - "message" in value && - typeof value.message === "string" + typeof record.message === "string" && + (record.name === undefined || typeof record.name === "string") && + (record.stack === undefined || typeof record.stack === "string") && + Object.keys(record).every((key) => serializedErrorKeys.has(key)) ); }