@cfasim-ui/wasm 0.10.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/wasm",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "WebAssembly integration for cfasim-ui",
6
6
  "license": "Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "./vite": "./src/vitePlugin.js"
21
21
  },
22
22
  "dependencies": {
23
- "@cfasim-ui/shared": "0.10.0"
23
+ "@cfasim-ui/shared": "0.11.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "vue": "^3.5.0"
@@ -1,12 +1,22 @@
1
+ // Subpath imports keep Vue out of the worker: the package barrel re-exports
2
+ // `useUrlParams`, which imports it.
1
3
  import {
2
4
  postWithTransfer,
3
5
  postModelOutputsWithTransfer,
4
6
  postErrorWithTransfer,
5
7
  } from "@cfasim-ui/shared/transfer";
8
+ import type { WasmTrapKind } from "@cfasim-ui/shared/transfer";
9
+ import {
10
+ describeWasmTrap,
11
+ describeWasmInitTrap,
12
+ isWasmTrap,
13
+ } from "@cfasim-ui/shared/workerError";
6
14
  import type { WorkerMessage } from "./messages.js";
7
15
 
8
16
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
- const modulePromises = new Map<string, Promise<Record<string, any>>>();
17
+ type WasmModule = Record<string, any>;
18
+
19
+ const modulePromises = new Map<string, Promise<WasmModule>>();
10
20
 
11
21
  const baseUrl = import.meta.env.BASE_URL ?? "/";
12
22
 
@@ -19,8 +29,7 @@ function wasmVersion(model: string): string | undefined {
19
29
  return __CFASIM_WASM_VERSIONS__?.[model];
20
30
  }
21
31
 
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
- function ensureModule(model: string): Promise<Record<string, any>> {
32
+ function ensureModule(model: string): Promise<WasmModule> {
24
33
  if (!modulePromises.has(model)) {
25
34
  const promise = (async () => {
26
35
  const dir = `${self.location.origin}${baseUrl}wasm/${model}/`;
@@ -35,6 +44,9 @@ function ensureModule(model: string): Promise<Record<string, any>> {
35
44
  } else {
36
45
  await mod.default();
37
46
  }
47
+ // cfasim-model's panic recorder (releases after 0.10.0). Installed after
48
+ // init so it chains onto any hook the model's start function set.
49
+ mod.__cfasim_install_panic_hook?.();
38
50
  return mod;
39
51
  })();
40
52
  promise.catch(() => {
@@ -47,9 +59,10 @@ function ensureModule(model: string): Promise<Record<string, any>> {
47
59
 
48
60
  self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
49
61
  const { id, model, fn, args } = event.data;
62
+ let mod: WasmModule | undefined;
50
63
  try {
51
64
  const t0 = performance.now();
52
- const mod = await ensureModule(model);
65
+ mod = await ensureModule(model);
53
66
  const result = mod[fn](...args);
54
67
  const elapsed = Math.round((performance.now() - t0) * 10) / 10;
55
68
  console.log(`[wasm-worker] ${model}.${fn} ${elapsed}ms`);
@@ -81,6 +94,33 @@ self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
81
94
  postWithTransfer(self, id, result);
82
95
  }
83
96
  } catch (error) {
84
- postErrorWithTransfer(self, id, error);
97
+ if (!isWasmTrap(error)) {
98
+ const text = error instanceof Error ? error.message : String(error);
99
+ console.error(`[wasm-worker] ${model}.${fn} failed: ${text}`, error);
100
+ postErrorWithTransfer(self, id, error);
101
+ return;
102
+ }
103
+ // The instance is unusable after a trap: a panic does not unwind, so
104
+ // destructors never ran, and the glue keeps a half-initialized instance
105
+ // when init trapped. The main thread replaces the worker on `trap`.
106
+ const trapMessage = (error as Error).message;
107
+ let message: string;
108
+ let trap: WasmTrapKind;
109
+ if (mod) {
110
+ const takePanic = mod.__cfasim_take_panic;
111
+ let panic: string | undefined;
112
+ try {
113
+ panic = takePanic?.() as string | undefined;
114
+ } catch {
115
+ // Reading the string allocates, which can trap again after an OOM.
116
+ }
117
+ message = describeWasmTrap(trapMessage, panic, !!takePanic);
118
+ trap = "call";
119
+ } else {
120
+ message = describeWasmInitTrap(trapMessage);
121
+ trap = "init";
122
+ }
123
+ console.error(`[wasm-worker] ${model}.${fn} failed: ${message}`, error);
124
+ postErrorWithTransfer(self, id, new Error(message), { trap });
85
125
  }
86
126
  };
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import type { WorkerMessage } from "./messages.js";
3
+
4
+ class FakeWorker extends EventTarget {
5
+ static instances: FakeWorker[] = [];
6
+ posted: WorkerMessage[] = [];
7
+ terminated = false;
8
+
9
+ constructor() {
10
+ super();
11
+ FakeWorker.instances.push(this);
12
+ }
13
+
14
+ postMessage(message: WorkerMessage) {
15
+ this.posted.push(message);
16
+ }
17
+
18
+ terminate() {
19
+ this.terminated = true;
20
+ }
21
+
22
+ reply(data: Record<string, unknown>) {
23
+ this.dispatchEvent(new MessageEvent("message", { data }));
24
+ }
25
+
26
+ fail(message: string) {
27
+ const e = new Event("error") as Event & { message: string };
28
+ e.message = message;
29
+ this.dispatchEvent(e);
30
+ }
31
+
32
+ static current(): FakeWorker {
33
+ return FakeWorker.instances[FakeWorker.instances.length - 1];
34
+ }
35
+ }
36
+
37
+ let runWasm: typeof import("./wasmWorkerApi.js").runWasm;
38
+ let cancelWasm: typeof import("./wasmWorkerApi.js").cancelWasm;
39
+
40
+ beforeAll(async () => {
41
+ vi.stubGlobal("Worker", FakeWorker);
42
+ ({ runWasm, cancelWasm } = await import("./wasmWorkerApi.js"));
43
+ });
44
+
45
+ function lastPosted(w: FakeWorker): WorkerMessage {
46
+ return w.posted[w.posted.length - 1];
47
+ }
48
+
49
+ const PANIC = "Model panicked at src/lib.rs:21:9: rate must be <= 5, got 9";
50
+
51
+ describe("runWasm", () => {
52
+ it("resolves a plain result by request id", async () => {
53
+ const w = FakeWorker.current();
54
+ const p = runWasm("m", "double", "2");
55
+ const { id } = lastPosted(w);
56
+ w.reply({ id: id + 1000, result: "not mine" });
57
+ w.reply({ id, result: 4 });
58
+ await expect(p).resolves.toBe(4);
59
+ });
60
+
61
+ it("rejects an ordinary error, even an empty one, and keeps the worker", async () => {
62
+ const w = FakeWorker.current();
63
+ const p = runWasm("m", "fallible", "9");
64
+ w.reply({ id: lastPosted(w).id, error: "rate must be <= 5, got 9" });
65
+ await expect(p).rejects.toThrow("rate must be <= 5, got 9");
66
+ const empty = runWasm("m", "fallible", "");
67
+ w.reply({ id: lastPosted(w).id, error: "" });
68
+ await expect(empty).rejects.toThrow();
69
+ expect(w.terminated).toBe(false);
70
+ expect(FakeWorker.current()).toBe(w);
71
+ });
72
+
73
+ it("replaces the worker after a trap and re-sends the other calls", async () => {
74
+ const old = FakeWorker.current();
75
+ const trapped = runWasm("m", "boom", "9");
76
+ const survivor = runWasm("m", "ok", "3");
77
+ const trappedId = old.posted[old.posted.length - 2].id;
78
+ const survivorId = lastPosted(old).id;
79
+
80
+ old.reply({ id: trappedId, error: PANIC, trap: "call" });
81
+ await expect(trapped).rejects.toThrow(PANIC);
82
+
83
+ const fresh = FakeWorker.current();
84
+ expect(old.terminated).toBe(true);
85
+ expect(fresh).not.toBe(old);
86
+ expect(fresh.posted).toEqual([
87
+ { id: survivorId, model: "m", fn: "ok", args: ["3"] },
88
+ ]);
89
+
90
+ // Late events from the retired worker are ignored; the fresh one answers.
91
+ old.reply({ id: survivorId, result: "stale" });
92
+ old.fail("stale worker error");
93
+ fresh.reply({ id: survivorId, result: 6 });
94
+ await expect(survivor).resolves.toBe(6);
95
+
96
+ // The replacement is live for new calls.
97
+ const next = runWasm("m", "ok", "4");
98
+ fresh.reply({ id: lastPosted(fresh).id, result: 8 });
99
+ await expect(next).resolves.toBe(8);
100
+ });
101
+
102
+ it("fails queued repeats of the trapped call instead of re-sending them", async () => {
103
+ const old = FakeWorker.current();
104
+ const first = runWasm("m", "boom", "9");
105
+ const repeat = runWasm("m", "boom", "9");
106
+ const other = runWasm("m", "boom", "1");
107
+ const firstId = old.posted[old.posted.length - 3].id;
108
+ const otherId = lastPosted(old).id;
109
+
110
+ old.reply({ id: firstId, error: PANIC, trap: "call" });
111
+ await expect(first).rejects.toThrow(PANIC);
112
+ await expect(repeat).rejects.toThrow(PANIC);
113
+
114
+ const fresh = FakeWorker.current();
115
+ expect(fresh.posted.map((m) => m.id)).toEqual([otherId]);
116
+ fresh.reply({ id: otherId, result: 1 });
117
+ await expect(other).resolves.toBe(1);
118
+ });
119
+
120
+ it("fails every queued call into a model whose init trapped", async () => {
121
+ const old = FakeWorker.current();
122
+ const init = runWasm("broken", "a");
123
+ const sameModel = runWasm("broken", "b", "x");
124
+ const otherModel = runWasm("fine", "a");
125
+ const initId = old.posted[old.posted.length - 3].id;
126
+ const otherId = lastPosted(old).id;
127
+
128
+ const message = "Model panicked or aborted while initializing";
129
+ vi.useFakeTimers();
130
+ try {
131
+ old.reply({ id: initId, error: message, trap: "init" });
132
+ await expect(init).rejects.toThrow(message);
133
+ await expect(sameModel).rejects.toThrow(message);
134
+
135
+ const fresh = FakeWorker.current();
136
+ expect(old.terminated).toBe(true);
137
+ expect(fresh.posted.map((m) => m.id)).toEqual([otherId]);
138
+ fresh.reply({ id: otherId, result: "ok" });
139
+ await expect(otherModel).resolves.toBe("ok");
140
+
141
+ // Within the retry window a new call into that model is rejected
142
+ // without reaching the worker; afterwards it is sent again.
143
+ await expect(runWasm("broken", "a")).rejects.toThrow(message);
144
+ expect(fresh.posted).toHaveLength(1);
145
+ vi.advanceTimersByTime(2000);
146
+ const later = runWasm("broken", "a");
147
+ expect(fresh.posted).toHaveLength(2);
148
+ fresh.reply({ id: lastPosted(fresh).id, result: "fixed" });
149
+ await expect(later).resolves.toBe("fixed");
150
+ } finally {
151
+ vi.useRealTimers();
152
+ }
153
+ });
154
+
155
+ it("fails pending calls on a worker error and starts fresh after a short wait", async () => {
156
+ vi.useFakeTimers();
157
+ try {
158
+ const w = FakeWorker.current();
159
+ const p = runWasm("m", "slow");
160
+ w.fail("Worker failed to load");
161
+ await expect(p).rejects.toThrow("Worker failed to load");
162
+
163
+ // Within the retry window the same rejection comes back, no spawn.
164
+ await expect(runWasm("m", "ok", "0")).rejects.toThrow(
165
+ "Worker failed to load",
166
+ );
167
+ expect(FakeWorker.current()).toBe(w);
168
+
169
+ vi.advanceTimersByTime(2000);
170
+ const retry = runWasm("m", "ok", "1");
171
+ const fresh = FakeWorker.current();
172
+ expect(fresh).not.toBe(w);
173
+ expect(w.terminated).toBe(true);
174
+ fresh.reply({ id: lastPosted(fresh).id, result: 2 });
175
+ await expect(retry).resolves.toBe(2);
176
+ } finally {
177
+ vi.useRealTimers();
178
+ }
179
+ });
180
+
181
+ it("cancelWasm rejects in-flight calls with 'cancelled'", async () => {
182
+ const w = FakeWorker.current();
183
+ const p = runWasm("m", "slow");
184
+ cancelWasm();
185
+ await expect(p).rejects.toThrow("cancelled");
186
+ expect(w.terminated).toBe(true);
187
+ expect(FakeWorker.current()).not.toBe(w);
188
+ });
189
+ });
@@ -1,4 +1,4 @@
1
- import type { TransferableResponse } from "@cfasim-ui/shared";
1
+ import type { TransferableResponse, WasmTrapKind } from "@cfasim-ui/shared";
2
2
  import {
3
3
  unwrapResponse,
4
4
  describeWorkerError,
@@ -8,15 +8,28 @@ import type { WorkerMessage } from "./messages.js";
8
8
 
9
9
  let lastId = 0;
10
10
 
11
- // Rejects keyed by request id so `cancelWasm` can fail in-flight promises
12
- // when we terminate the worker mid-run, and so worker-level errors can drain
13
- // every pending call at once.
14
- const pendingRejects = new Map<number, (reason: Error) => void>();
11
+ interface Pending {
12
+ message: WorkerMessage;
13
+ resolve: (value: unknown) => void;
14
+ reject: (reason: Error) => void;
15
+ }
16
+
17
+ // In-flight calls keyed by request id. `cancelWasm` fails them when it
18
+ // terminates the worker mid-run, worker-level errors drain them all at once,
19
+ // and a trap re-sends the survivors to the replacement worker.
20
+ const pending = new Map<number, Pending>();
15
21
 
16
- // Set once the worker fires an `error` / `messageerror` event. Subsequent
17
- // `runWasm` calls short-circuit with this error so the caller sees a real
18
- // rejection instead of waiting forever on a worker that will never reply.
22
+ // Set when the worker fires an `error` / `messageerror` event, which fails
23
+ // every pending call. A `runWasm` after `WORKER_RETRY_MS` starts a fresh
24
+ // worker; sooner ones get the same rejection, so a worker script that never
25
+ // loads costs one spawn per retry window, not one per slider tick.
19
26
  let workerDeadError: Error | null = null;
27
+ let workerDiedAt = 0;
28
+ const WORKER_RETRY_MS = 2000;
29
+
30
+ // Models whose initialization trapped, with the same retry window: each
31
+ // retry costs a worker and a module load.
32
+ const initTraps = new Map<string, { error: Error; at: number }>();
20
33
 
21
34
  function newWorker(): Worker {
22
35
  const w = new Worker(new URL("./wasm.worker.ts", import.meta.url), {
@@ -25,50 +38,112 @@ function newWorker(): Worker {
25
38
  function failAll(message: string) {
26
39
  if (workerDeadError) return;
27
40
  workerDeadError = new Error(message);
28
- const rejects = Array.from(pendingRejects.values());
29
- pendingRejects.clear();
30
- for (const reject of rejects) reject(workerDeadError);
41
+ workerDiedAt = Date.now();
42
+ const entries = Array.from(pending.values());
43
+ pending.clear();
44
+ for (const entry of entries) entry.reject(workerDeadError);
31
45
  }
46
+ // Events from a worker that has since been replaced are stale: its
47
+ // requests were re-sent to, or failed with, the current worker.
32
48
  w.addEventListener("error", (e: ErrorEvent) => {
49
+ if (w !== worker) return;
33
50
  failAll(describeWorkerError(e));
34
51
  });
35
52
  w.addEventListener("messageerror", () => {
53
+ if (w !== worker) return;
36
54
  failAll(MESSAGEERROR_TEXT);
37
55
  });
56
+ w.addEventListener("message", (event: MessageEvent<TransferableResponse>) => {
57
+ if (w !== worker) return;
58
+ const data = event.data;
59
+ const entry = data && pending.get(data.id);
60
+ if (!entry) return;
61
+ pending.delete(data.id);
62
+ if (data.error !== undefined) {
63
+ const error = new Error(data.error);
64
+ if (data.trap) replaceWorker(entry.message, data.trap, error);
65
+ entry.reject(error);
66
+ } else {
67
+ entry.resolve(unwrapResponse(data));
68
+ }
69
+ });
38
70
  return w;
39
71
  }
40
72
 
41
73
  let worker = newWorker();
42
74
 
75
+ function restartWorker(): void {
76
+ worker.terminate();
77
+ workerDeadError = null;
78
+ worker = newWorker();
79
+ }
80
+
81
+ function sameCall(a: WorkerMessage, b: WorkerMessage): boolean {
82
+ return (
83
+ a.model === b.model &&
84
+ a.fn === b.fn &&
85
+ a.args.length === b.args.length &&
86
+ a.args.every((arg, i) => arg === b.args[i])
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Replace the worker after a trap, since the old instance may be poisoned
92
+ * (a panic never unwinds). Other in-flight calls move to the new worker,
93
+ * except those that would trap the same way: the same call again, or any
94
+ * call into a model whose init trapped. Calls with other inputs are re-sent
95
+ * even though they may trap too, each costing another replacement: failing
96
+ * them would report an error for inputs that might work and never retry.
97
+ */
98
+ function replaceWorker(
99
+ trapped: WorkerMessage,
100
+ kind: WasmTrapKind,
101
+ error: Error,
102
+ ): void {
103
+ if (kind === "init") {
104
+ initTraps.set(trapped.model, { error, at: Date.now() });
105
+ }
106
+ restartWorker();
107
+ for (const [id, entry] of Array.from(pending)) {
108
+ const doomed =
109
+ kind === "init"
110
+ ? entry.message.model === trapped.model
111
+ : sameCall(entry.message, trapped);
112
+ if (doomed) {
113
+ pending.delete(id);
114
+ entry.reject(error);
115
+ } else {
116
+ worker.postMessage(entry.message);
117
+ }
118
+ }
119
+ }
120
+
43
121
  export function runWasm(
44
122
  model: string,
45
123
  fn: string,
46
124
  ...args: string[]
47
125
  ): Promise<unknown> {
48
126
  if (workerDeadError) {
49
- return Promise.reject(workerDeadError);
127
+ // The worker's module failed to load or it posted something unreadable.
128
+ // That can be transient (a dev server restart), so try a fresh one
129
+ // rather than failing every call for the rest of the page's life.
130
+ if (Date.now() - workerDiedAt < WORKER_RETRY_MS) {
131
+ return Promise.reject(workerDeadError);
132
+ }
133
+ restartWorker();
134
+ }
135
+ const initTrap = initTraps.get(model);
136
+ if (initTrap) {
137
+ if (Date.now() - initTrap.at < WORKER_RETRY_MS) {
138
+ return Promise.reject(initTrap.error);
139
+ }
140
+ initTraps.delete(model);
50
141
  }
51
142
  return new Promise((resolve, reject) => {
52
143
  const id = ++lastId;
53
- // Capture the current worker so a later `cancelWasm` that swaps the
54
- // worker reference doesn't leave the listener attached to a fresh
55
- // (unrelated) worker.
56
- const myWorker = worker;
57
- pendingRejects.set(id, reject);
58
-
59
- function listener(event: MessageEvent<TransferableResponse>) {
60
- if (event.data?.id !== id) return;
61
- myWorker.removeEventListener("message", listener);
62
- pendingRejects.delete(id);
63
- if (event.data.error) {
64
- reject(new Error(event.data.error));
65
- } else {
66
- resolve(unwrapResponse(event.data));
67
- }
68
- }
69
-
70
- myWorker.addEventListener("message", listener);
71
- myWorker.postMessage({ id, model, fn, args } as WorkerMessage);
144
+ const message: WorkerMessage = { id, model, fn, args };
145
+ pending.set(id, { message, resolve, reject });
146
+ worker.postMessage(message);
72
147
  });
73
148
  }
74
149
 
@@ -80,13 +155,11 @@ export function runWasm(
80
155
  * known to be obsolete (e.g. the user has changed inputs).
81
156
  */
82
157
  export function cancelWasm(): void {
83
- if (pendingRejects.size === 0) return;
84
- worker.terminate();
85
- const rejects = Array.from(pendingRejects.values());
86
- pendingRejects.clear();
87
- workerDeadError = null;
88
- worker = newWorker();
89
- for (const reject of rejects) {
90
- reject(new Error("cancelled"));
158
+ if (pending.size === 0) return;
159
+ const entries = Array.from(pending.values());
160
+ pending.clear();
161
+ restartWorker();
162
+ for (const entry of entries) {
163
+ entry.reject(new Error("cancelled"));
91
164
  }
92
165
  }