@cfasim-ui/shared 0.9.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/ModelOutput.test.ts +18 -1
- package/src/ModelOutput.ts +25 -3
- package/src/csv.ts +3 -0
- package/src/decodeModelOutputs.test.ts +68 -0
- package/src/decodeModelOutputs.ts +80 -0
- package/src/index.ts +14 -3
- package/src/transferUtils.ts +11 -0
- package/src/workerError.test.ts +98 -0
- package/src/workerError.ts +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfasim-ui/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared utilities for cfasim-ui",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
],
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts",
|
|
20
|
-
"./transfer": "./src/transferUtils.ts"
|
|
20
|
+
"./transfer": "./src/transferUtils.ts",
|
|
21
|
+
"./workerError": "./src/workerError.ts"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
23
24
|
"vue": "^3.5.0"
|
package/src/ModelOutput.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { ModelOutput } from "./ModelOutput.js";
|
|
2
|
+
import { ModelOutput, DATE_MISSING, isoDate } from "./ModelOutput.js";
|
|
3
3
|
import type {
|
|
4
4
|
ModelOutputWire,
|
|
5
5
|
ModelOutputsWire,
|
|
@@ -207,3 +207,20 @@ describe("transfer protocol with ModelOutputs", () => {
|
|
|
207
207
|
expect(unwrapResponse(response)).toBe("hello");
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("date columns", () => {
|
|
212
|
+
it("decodes days since the epoch to ISO strings and CSV", () => {
|
|
213
|
+
const output = new ModelOutput(
|
|
214
|
+
3,
|
|
215
|
+
[{ name: "when", type: "date" }],
|
|
216
|
+
[new Uint32Array([0, 20458, DATE_MISSING])],
|
|
217
|
+
);
|
|
218
|
+
expect(output.dates("when")).toEqual(["1970-01-01", "2026-01-05", null]);
|
|
219
|
+
expect(isoDate(20465)).toBe("2026-01-12");
|
|
220
|
+
expect(isoDate(0)).toBe("1970-01-01");
|
|
221
|
+
expect(isoDate(2_932_897)).toBe("10000-01-01");
|
|
222
|
+
expect(isoDate(4_000_000_000)).toBeNull();
|
|
223
|
+
expect(modelOutputToCSV(output)).toBe("when\n1970-01-01\n2026-01-05\n");
|
|
224
|
+
expect(() => output.dates("nope")).toThrow();
|
|
225
|
+
});
|
|
226
|
+
});
|
package/src/ModelOutput.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export type ColumnType = "i32" | "u32" | "f64" | "bool" | "enum";
|
|
1
|
+
export type ColumnType = "i32" | "u32" | "f64" | "bool" | "enum" | "date";
|
|
2
|
+
|
|
3
|
+
/** Value of a `date` column with no date (`DATE_MISSING` on the Python side). */
|
|
4
|
+
export const DATE_MISSING = 0xffffffff;
|
|
5
|
+
const DAY_MS = 86_400_000;
|
|
2
6
|
|
|
3
7
|
export interface ColumnDescriptor {
|
|
4
8
|
name: string;
|
|
@@ -20,17 +24,27 @@ export interface ModelOutputsWire {
|
|
|
20
24
|
outputs: Record<string, ModelOutputWire>;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
const COLUMN_TYPE_TO_CTOR: Record<
|
|
27
|
+
export const COLUMN_TYPE_TO_CTOR: Record<
|
|
24
28
|
ColumnType,
|
|
25
|
-
new (buffer: ArrayBuffer)
|
|
29
|
+
{ new (buffer: ArrayBuffer): TypedColumn; BYTES_PER_ELEMENT: number }
|
|
26
30
|
> = {
|
|
27
31
|
f64: Float64Array,
|
|
28
32
|
i32: Int32Array,
|
|
29
33
|
u32: Uint32Array,
|
|
30
34
|
bool: Uint8Array,
|
|
31
35
|
enum: Uint32Array,
|
|
36
|
+
date: Uint32Array,
|
|
32
37
|
};
|
|
33
38
|
|
|
39
|
+
/** ISO `yyyy-mm-dd` for a `date` column value (days since 1970-01-01). */
|
|
40
|
+
export function isoDate(days: number): string | null {
|
|
41
|
+
const date = new Date(days * DAY_MS);
|
|
42
|
+
if (days === DATE_MISSING || Number.isNaN(date.getTime())) return null;
|
|
43
|
+
// Not toISOString, which writes years past 9999 as "+010000".
|
|
44
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
|
45
|
+
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
export class ModelOutput {
|
|
35
49
|
readonly length: number;
|
|
36
50
|
readonly columns: readonly ColumnDescriptor[];
|
|
@@ -66,6 +80,14 @@ export class ModelOutput {
|
|
|
66
80
|
return desc.enumLabels[index];
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/** A `date` column as ISO strings (`null` where the date is missing). */
|
|
84
|
+
dates(columnName: string): (string | null)[] {
|
|
85
|
+
if (this.descriptor(columnName).type !== "date") {
|
|
86
|
+
throw new Error(`Column ${columnName} is not a date`);
|
|
87
|
+
}
|
|
88
|
+
return Array.from(this.column(columnName), isoDate);
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
get names(): string[] {
|
|
70
92
|
return this.columns.map((c) => c.name);
|
|
71
93
|
}
|
package/src/csv.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelOutput } from "./ModelOutput.js";
|
|
2
|
+
import { isoDate } from "./ModelOutput.js";
|
|
2
3
|
|
|
3
4
|
export function modelOutputToCSV(output: ModelOutput): string {
|
|
4
5
|
const header = output.names.join(",");
|
|
@@ -10,6 +11,8 @@ export function modelOutputToCSV(output: ModelOutput): string {
|
|
|
10
11
|
const val = output.buffers[c][r];
|
|
11
12
|
if (desc.type === "enum") {
|
|
12
13
|
cells.push(desc.enumLabels![val as number]);
|
|
14
|
+
} else if (desc.type === "date") {
|
|
15
|
+
cells.push(isoDate(val as number) ?? "");
|
|
13
16
|
} else if (desc.type === "bool") {
|
|
14
17
|
cells.push(val ? "true" : "false");
|
|
15
18
|
} else {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { decodeModelOutputs } from "./decodeModelOutputs.js";
|
|
5
|
+
|
|
6
|
+
// Shared protocol fixtures, written by the Python reference implementation.
|
|
7
|
+
// (__dirname rather than import.meta.url: happy-dom gives the latter an http scheme.)
|
|
8
|
+
const FIXTURES = resolve(__dirname, "../../../cfasim-model-py/tests/fixtures");
|
|
9
|
+
|
|
10
|
+
function fixtureBuffer(name: string): ArrayBuffer {
|
|
11
|
+
const bytes = readFileSync(resolve(FIXTURES, name));
|
|
12
|
+
return bytes.buffer.slice(
|
|
13
|
+
bytes.byteOffset,
|
|
14
|
+
bytes.byteOffset + bytes.byteLength,
|
|
15
|
+
) as ArrayBuffer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const expected = JSON.parse(
|
|
19
|
+
readFileSync(resolve(FIXTURES, "sample.columns.json"), "utf8"),
|
|
20
|
+
) as Record<string, Record<string, number[]>>;
|
|
21
|
+
|
|
22
|
+
describe("decodeModelOutputs", () => {
|
|
23
|
+
it("decodes the shared fixture into ModelOutput tables", () => {
|
|
24
|
+
const outputs = decodeModelOutputs(fixtureBuffer("sample.cfasim"));
|
|
25
|
+
expect(Object.keys(outputs).sort()).toEqual(["empty", "series"]);
|
|
26
|
+
for (const [table, columns] of Object.entries(expected)) {
|
|
27
|
+
for (const [name, values] of Object.entries(columns)) {
|
|
28
|
+
expect(Array.from(outputs[table].column(name))).toEqual(values);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
expect(outputs.series.column("time")).toBeInstanceOf(Float64Array);
|
|
32
|
+
expect(outputs.series.column("delta")).toBeInstanceOf(Int32Array);
|
|
33
|
+
expect(outputs.series.column("count")).toBeInstanceOf(Uint32Array);
|
|
34
|
+
expect(outputs.series.column("flag")).toBeInstanceOf(Uint8Array);
|
|
35
|
+
const status = outputs.series.column("status");
|
|
36
|
+
expect(outputs.series.label("status", status[1])).toBe("R");
|
|
37
|
+
expect(outputs.empty.length).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("matches the committed header", () => {
|
|
41
|
+
const buf = fixtureBuffer("sample.cfasim");
|
|
42
|
+
const headerLength = new DataView(buf).getUint32(0, true);
|
|
43
|
+
const header = JSON.parse(
|
|
44
|
+
new TextDecoder().decode(new Uint8Array(buf, 4, headerLength)),
|
|
45
|
+
);
|
|
46
|
+
const dataStart = 4 + headerLength;
|
|
47
|
+
const committed = JSON.parse(
|
|
48
|
+
readFileSync(resolve(FIXTURES, "sample.header.json"), "utf8"),
|
|
49
|
+
);
|
|
50
|
+
expect(header).toEqual(committed);
|
|
51
|
+
expect(dataStart % 8).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects truncated and malformed files", () => {
|
|
55
|
+
const buf = fixtureBuffer("sample.cfasim");
|
|
56
|
+
expect(() => decodeModelOutputs(buf.slice(0, buf.byteLength - 4))).toThrow(
|
|
57
|
+
/truncated/,
|
|
58
|
+
);
|
|
59
|
+
expect(() => decodeModelOutputs(buf.slice(0, 2))).toThrow(/truncated/);
|
|
60
|
+
const bad = new Uint8Array(buf.slice(0));
|
|
61
|
+
const text = new TextDecoder().decode(bad.subarray(4, 24));
|
|
62
|
+
const idx = 4 + text.indexOf('"version":1') + '"version":'.length;
|
|
63
|
+
bad[idx] = "2".charCodeAt(0);
|
|
64
|
+
expect(() => decodeModelOutputs(bad.buffer as ArrayBuffer)).toThrow(
|
|
65
|
+
/version/,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ColumnDescriptor } from "./ModelOutput.js";
|
|
2
|
+
import { COLUMN_TYPE_TO_CTOR, ModelOutput } from "./ModelOutput.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Header of a `.cfasim` outputs file. The file is a little-endian u32 header
|
|
6
|
+
* length, a UTF-8 JSON header (space-padded to an 8-byte boundary), then the
|
|
7
|
+
* raw column buffers; `byteOffsets` are relative to the start of the buffers.
|
|
8
|
+
*/
|
|
9
|
+
export interface ModelOutputsFileTable {
|
|
10
|
+
length: number;
|
|
11
|
+
columns: ColumnDescriptor[];
|
|
12
|
+
byteOffsets: number[];
|
|
13
|
+
/** Unpadded byte length per column. */
|
|
14
|
+
byteLengths: number[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModelOutputsFileHeader {
|
|
18
|
+
version: number;
|
|
19
|
+
outputs: Record<string, ModelOutputsFileTable>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readModelOutputsHeader(buffer: ArrayBuffer): {
|
|
23
|
+
header: ModelOutputsFileHeader;
|
|
24
|
+
dataStart: number;
|
|
25
|
+
} {
|
|
26
|
+
if (buffer.byteLength < 4) {
|
|
27
|
+
throw new Error("outputs file is truncated (no header length)");
|
|
28
|
+
}
|
|
29
|
+
const headerLength = new DataView(buffer).getUint32(0, true);
|
|
30
|
+
const dataStart = 4 + headerLength;
|
|
31
|
+
if (dataStart > buffer.byteLength) {
|
|
32
|
+
throw new Error("outputs file is truncated (header)");
|
|
33
|
+
}
|
|
34
|
+
const text = new TextDecoder().decode(
|
|
35
|
+
new Uint8Array(buffer, 4, headerLength),
|
|
36
|
+
);
|
|
37
|
+
const header = JSON.parse(text) as ModelOutputsFileHeader;
|
|
38
|
+
if (header.version !== 1) {
|
|
39
|
+
throw new Error(`unsupported outputs file version ${header.version}`);
|
|
40
|
+
}
|
|
41
|
+
return { header, dataStart };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decode a `.cfasim` outputs file (as served by `GET /api/runs/{id}/outputs`
|
|
46
|
+
* or read from disk) into named `ModelOutput` tables. Column buffers are
|
|
47
|
+
* copied out of `buffer`, so the file bytes can be released afterwards.
|
|
48
|
+
*/
|
|
49
|
+
export function decodeModelOutputs(
|
|
50
|
+
buffer: ArrayBuffer,
|
|
51
|
+
): Record<string, ModelOutput> {
|
|
52
|
+
const { header, dataStart } = readModelOutputsHeader(buffer);
|
|
53
|
+
const result: Record<string, ModelOutput> = {};
|
|
54
|
+
for (const [name, table] of Object.entries(header.outputs)) {
|
|
55
|
+
const buffers = table.columns.map((col, i) => {
|
|
56
|
+
const bpe = COLUMN_TYPE_TO_CTOR[col.type]?.BYTES_PER_ELEMENT;
|
|
57
|
+
if (bpe === undefined) {
|
|
58
|
+
throw new Error(`column ${col.name} has unknown type ${col.type}`);
|
|
59
|
+
}
|
|
60
|
+
const start = dataStart + table.byteOffsets[i];
|
|
61
|
+
const bytes = table.byteLengths[i];
|
|
62
|
+
if (bytes !== table.length * bpe) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`column ${col.name} has ${bytes} bytes for ${table.length} rows`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (start + bytes > buffer.byteLength) {
|
|
68
|
+
throw new Error(`outputs file is truncated (column ${col.name})`);
|
|
69
|
+
}
|
|
70
|
+
return buffer.slice(start, start + bytes);
|
|
71
|
+
});
|
|
72
|
+
result[name] = ModelOutput.fromWire({
|
|
73
|
+
__modelOutput: true,
|
|
74
|
+
length: table.length,
|
|
75
|
+
columns: table.columns,
|
|
76
|
+
buffers,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,8 @@ export {
|
|
|
4
4
|
postErrorWithTransfer,
|
|
5
5
|
unwrapResponse,
|
|
6
6
|
} from "./transferUtils.js";
|
|
7
|
-
export type { TransferableResponse } from "./transferUtils.js";
|
|
8
|
-
export { ModelOutput } from "./ModelOutput.js";
|
|
7
|
+
export type { TransferableResponse, WasmTrapKind } from "./transferUtils.js";
|
|
8
|
+
export { ModelOutput, isoDate, DATE_MISSING } from "./ModelOutput.js";
|
|
9
9
|
export type {
|
|
10
10
|
ColumnDescriptor,
|
|
11
11
|
ColumnType,
|
|
@@ -14,7 +14,18 @@ export type {
|
|
|
14
14
|
ModelOutputsWire,
|
|
15
15
|
} from "./ModelOutput.js";
|
|
16
16
|
export { modelOutputToCSV } from "./csv.js";
|
|
17
|
-
export {
|
|
17
|
+
export { decodeModelOutputs } from "./decodeModelOutputs.js";
|
|
18
|
+
export type {
|
|
19
|
+
ModelOutputsFileHeader,
|
|
20
|
+
ModelOutputsFileTable,
|
|
21
|
+
} from "./decodeModelOutputs.js";
|
|
22
|
+
export {
|
|
23
|
+
describeWorkerError,
|
|
24
|
+
describeWasmTrap,
|
|
25
|
+
describeWasmInitTrap,
|
|
26
|
+
isWasmTrap,
|
|
27
|
+
MESSAGEERROR_TEXT,
|
|
28
|
+
} from "./workerError.js";
|
|
18
29
|
export { formatNumber, isNumberFormat } from "./formatNumber.js";
|
|
19
30
|
export type { NumberFormat, NumberFormatPreset } from "./formatNumber.js";
|
|
20
31
|
export {
|
package/src/transferUtils.ts
CHANGED
|
@@ -8,6 +8,8 @@ interface ModelOutputsResponsePayload {
|
|
|
8
8
|
>;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type WasmTrapKind = "call" | "init";
|
|
12
|
+
|
|
11
13
|
export interface TransferableResponse {
|
|
12
14
|
id: number;
|
|
13
15
|
result?: unknown;
|
|
@@ -15,6 +17,13 @@ export interface TransferableResponse {
|
|
|
15
17
|
bufferType?: string;
|
|
16
18
|
modelOutputs?: ModelOutputsResponsePayload;
|
|
17
19
|
error?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The call died in a WebAssembly trap and the instance is no longer
|
|
22
|
+
* trustworthy: `"call"` inside the function, `"init"` while the module was
|
|
23
|
+
* initializing (its glue keeps the half-initialized instance, so only a new
|
|
24
|
+
* worker can retry).
|
|
25
|
+
*/
|
|
26
|
+
trap?: WasmTrapKind;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
const TYPED_ARRAY_CONSTRUCTORS: Record<
|
|
@@ -94,10 +103,12 @@ export function postErrorWithTransfer(
|
|
|
94
103
|
port: typeof globalThis,
|
|
95
104
|
id: number,
|
|
96
105
|
error: unknown,
|
|
106
|
+
options: { trap?: WasmTrapKind } = {},
|
|
97
107
|
): void {
|
|
98
108
|
port.postMessage({
|
|
99
109
|
id,
|
|
100
110
|
error: error instanceof Error ? error.message : String(error),
|
|
111
|
+
...(options.trap ? { trap: options.trap } : {}),
|
|
101
112
|
} satisfies TransferableResponse);
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
describeWasmTrap,
|
|
4
|
+
describeWasmInitTrap,
|
|
5
|
+
describeWorkerError,
|
|
6
|
+
isWasmTrap,
|
|
7
|
+
} from "./workerError.js";
|
|
8
|
+
import { postErrorWithTransfer } from "./transferUtils.js";
|
|
9
|
+
|
|
10
|
+
describe("describeWasmTrap", () => {
|
|
11
|
+
it("reports a recorded panic with its location", () => {
|
|
12
|
+
expect(
|
|
13
|
+
describeWasmTrap(
|
|
14
|
+
"unreachable",
|
|
15
|
+
"panicked at src/lib.rs:21:9: rate must be <= 5, got 9",
|
|
16
|
+
true,
|
|
17
|
+
),
|
|
18
|
+
).toBe("Model panicked at src/lib.rs:21:9: rate must be <= 5, got 9");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("explains an unreachable trap with no panic as OOM or a replaced hook", () => {
|
|
22
|
+
expect(describeWasmTrap("unreachable", undefined, true)).toMatch(
|
|
23
|
+
/aborted without a recorded panic message.*out of memory.*replaced the panic hook/,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("recognizes every engine's wording of the unreachable trap", () => {
|
|
28
|
+
for (const text of [
|
|
29
|
+
"unreachable executed",
|
|
30
|
+
"Unreachable code should not be executed (evaluating 'wasm.simulate(a)')",
|
|
31
|
+
]) {
|
|
32
|
+
expect(describeWasmTrap(text, undefined, true)).toMatch(/aborted/);
|
|
33
|
+
expect(describeWasmTrap(text, undefined, false)).toMatch(/rebuild/);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("asks for a rebuild against current cfasim-model when the hook is missing", () => {
|
|
38
|
+
expect(describeWasmTrap("unreachable", undefined, false)).toMatch(
|
|
39
|
+
/rebuild it against the current cfasim-model/,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("passes other trap messages through", () => {
|
|
44
|
+
expect(
|
|
45
|
+
describeWasmTrap("memory access out of bounds", undefined, true),
|
|
46
|
+
).toBe("Model trapped: memory access out of bounds");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("describeWasmInitTrap", () => {
|
|
51
|
+
it("explains an abort in init and passes other traps through", () => {
|
|
52
|
+
expect(describeWasmInitTrap("unreachable executed")).toMatch(
|
|
53
|
+
/panicked or aborted while initializing/,
|
|
54
|
+
);
|
|
55
|
+
expect(describeWasmInitTrap("Maximum call stack size exceeded")).toBe(
|
|
56
|
+
"Model trapped while initializing: Maximum call stack size exceeded",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isWasmTrap", () => {
|
|
62
|
+
it("recognizes traps and engine stack overflows, not ordinary errors", () => {
|
|
63
|
+
expect(isWasmTrap(new WebAssembly.RuntimeError("unreachable"))).toBe(true);
|
|
64
|
+
expect(isWasmTrap(new RangeError("Maximum call stack size exceeded"))).toBe(
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
const spiderMonkey = new Error("too much recursion");
|
|
68
|
+
spiderMonkey.name = "InternalError";
|
|
69
|
+
expect(isWasmTrap(spiderMonkey)).toBe(true);
|
|
70
|
+
expect(isWasmTrap(new RangeError("Invalid array length"))).toBe(false);
|
|
71
|
+
expect(isWasmTrap(new Error("rate must be <= 5"))).toBe(false);
|
|
72
|
+
expect(isWasmTrap("unreachable")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("postErrorWithTransfer", () => {
|
|
77
|
+
it("flags a trap only when asked", () => {
|
|
78
|
+
const port = { postMessage: vi.fn() } as unknown as typeof globalThis;
|
|
79
|
+
postErrorWithTransfer(port, 1, new Error("boom"));
|
|
80
|
+
postErrorWithTransfer(port, 2, "plain", { trap: "call" });
|
|
81
|
+
expect(port.postMessage).toHaveBeenNthCalledWith(1, {
|
|
82
|
+
id: 1,
|
|
83
|
+
error: "boom",
|
|
84
|
+
});
|
|
85
|
+
expect(port.postMessage).toHaveBeenNthCalledWith(2, {
|
|
86
|
+
id: 2,
|
|
87
|
+
error: "plain",
|
|
88
|
+
trap: "call",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("describeWorkerError", () => {
|
|
94
|
+
it("falls back to a hint when the event is sanitized", () => {
|
|
95
|
+
const e = { message: "", filename: "", lineno: 0 } as ErrorEvent;
|
|
96
|
+
expect(describeWorkerError(e)).toMatch(/no error details/);
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/workerError.ts
CHANGED
|
@@ -15,3 +15,55 @@ export function describeWorkerError(e: ErrorEvent): string {
|
|
|
15
15
|
|
|
16
16
|
export const MESSAGEERROR_TEXT =
|
|
17
17
|
"Worker received a message it could not deserialize (messageerror)";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Message for a call that died in a WebAssembly trap. `panic` is the text
|
|
21
|
+
* recorded by `cfasim-model`'s panic hook (`panicked at <file>:<line>:<col>:
|
|
22
|
+
* <message>`) when the model panicked. `canRecord` says the module exports
|
|
23
|
+
* that hook at all; when it does not, the model is built on a cfasim-model
|
|
24
|
+
* too old to say. An `unreachable` trap with nothing recorded is an abort:
|
|
25
|
+
* the allocator giving up, or a panic after the model replaced the hook.
|
|
26
|
+
* Engines word the trap differently ("unreachable", "unreachable executed",
|
|
27
|
+
* "Unreachable code should not be executed"), hence the loose match.
|
|
28
|
+
*/
|
|
29
|
+
export function describeWasmTrap(
|
|
30
|
+
trapMessage: string,
|
|
31
|
+
panic: string | undefined,
|
|
32
|
+
canRecord: boolean,
|
|
33
|
+
): string {
|
|
34
|
+
if (panic) return `Model ${panic}`;
|
|
35
|
+
if (!/unreachable/i.test(trapMessage)) return `Model trapped: ${trapMessage}`;
|
|
36
|
+
if (canRecord) {
|
|
37
|
+
return "Model aborted without a recorded panic message: it ran out of memory, or it replaced the panic hook outside a start function";
|
|
38
|
+
}
|
|
39
|
+
return "Model panicked or aborted (unreachable); rebuild it against the current cfasim-model to see the panic message";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Message for a trap while the module initialized. The panic hook is
|
|
44
|
+
* installed after init, so a start-function panic has no recorded message.
|
|
45
|
+
*/
|
|
46
|
+
export function describeWasmInitTrap(trapMessage: string): string {
|
|
47
|
+
if (/unreachable/i.test(trapMessage)) {
|
|
48
|
+
return "Model panicked or aborted while initializing, before the panic hook could be installed";
|
|
49
|
+
}
|
|
50
|
+
return `Model trapped while initializing: ${trapMessage}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether an error thrown by a wasm call left the instance unusable. A
|
|
55
|
+
* `WebAssembly.RuntimeError` is any trap (a Rust panic or abort ends in
|
|
56
|
+
* `unreachable`). A stack overflow is not a trap but unwinds through the
|
|
57
|
+
* wasm frames without running their epilogues, so the shadow stack pointer
|
|
58
|
+
* is left wherever it was: V8 throws a `RangeError`, SpiderMonkey an
|
|
59
|
+
* `InternalError`. A JS-side overflow in the worker would match too; the
|
|
60
|
+
* worker has no recursion of its own, and the cost is one spare replacement.
|
|
61
|
+
*/
|
|
62
|
+
export function isWasmTrap(error: unknown): boolean {
|
|
63
|
+
if (error instanceof WebAssembly.RuntimeError) return true;
|
|
64
|
+
if (!(error instanceof Error)) return false;
|
|
65
|
+
return (
|
|
66
|
+
(error.name === "RangeError" || error.name === "InternalError") &&
|
|
67
|
+
/call stack|too much recursion/i.test(error.message)
|
|
68
|
+
);
|
|
69
|
+
}
|