diff --git a/src/lib/data/codecErrors.ts b/src/lib/data/codecErrors.ts new file mode 100644 index 00000000..928bdb9f --- /dev/null +++ b/src/lib/data/codecErrors.ts @@ -0,0 +1,126 @@ +import * as zarr from "zarrita"; + +/** + * A dataset whose chunks use a codec gridlook has no decoder for behaves + * exactly like a dataset that is merely slow or unreachable: the repository + * opens, every variable is listed, and the failure only arrives on the first + * chunk read. zarrita raises a precise error at that point — it names the + * codec — but the message reaches the reader under a "Could not fetch data" + * heading that points at the network, which is the wrong place to look. + * + * This module classifies those errors and rewrites them into something that + * says which codec is missing. It changes no behaviour and adds no data path: + * an unsupported dataset still fails, it just says why. Naming the codec is + * also what makes the gap reportable — an unsupported codec is invisible until + * someone runs into a dataset that uses it. + * + * Errors are matched both structurally and by message text, because the grid + * data worker flattens errors to a plain string before posting them back to + * the main thread (see `src/lib/grids/gridData.worker.ts`), so by the time one + * reaches the UI its class and fields are gone. + */ + +export type TCodecErrorExplanation = { + heading: string; + detail: string; +}; + +const UNKNOWN_CODEC = /^Unknown codec:\s*(\S+)/; +const CODEC_PIPELINE = /^Failed to (?:de|en)code chunk via codec "([^"]+)"/; +const UNSUPPORTED_DATA_TYPE = /^Unknown or unsupported dataType:\s*(\S+)/; + +/** The `: reason` the worker appends when flattening a wrapped error. */ +const FLATTENED_CAUSE = /(?:^|\s)—\s(.+)$/; + +function messageOf(error: unknown) { + if (error instanceof Error) { + return error.message; + } + return typeof error === "string" ? error : ""; +} + +function causeMessageOf(error: unknown) { + if (error instanceof Error && error.cause instanceof Error) { + return error.cause.message; + } + // Across the worker boundary the cause survives only as flattened text. + return messageOf(error).match(FLATTENED_CAUSE)?.[1]; +} + +function unknownCodecName(error: unknown) { + if (zarr.isZarritaError(error, "UnknownCodecError")) { + return error.codec; + } + return messageOf(error).match(UNKNOWN_CODEC)?.[1]; +} + +function failingCodecName(error: unknown) { + if (zarr.isZarritaError(error, "CodecPipelineError")) { + return error.codec; + } + return messageOf(error).match(CODEC_PIPELINE)?.[1]; +} + +function unsupportedDataType(error: unknown) { + return messageOf(error).match(UNSUPPORTED_DATA_TYPE)?.[1]; +} + +function explainUnknownCodec(codec: string): TCodecErrorExplanation { + return { + heading: `Unsupported codec: ${codec}`, + detail: + `This dataset's chunks are compressed with "${codec}", which gridlook ` + + `has no decoder for. The data and the connection are fine — only the ` + + `codec is missing. Quote that codec name when reporting it.`, + }; +} + +function explainCodecFailure( + codec: string, + cause: string | undefined +): TCodecErrorExplanation { + return { + heading: `Codec failed: ${codec}`, + detail: cause + ? `Decoding a chunk with "${codec}" failed: ${cause}` + : `Decoding a chunk with "${codec}" failed. The codec is available but ` + + `rejected this dataset's chunks.`, + }; +} + +function explainUnsupportedDataType(dataType: string): TCodecErrorExplanation { + const isFloat16 = dataType.startsWith("float16"); + return { + heading: `Unsupported data type: ${dataType}`, + detail: isFloat16 + ? `This variable is stored as ${dataType}, which this browser cannot ` + + `represent. Chrome 135, Firefox 129 or Safari 26 and newer support it.` + : `This variable is stored as ${dataType}, which gridlook cannot read.`, + }; +} + +/** + * Recognise a codec or data-type failure and describe it in the reader's + * terms. Returns `undefined` for everything else, so callers fall back to + * whatever they showed before. + */ +export function explainCodecError( + error: unknown +): TCodecErrorExplanation | undefined { + const missingCodec = unknownCodecName(error); + if (missingCodec) { + return explainUnknownCodec(missingCodec); + } + + const brokenCodec = failingCodecName(error); + if (brokenCodec) { + return explainCodecFailure(brokenCodec, causeMessageOf(error)); + } + + const dataType = unsupportedDataType(error); + if (dataType) { + return explainUnsupportedDataType(dataType); + } + + return undefined; +} diff --git a/src/lib/grids/gridData.worker.ts b/src/lib/grids/gridData.worker.ts index af94fb7d..964dff9b 100644 --- a/src/lib/grids/gridData.worker.ts +++ b/src/lib/grids/gridData.worker.ts @@ -6,6 +6,7 @@ import { type TGridDataWorkerRequest, type TGridDataWorkerResponse, } from "@/lib/grids/gridDataWorkerProtocol.ts"; +import { flattenErrorMessage } from "@/utils/errorHandling.ts"; const workerScope = self as unknown as DedicatedWorkerGlobalScope; @@ -35,7 +36,7 @@ workerScope.onmessage = async (event: MessageEvent) => { const response: TGridDataWorkerResponse = { requestId, type: GridDataWorkerMessageType.ERROR, - message: error instanceof Error ? error.message : String(error), + message: flattenErrorMessage(error), }; workerScope.postMessage(response); } diff --git a/src/ui/common/useLog.ts b/src/ui/common/useLog.ts index db616910..4e37544d 100644 --- a/src/ui/common/useLog.ts +++ b/src/ui/common/useLog.ts @@ -1,17 +1,26 @@ import { ToastType, useToast } from "./useToast.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 +// stays up longer than a transient error. +const EXPLAINED_ERROR_DURATION = 12000; + export function useLog() { const { addToast } = useToast(); function logError(maybeError: unknown, context?: string) { const error = toNormalizedError(maybeError); console.error(context, error, error?.stack); - const prefix = context ?? "Error"; + // 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 = explainCodecError(maybeError); + const prefix = explanation?.heading ?? context ?? "Error"; addToast(prefix, { - detail: `${getErrorMessage(error)}`, - duration: 4000, + detail: explanation?.detail ?? `${getErrorMessage(error)}`, + duration: explanation ? EXPLAINED_ERROR_DURATION : 4000, type: ToastType.DANGER, }); } diff --git a/src/utils/errorHandling.ts b/src/utils/errorHandling.ts index 852b4ff6..ac7afac1 100644 --- a/src/utils/errorHandling.ts +++ b/src/utils/errorHandling.ts @@ -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; +} diff --git a/tests/unit/lib/data/codecErrors.test.ts b/tests/unit/lib/data/codecErrors.test.ts new file mode 100644 index 00000000..7fa055f0 --- /dev/null +++ b/tests/unit/lib/data/codecErrors.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { get, open } from "zarrita"; + +import "@/lib/data/codecs.ts"; +import { explainCodecError } from "@/lib/data/codecErrors.ts"; +import { flattenErrorMessage } from "@/utils/errorHandling.ts"; + +/** + * 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 + * through `flattenErrorMessage` plus `new Error(...)` that the grid data + * worker performs, which strips the error's class and its fields. + */ + +const V2MetadataKey = { + FILL_VALUE: "fill_value", + ZARR_FORMAT: "zarr_format", +} as const; + +function base64Bytes(encoded: string) { + return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); +} + +/** What `gridData.worker.ts` posts back and `gridDataWorkerClient.ts` rebuilds. */ +function throughWorker(error: unknown) { + return new Error(flattenErrorMessage(error)); +} + +function v2Store(dtype: string, compressor: unknown, chunk: Uint8Array) { + const metadata = new TextEncoder().encode( + JSON.stringify({ + chunks: [4], + compressor, + dtype, + filters: [], + order: "C", + shape: [4], + [V2MetadataKey.FILL_VALUE]: null, + [V2MetadataKey.ZARR_FORMAT]: 2, + }) + ); + return new Map([ + ["/.zarray", metadata], + ["/0", chunk], + ]); +} + +async function readV2(compressor: unknown, chunk: Uint8Array) { + const array = await open.v2(v2Store(") { + return promise.then( + () => undefined, + (error: unknown) => error + ); +} + +describe("an unregistered codec", () => { + async function unknownCodecError() { + return await caught(readV2({ id: "quantize" }, new Uint8Array(16))); + } + + it("names the codec in the heading", async () => { + const explanation = explainCodecError(await unknownCodecError()); + expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize"); + }); + + it("says the network is not the problem", async () => { + 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 = explainCodecError( + throughWorker(await unknownCodecError()) + ); + expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize"); + }); +}); + +describe("a registered codec that rejects the chunk", () => { + // A fletcher32 chunk whose trailing checksum does not match its payload. + const CORRUPT_FLETCHER32 = "AACAPwAAAEAAAEBAAACAQAAAAAA="; + + async function codecPipelineError() { + return await caught( + readV2({ id: "fletcher32" }, base64Bytes(CORRUPT_FLETCHER32)) + ); + } + + it("names the codec that failed", async () => { + 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 = 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 = explainCodecError( + throughWorker(await codecPipelineError()) + ); + expect(explanation?.heading).toBe("Codec failed: numcodecs.fletcher32"); + expect(explanation?.detail).toContain("checksum mismatch"); + }); +}); + +describe("a data type this browser cannot represent", () => { + async function unsupportedDataTypeError() { + // zarrita maps float16 onto `globalThis.Float16Array`, which older + // browsers do not have. + const original = Reflect.get(globalThis, "Float16Array"); + Reflect.deleteProperty(globalThis, "Float16Array"); + try { + const store = v2Store(" { + const explanation = explainCodecError(await unsupportedDataTypeError()); + expect(explanation?.heading).toBe("Unsupported data type: float16"); + expect(explanation?.detail).toContain("Safari 26"); + }); +}); + +describe("errors that are not codec problems", () => { + const UNRELATED = [ + new Error("Failed to fetch chunk bytes from example.invalid"), + new TypeError("Failed to fetch"), + new Error("Cannot convert a BigInt value to a number"), + new Error("Not found: /some/path"), + "a bare string", + undefined, + ]; + + it.each(UNRELATED)("passes through %s", (error) => { + expect(explainCodecError(error)).toBeUndefined(); + }); +}); diff --git a/tests/unit/utils/errorHandling.test.ts b/tests/unit/utils/errorHandling.test.ts new file mode 100644 index 00000000..fa349740 --- /dev/null +++ b/tests/unit/utils/errorHandling.test.ts @@ -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"); + }); +});