From bbf729e449ab79fca54fae162906a00dfad4ffb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:59:27 +0000 Subject: [PATCH] Forward error causes across the WASM worker boundary (0.10.1) The worker protocol serialized only name/code/message/details, so a NecRuntimeError(" failed at the WASM boundary") from a worker-backed model lost its cause (e.g. "memory access out of bounds", "Aborted()"). serializeError now emits an optional, structured-clone-safe cause: { name, message }; reviveError and the client's crash path rebuild it as an Error with the same name and message. Older workers that omit the field still revive with cause === undefined. NecStateError accepts an optional cause argument so state errors keep theirs too. Bump @necpp-engine/wasm to 0.10.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PyKgo3fEVyzmSVf2CVaERy --- CHANGELOG.md | 13 +++ docs/wasm-api.md | 4 + packages/necpp-wasm/README.md | 2 +- .../current-quadrature-v1/manifest.json | 2 +- packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 2 +- packages/necpp-wasm/src/errors.ts | 11 ++- packages/necpp-wasm/src/versions.ts | 2 +- packages/necpp-wasm/src/worker-client.ts | 3 +- packages/necpp-wasm/src/worker-protocol.ts | 87 ++++++++++++++----- .../necpp-wasm/test/facade-runtime.test.mjs | 2 +- .../necpp-wasm/test/pack/manifest.test.mjs | 2 +- .../necpp-wasm/test/worker-client.test.mjs | 53 +++++++++++ .../necpp-wasm/test/worker-protocol.test.mjs | 85 ++++++++++++++++++ 14 files changed, 241 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989f750f..bdb02ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.10.1 - 2026-10-22 + +### Bug Fixes + +* **Worker errors keep their cause:** errors from worker-backed models + (`createNecWorkerModel`, `createNecArraySolver`) now carry a `cause` with + the original `name` and `message`. Before this fix, a WASM trap reached the + caller only as " failed at the WASM boundary", and the reason, such as + "memory access out of bounds" or "Aborted()", was lost. The worker protocol + sends the cause as an optional `{ name, message }` field. Workers that omit + it still work, and their errors arrive with `cause === undefined`. + `NecStateError` takes an optional fourth `cause` constructor argument. + ## 0.10.0 - 2026-10-18 ### Added diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 806eb244..b2ca4b00 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -326,6 +326,10 @@ enumerates every operation/state pair plus both `prepare()` branches. All native exceptions are contained at the C ABI. The TypeScript layer maps a nonzero native status to one of the errors below and retains the native message as `message` or `cause`; a raw number or string is never thrown. +Worker-backed models (`createNecWorkerModel`, `createNecArraySolver`) forward +the `cause` across the worker boundary as a plain `Error` that keeps the +original `name` and `message`, for example `RuntimeError: memory access out +of bounds` from a WASM trap. Only that one level crosses the boundary. ## Error taxonomy diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 99103406..120a1f83 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -864,7 +864,7 @@ appropriate CORS header. import { createNecModel } from "@necpp-engine/wasm"; const model = await createNecModel({ - wasmUrl: new URL("https://cdn.example.test/necpp/0.10.0/nec2pp.wasm"), + wasmUrl: new URL("https://cdn.example.test/necpp/0.10.1/nec2pp.wasm"), }); model.dispose(); ``` diff --git a/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json b/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json index ebffaaad..3d8081ac 100644 --- a/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json +++ b/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json @@ -3,7 +3,7 @@ "fixtureSchema": "current-quadrature-v1", "abiVersion": 1, "engineVersion": "2.5.0", - "packageVersion": "0.10.0", + "packageVersion": "0.10.1", "cases": [ { "id": "dipole", diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 552829fe..cfc34455 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@necpp-engine/wasm", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp-engine/wasm", - "version": "0.10.0", + "version": "0.10.1", "license": "GPL-2.0-or-later", "devDependencies": { "@types/node": "^24.13.3", diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index c908e4de..5fadbe5a 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@necpp-engine/wasm", - "version": "0.10.0", + "version": "0.10.1", "private": false, "type": "module", "description": "Stateful NEC2++ electromagnetic solver and symmetric-array API for Node and browsers", diff --git a/packages/necpp-wasm/src/errors.ts b/packages/necpp-wasm/src/errors.ts index 80370f45..3b3841c9 100644 --- a/packages/necpp-wasm/src/errors.ts +++ b/packages/necpp-wasm/src/errors.ts @@ -31,11 +31,18 @@ export class NecStateError extends NecError<"NEC_STATE"> { readonly operation: string; readonly state: NecModelState; - constructor(operation: string, state: NecModelState, message?: string) { + constructor( + operation: string, + state: NecModelState, + message?: string, + cause?: unknown, + ) { super( "NEC_STATE", message ?? `Operation ${operation} is not valid while the model is ${state}`, - { details: { operation, state } }, + cause === undefined + ? { details: { operation, state } } + : { details: { operation, state }, cause }, ); this.name = "NecStateError"; this.operation = operation; diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts index 7a44e60e..bbfc4b05 100644 --- a/packages/necpp-wasm/src/versions.ts +++ b/packages/necpp-wasm/src/versions.ts @@ -9,7 +9,7 @@ * `NECQ` and `NECF` buffers this package writes. They must match * `src/nec_prepared_current_quadrature.h` and `src/necpp_wasm_v1.cpp`. */ -export const packageVersion = "0.10.0"; +export const packageVersion = "0.10.1"; export const abiVersion = 1; export const engineVersion = "2.5.0"; diff --git a/packages/necpp-wasm/src/worker-client.ts b/packages/necpp-wasm/src/worker-client.ts index cb96e90e..cb3d4d77 100644 --- a/packages/necpp-wasm/src/worker-client.ts +++ b/packages/necpp-wasm/src/worker-client.ts @@ -37,6 +37,7 @@ import { isWorkerResponse, reviveEmbeddedFarFieldResult, reviveError, + reviveErrorOptions, reviveFarFieldResult, reviveGeometryCompletionResult, reviveImpedanceResult, @@ -471,7 +472,7 @@ class WorkerNecModel implements NecWorkerModel { if (data.kind === "crash") { this.#failAll(new NecRuntimeError( data.error.message, - data.error.details === undefined ? {} : { details: data.error.details }, + reviveErrorOptions(data.error), )); return; } diff --git a/packages/necpp-wasm/src/worker-protocol.ts b/packages/necpp-wasm/src/worker-protocol.ts index cd392ce0..22a734fe 100644 --- a/packages/necpp-wasm/src/worker-protocol.ts +++ b/packages/necpp-wasm/src/worker-protocol.ts @@ -74,6 +74,12 @@ export type WorkerRequest = readonly kind: "cancel-field"; }; +/** Structured-clone-safe summary of an error's `cause` (one level deep). */ +export interface SerializedErrorCause { + readonly name: string; + readonly message: string; +} + export interface SerializedNecError { readonly name: string; readonly code: NecErrorCode; @@ -81,6 +87,8 @@ export interface SerializedNecError { readonly details?: Readonly>; readonly operation?: string; readonly state?: NecModelState; + /** Optional since 0.10.1; older workers omit it. */ + readonly cause?: SerializedErrorCause; } export type WorkerProgressMessage = { @@ -161,40 +169,78 @@ export function collectTransferables(value: unknown): ArrayBuffer[] { return [...buffers]; } -function errorOptions( - details: Readonly> | undefined, -): NecErrorOptions { - return details === undefined ? {} : { details }; +function serializeCause(cause: unknown): SerializedErrorCause | undefined { + if (cause === undefined) { + return undefined; + } + if (cause instanceof Error) { + return { name: String(cause.name), message: String(cause.message) }; + } + return { name: "Error", message: String(cause) }; +} + +function reviveCause( + cause: SerializedErrorCause | undefined, +): Error | undefined { + if ( + typeof cause !== "object" + || cause === null + || typeof cause.message !== "string" + ) { + return undefined; + } + const revived = new Error(cause.message); + if (typeof cause.name === "string") { + revived.name = cause.name; + } + return revived; +} + +/** Rebuild constructor options, including the cause, from a wire error. */ +export function reviveErrorOptions(serialized: SerializedNecError): NecErrorOptions { + const cause = reviveCause(serialized.cause); + return { + ...(serialized.details === undefined ? {} : { details: serialized.details }), + ...(cause === undefined ? {} : { cause }), + }; +} + +function withOptionalFields( + serialized: SerializedNecError, + error: NecError, +): SerializedNecError { + const cause = serializeCause(error.cause); + return { + ...serialized, + ...(error.details === undefined ? {} : { details: error.details }), + ...(cause === undefined ? {} : { cause }), + }; } export function serializeError(error: unknown): SerializedNecError { if (error instanceof NecStateError) { - const serialized: SerializedNecError = { + return withOptionalFields({ name: error.name, code: error.code, message: error.message, operation: error.operation, state: error.state, - }; - return error.details === undefined - ? serialized - : { ...serialized, details: error.details }; + }, error); } if (error instanceof NecError) { - const serialized: SerializedNecError = { + return withOptionalFields({ name: error.name, code: error.code, message: error.message, - }; - return error.details === undefined - ? serialized - : { ...serialized, details: error.details }; + }, error); } if (error instanceof Error) { + const cause = serializeCause(error.cause); return { name: "NecRuntimeError", code: "NEC_RUNTIME", message: error.message, + ...(cause === undefined ? {} : { cause }), }; } return { @@ -211,27 +257,28 @@ export function reviveError(serialized: SerializedNecError): NecError { serialized.operation ?? "unknown", serialized.state ?? "disposed", serialized.message, + reviveErrorOptions(serialized).cause, ); case "NEC_INPUT": - return new NecInputError(serialized.message, errorOptions(serialized.details)); + return new NecInputError(serialized.message, reviveErrorOptions(serialized)); case "NEC_GEOMETRY": return new NecGeometryError( serialized.message, - errorOptions(serialized.details), + reviveErrorOptions(serialized), ); case "NEC_PORT": - return new NecPortError(serialized.message, errorOptions(serialized.details)); + return new NecPortError(serialized.message, reviveErrorOptions(serialized)); case "NEC_CONDITIONING": return new NecConditioningError( serialized.message, - errorOptions(serialized.details), + reviveErrorOptions(serialized), ); case "NEC_SOLVER": - return new NecSolverError(serialized.message, errorOptions(serialized.details)); + return new NecSolverError(serialized.message, reviveErrorOptions(serialized)); default: return new NecRuntimeError( serialized.message, - errorOptions(serialized.details), + reviveErrorOptions(serialized), ); } } diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 52b8c141..656e6087 100644 --- a/packages/necpp-wasm/test/facade-runtime.test.mjs +++ b/packages/necpp-wasm/test/facade-runtime.test.mjs @@ -54,7 +54,7 @@ function addDipole(model) { } test("package, engine, and ABI versions are exported", () => { - assert.equal(packageVersion, "0.10.0"); + assert.equal(packageVersion, "0.10.1"); assert.equal(engineVersion, "2.5.0"); assert.equal(abiVersion, 1); }); diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index f470b17d..b0884888 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -23,7 +23,7 @@ test("npm pack contains only the documented publish files", { skip }, () => { assert.equal(packed.version, packageJson.version); const filenamePrefix = packageJson.name.slice(1).replace("/", "-"); assert.equal(packed.filename, `${filenamePrefix}-${packageJson.version}.tgz`); - assert.equal(packageJson.version, "0.10.0"); + assert.equal(packageJson.version, "0.10.1"); assert.equal(fixtureManifest.packageVersion, packageJson.version); assert.equal(packageJson.engines.node, ">=24"); assert.deepEqual(packageJson.publishConfig, { diff --git a/packages/necpp-wasm/test/worker-client.test.mjs b/packages/necpp-wasm/test/worker-client.test.mjs index 8a6f6282..126d11ea 100644 --- a/packages/necpp-wasm/test/worker-client.test.mjs +++ b/packages/necpp-wasm/test/worker-client.test.mjs @@ -172,6 +172,9 @@ function createLoopbackHost(createModel, options = {}) { simulateExit(code) { exitListener?.(code); }, + postFromWorker(data) { + port2.postMessage(data); + }, postMessage(data, transfer = []) { port1.postMessage(data, transfer); }, @@ -423,6 +426,56 @@ test("the client thread keeps a heartbeat while a worker request is outstanding" await model.dispose(); }); +test("worker operation failures keep the WASM trap cause", async () => { + const trap = new WebAssembly.RuntimeError("memory access out of bounds"); + const { model } = await preparedModel(async () => createFakeModel({ + computeImpedanceMatrix() { + throw new NecRuntimeError("computeImpedanceMatrix failed at the WASM boundary", { + cause: trap, + details: { operation: "computeImpedanceMatrix" }, + }); + }, + })); + await assert.rejects(model.computeImpedanceMatrix(), (error) => ( + error instanceof NecRuntimeError + && error.details?.operation === "computeImpedanceMatrix" + && error.cause instanceof Error + && error.cause.name === "RuntimeError" + && error.cause.message === "memory access out of bounds" + )); + await model.dispose(); +}); + +test("worker crash messages keep their serialized cause", async () => { + let release; + const hang = { + filter: (request) => request.method === "computeImpedanceMatrix", + gate: new Promise((resolve) => { + release = resolve; + }), + }; + const host = createLoopbackHost(async () => createFakeModel(), { hang }); + const model = await createNecWorkerModelFromHost(host); + const pending = model.computeImpedanceMatrix(); + host.postFromWorker({ + kind: "crash", + error: { + name: "NecRuntimeError", + code: "NEC_RUNTIME", + message: "The NEC worker failed", + cause: { name: "RuntimeError", message: "unreachable" }, + }, + }); + await assert.rejects(pending, (error) => ( + error instanceof NecRuntimeError + && error.message === "The NEC worker failed" + && error.cause?.name === "RuntimeError" + && error.cause.message === "unreachable" + )); + release(); + model.terminate(); +}); + test("termination releases the worker and rejects outstanding operations", async () => { let release; const hang = { diff --git a/packages/necpp-wasm/test/worker-protocol.test.mjs b/packages/necpp-wasm/test/worker-protocol.test.mjs index a9e59a65..511c7490 100644 --- a/packages/necpp-wasm/test/worker-protocol.test.mjs +++ b/packages/necpp-wasm/test/worker-protocol.test.mjs @@ -128,6 +128,91 @@ test("typed errors round-trip through the worker protocol", () => { assert.equal(runtime.message, "boom"); }); +// (module (func (export "run") unreachable)) +const trappingModuleBytes = Uint8Array.of( + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x00, + 0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b, +); + +function realWasmTrap() { + const instance = new WebAssembly.Instance( + new WebAssembly.Module(trappingModuleBytes), + ); + try { + instance.exports.run(); + } catch (error) { + return error; + } + throw new Error("the trapping module did not trap"); +} + +test("a WASM trap cause survives the worker protocol and structured clone", () => { + const trap = realWasmTrap(); + assert.ok(trap instanceof WebAssembly.RuntimeError); + const original = new NecRuntimeError("solve failed at the WASM boundary", { + cause: trap, + details: { operation: "solve" }, + }); + + const wire = structuredClone(serializeError(original)); + assert.deepEqual(wire.cause, { name: "RuntimeError", message: trap.message }); + + const revived = reviveError(wire); + assert.ok(revived instanceof NecRuntimeError); + assert.equal(revived.message, "solve failed at the WASM boundary"); + assert.deepEqual(revived.details, { operation: "solve" }); + assert.ok(revived.cause instanceof Error); + assert.equal(revived.cause.name, "RuntimeError"); + assert.equal(revived.cause.message, trap.message); +}); + +test("errors without a cause revive without one", () => { + const typed = reviveError(structuredClone(serializeError( + new NecInputError("bad input", { details: { field: "x" } }), + ))); + assert.ok(typed instanceof NecInputError); + assert.equal(typed.cause, undefined); + assert.deepEqual(typed.details, { field: "x" }); + + const wire = serializeError(new NecRuntimeError("no cause")); + assert.equal("cause" in wire, false); + assert.equal(reviveError(structuredClone(wire)).cause, undefined); + + // Wire errors from workers older than 0.10.1 carry no cause field. + const legacy = reviveError({ + name: "NecRuntimeError", + code: "NEC_RUNTIME", + message: "legacy", + }); + assert.equal(legacy.cause, undefined); +}); + +test("non-Error causes and NecStateError causes are preserved", () => { + const stringCause = reviveError(structuredClone(serializeError( + new NecRuntimeError("wrapped", { cause: "Aborted(). Build with -sASSERTIONS" }), + ))); + assert.equal(stringCause.cause.name, "Error"); + assert.equal(stringCause.cause.message, "Aborted(). Build with -sASSERTIONS"); + + const state = reviveError(structuredClone(serializeError( + new NecStateError("prepare", "empty", undefined, realWasmTrap()), + ))); + assert.ok(state instanceof NecStateError); + assert.equal(state.operation, "prepare"); + assert.equal(state.state, "empty"); + assert.equal(state.cause.name, "RuntimeError"); + + const plain = reviveError(serializeError( + new Error("outer", { cause: new TypeError("inner") }), + )); + assert.ok(plain instanceof NecRuntimeError); + assert.equal(plain.cause.name, "TypeError"); + assert.equal(plain.cause.message, "inner"); +}); + test("create options copy wasm bytes and reject invalid overrides", () => { const bytes = new Uint8Array([1, 2, 3]); const serialized = serializeCreateOptions({ wasmBinary: bytes });