Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
143 changes: 143 additions & 0 deletions src/lib/data/codecErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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 TDataErrorExplanation = {
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): TDataErrorExplanation {
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
): TDataErrorExplanation {
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): TDataErrorExplanation {
const isFloat16 = dataType.startsWith("float16");
return {
heading: `Unsupported data type: ${dataType}`,
detail: isFloat16

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be really interested in seeing such a file. I haven't seen datatype issues like this in a long time, but I haven't checked older browsers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment. Below. Happy to drop if you prefer. It is just an issue for older browsers.

? `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 explainDataError(
Comment thread
eeholmes marked this conversation as resolved.
Outdated
error: unknown
): TDataErrorExplanation | 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;
}

/**
* 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;
}
3 changes: 2 additions & 1 deletion src/lib/grids/gridData.worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference lib="webworker" />

import { flattenErrorMessage } from "@/lib/data/codecErrors.ts";
import { ZarrDataManager } from "@/lib/data/ZarrDataManager.ts";
import {
GridDataWorkerMessageType,
Expand Down Expand Up @@ -35,7 +36,7 @@ workerScope.onmessage = async (event: MessageEvent<TGridDataWorkerRequest>) => {
const response: TGridDataWorkerResponse = {
requestId,
type: GridDataWorkerMessageType.ERROR,
message: error instanceof Error ? error.message : String(error),
message: flattenErrorMessage(error),
Comment thread
eeholmes marked this conversation as resolved.
};
workerScope.postMessage(response);
}
Expand Down
15 changes: 12 additions & 3 deletions src/ui/common/useLog.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { ToastType, useToast } from "./useToast.ts";

import { explainDataError } 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 = explainDataError(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,
});
}
Expand Down
170 changes: 170 additions & 0 deletions tests/unit/lib/data/codecErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { get, open } from "zarrita";

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

/**
* Every error fed to `explainDataError` 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("<f4", compressor, chunk), {
kind: "array",
});
return await get(array);
}

function caught(promise: Promise<unknown>) {
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 = explainDataError(await unknownCodecError());
expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize");
});

it("says the network is not the problem", async () => {
const explanation = explainDataError(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(
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 = explainDataError(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());
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(
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("<f2", null, new Uint8Array(8));
return await caught(open.v2(store, { kind: "array" }));
} finally {
Reflect.set(globalThis, "Float16Array", original);
}
}

it("names the data type and the browsers that support it", async () => {
const explanation = explainDataError(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(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");
});
});