Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fix-cast-error-cause-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

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.
Original file line number Diff line number Diff line change
@@ -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,
}) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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);
// 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("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;

const error = castErrorCause(serialized);

expect(error.message).toBe("outer");
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 }) => {
const error = castErrorCause("string cause");

expect(error).toBeInstanceOf(Error);
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);
});
});
42 changes: 42 additions & 0 deletions packages/wrangler/src/api/startDevWorker/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,54 @@ export function castErrorCause(cause: unknown) {
return cause;
}

// 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) {
error.name = cause.name;
}
if (cause.stack !== undefined) {
error.stack = cause.stack;
}
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;
}

const error = new Error();
error.cause = cause;

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<string, unknown>;
return (
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))
);
}

// ConfigController
export type ConfigUpdateEvent = {
type: "configUpdate";
Expand Down
Loading