@cfasim-ui/shared 0.10.0 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/shared",
3
- "version": "0.10.0",
3
+ "version": "0.12.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/index.ts CHANGED
@@ -4,7 +4,7 @@ export {
4
4
  postErrorWithTransfer,
5
5
  unwrapResponse,
6
6
  } from "./transferUtils.js";
7
- export type { TransferableResponse } from "./transferUtils.js";
7
+ export type { TransferableResponse, WasmTrapKind } from "./transferUtils.js";
8
8
  export { ModelOutput, isoDate, DATE_MISSING } from "./ModelOutput.js";
9
9
  export type {
10
10
  ColumnDescriptor,
@@ -19,7 +19,13 @@ export type {
19
19
  ModelOutputsFileHeader,
20
20
  ModelOutputsFileTable,
21
21
  } from "./decodeModelOutputs.js";
22
- export { describeWorkerError, MESSAGEERROR_TEXT } from "./workerError.js";
22
+ export {
23
+ describeWorkerError,
24
+ describeWasmTrap,
25
+ describeWasmInitTrap,
26
+ isWasmTrap,
27
+ MESSAGEERROR_TEXT,
28
+ } from "./workerError.js";
23
29
  export { formatNumber, isNumberFormat } from "./formatNumber.js";
24
30
  export type { NumberFormat, NumberFormatPreset } from "./formatNumber.js";
25
31
  export {
@@ -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
+ });
@@ -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
+ }