Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 "<op> 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
Expand Down
4 changes: 4 additions & 0 deletions docs/wasm-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/necpp-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/necpp-wasm/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/necpp-wasm/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/necpp-wasm/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/necpp-wasm/src/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
3 changes: 2 additions & 1 deletion packages/necpp-wasm/src/worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isWorkerResponse,
reviveEmbeddedFarFieldResult,
reviveError,
reviveErrorOptions,
reviveFarFieldResult,
reviveGeometryCompletionResult,
reviveImpedanceResult,
Expand Down Expand Up @@ -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;
}
Expand Down
87 changes: 67 additions & 20 deletions packages/necpp-wasm/src/worker-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,21 @@ 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;
readonly message: string;
readonly details?: Readonly<Record<string, unknown>>;
readonly operation?: string;
readonly state?: NecModelState;
/** Optional since 0.10.1; older workers omit it. */
readonly cause?: SerializedErrorCause;
}

export type WorkerProgressMessage = {
Expand Down Expand Up @@ -161,40 +169,78 @@ export function collectTransferables(value: unknown): ArrayBuffer[] {
return [...buffers];
}

function errorOptions(
details: Readonly<Record<string, unknown>> | 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 {
Expand All @@ -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),
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/necpp-wasm/test/facade-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/necpp-wasm/test/pack/manifest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
53 changes: 53 additions & 0 deletions packages/necpp-wasm/test/worker-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ function createLoopbackHost(createModel, options = {}) {
simulateExit(code) {
exitListener?.(code);
},
postFromWorker(data) {
port2.postMessage(data);
},
postMessage(data, transfer = []) {
port1.postMessage(data, transfer);
},
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading