Skip to content
Merged
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
29 changes: 6 additions & 23 deletions src/lib/data/codecErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import * as zarr from "zarrita";
* reaches the UI its class and fields are gone.
*/

export type TDataErrorExplanation = {
export type TCodecErrorExplanation = {
heading: string;
detail: string;
};
Expand Down Expand Up @@ -65,7 +65,7 @@ function unsupportedDataType(error: unknown) {
return messageOf(error).match(UNSUPPORTED_DATA_TYPE)?.[1];
}

function explainUnknownCodec(codec: string): TDataErrorExplanation {
function explainUnknownCodec(codec: string): TCodecErrorExplanation {
return {
heading: `Unsupported codec: ${codec}`,
detail:
Expand All @@ -78,7 +78,7 @@ function explainUnknownCodec(codec: string): TDataErrorExplanation {
function explainCodecFailure(
codec: string,
cause: string | undefined
): TDataErrorExplanation {
): TCodecErrorExplanation {
return {
heading: `Codec failed: ${codec}`,
detail: cause
Expand All @@ -88,7 +88,7 @@ function explainCodecFailure(
};
}

function explainUnsupportedDataType(dataType: string): TDataErrorExplanation {
function explainUnsupportedDataType(dataType: string): TCodecErrorExplanation {
const isFloat16 = dataType.startsWith("float16");
return {
heading: `Unsupported data type: ${dataType}`,
Expand All @@ -104,9 +104,9 @@ function explainUnsupportedDataType(dataType: string): TDataErrorExplanation {
* terms. Returns `undefined` for everything else, so callers fall back to
* whatever they showed before.
*/
export function explainDataError(
export function explainCodecError(
error: unknown
): TDataErrorExplanation | undefined {
): TCodecErrorExplanation | undefined {
const missingCodec = unknownCodecName(error);
if (missingCodec) {
return explainUnknownCodec(missingCodec);
Expand All @@ -124,20 +124,3 @@ export function explainDataError(

return undefined;
}

/**
* Flatten an error to a string that survives `postMessage`.
*
* zarrita reports a codec that threw as a `CodecPipelineError` naming the
* codec, with the reason it threw — a checksum mismatch, a truncated chunk —
* on `cause`. Structured-cloning an Error keeps neither the subclass nor the
* cause, so a worker that posts back only `error.message` drops the reason
* before anyone can read it. Appending the cause keeps both halves.
*/
export function flattenErrorMessage(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const cause = error.cause instanceof Error ? error.cause.message : undefined;
return cause ? `${error.message} — ${cause}` : error.message;
}
2 changes: 1 addition & 1 deletion src/lib/grids/gridData.worker.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/// <reference lib="webworker" />

import { flattenErrorMessage } from "@/lib/data/codecErrors.ts";
import { ZarrDataManager } from "@/lib/data/ZarrDataManager.ts";
import {
GridDataWorkerMessageType,
type TGridDataWorkerRequest,
type TGridDataWorkerResponse,
} from "@/lib/grids/gridDataWorkerProtocol.ts";
import { flattenErrorMessage } from "@/utils/errorHandling.ts";

const workerScope = self as unknown as DedicatedWorkerGlobalScope;

Expand Down
4 changes: 2 additions & 2 deletions src/ui/common/useLog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ToastType, useToast } from "./useToast.ts";

import { explainDataError } from "@/lib/data/codecErrors.ts";
import { explainCodecError } from "@/lib/data/codecErrors.ts";
import { getErrorMessage, toNormalizedError } from "@/utils/errorHandling.ts";

// A codec or data-type failure is worth reading and worth writing down, so it
Expand All @@ -16,7 +16,7 @@ export function useLog() {
// A recognised codec problem describes itself better than the call site
// can: "Could not fetch data" sends the reader to the network, which is
// not where the problem is.
const explanation = explainDataError(maybeError);
const explanation = explainCodecError(maybeError);
const prefix = explanation?.heading ?? context ?? "Error";
addToast(prefix, {
detail: explanation?.detail ?? `${getErrorMessage(error)}`,
Expand Down
17 changes: 17 additions & 0 deletions src/utils/errorHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,20 @@ export function getErrorMessage(error: unknown) {
// strip quotation marks added by JSON.stringify
return errorMessage.replace(/^"(.*)"$/, "$1");
}

/**
* Flatten an error to a string that survives `postMessage`.
*
* zarrita reports a codec that threw as a `CodecPipelineError` naming the
* codec, with the reason it threw — a checksum mismatch, a truncated chunk —
* on `cause`. Structured-cloning an Error keeps neither the subclass nor the
* cause, so a worker that posts back only `error.message` drops the reason
* before anyone can read it. Appending the cause keeps both halves.
*/
export function flattenErrorMessage(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const cause = error.cause instanceof Error ? error.cause.message : undefined;
return cause ? `${error.message} — ${cause}` : error.message;
}
39 changes: 11 additions & 28 deletions tests/unit/lib/data/codecErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { describe, expect, it } from "vitest";
import { get, open } from "zarrita";

import "@/lib/data/codecs.ts";
import {
explainDataError,
flattenErrorMessage,
} from "@/lib/data/codecErrors.ts";
import { explainCodecError } from "@/lib/data/codecErrors.ts";
import { flattenErrorMessage } from "@/utils/errorHandling.ts";

/**
* Every error fed to `explainDataError` here is one zarrita actually threw,
* Every error fed to `explainCodecError` here is one zarrita actually threw,
* not a hand-written stand-in, so the patterns stay tied to the real messages.
*
* Each case is checked twice: once as thrown, and once after the round trip
Expand Down Expand Up @@ -69,18 +67,18 @@ describe("an unregistered codec", () => {
}

it("names the codec in the heading", async () => {
const explanation = explainDataError(await unknownCodecError());
const explanation = explainCodecError(await unknownCodecError());
expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize");
});

it("says the network is not the problem", async () => {
const explanation = explainDataError(await unknownCodecError());
const explanation = explainCodecError(await unknownCodecError());
expect(explanation?.detail).toContain("numcodecs.quantize");
expect(explanation?.detail).toContain("connection are fine");
});

it("is still recognised after the worker flattens it", async () => {
const explanation = explainDataError(
const explanation = explainCodecError(
throughWorker(await unknownCodecError())
);
expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize");
Expand All @@ -98,19 +96,19 @@ describe("a registered codec that rejects the chunk", () => {
}

it("names the codec that failed", async () => {
const explanation = explainDataError(await codecPipelineError());
const explanation = explainCodecError(await codecPipelineError());
expect(explanation?.heading).toBe("Codec failed: numcodecs.fletcher32");
});

it("keeps the reason zarrita put on the cause", async () => {
const explanation = explainDataError(await codecPipelineError());
const explanation = explainCodecError(await codecPipelineError());
expect(explanation?.detail).toContain("checksum mismatch");
});

it("keeps the reason across the worker boundary", async () => {
// Without `flattenErrorMessage` the cause is dropped by structured clone
// and the reader is told only that some codec failed.
const explanation = explainDataError(
const explanation = explainCodecError(
throughWorker(await codecPipelineError())
);
expect(explanation?.heading).toBe("Codec failed: numcodecs.fletcher32");
Expand All @@ -133,7 +131,7 @@ describe("a data type this browser cannot represent", () => {
}

it("names the data type and the browsers that support it", async () => {
const explanation = explainDataError(await unsupportedDataTypeError());
const explanation = explainCodecError(await unsupportedDataTypeError());
expect(explanation?.heading).toBe("Unsupported data type: float16");
expect(explanation?.detail).toContain("Safari 26");
});
Expand All @@ -150,21 +148,6 @@ describe("errors that are not codec problems", () => {
];

it.each(UNRELATED)("passes through %s", (error) => {
expect(explainDataError(error)).toBeUndefined();
});
});

describe("flattenErrorMessage", () => {
it("returns the message when there is no cause", () => {
expect(flattenErrorMessage(new Error("plain"))).toBe("plain");
});

it("appends the cause when there is one", () => {
const error = new Error("outer", { cause: new Error("inner") });
expect(flattenErrorMessage(error)).toBe("outer — inner");
});

it("stringifies a non-error", () => {
expect(flattenErrorMessage("oops")).toBe("oops");
expect(explainCodecError(error)).toBeUndefined();
});
});
18 changes: 18 additions & 0 deletions tests/unit/utils/errorHandling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";

import { flattenErrorMessage } from "@/utils/errorHandling.ts";

describe("flattenErrorMessage", () => {
it("returns the message when there is no cause", () => {
expect(flattenErrorMessage(new Error("plain"))).toBe("plain");
});

it("appends the cause when there is one", () => {
const error = new Error("outer", { cause: new Error("inner") });
expect(flattenErrorMessage(error)).toBe("outer — inner");
});

it("stringifies a non-error", () => {
expect(flattenErrorMessage("oops")).toBe("oops");
});
});
Loading