Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
export {
allocBuffer,
freeBuffer,
readBytes,
unpackPointerLength,
writeBytes,
} from "./memory.js";
export { classifyTrap, type TrapKind } from "./trap-classifier.js";

View File

@@ -0,0 +1,79 @@
# Linear memory helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps the four primitive operations the host bridge performs against the guest's linear memory: allocating a buffer via `__suaveplan_alloc`, freeing it via `__suaveplan_free`, writing bytes into the buffer, and reading bytes back out. Adds the trap-protection and out-of-memory checks the rest of the bridge relies on so caller-site code stays focused on marshalling logic rather than guest-failure handling.
## Features
- `allocBuffer` — calls `__suaveplan_alloc`, catches traps as `SAND_WASM_BRIDGE.MEMORY_ALLOC_FAILED` with classified `trapKind`, and re-raises the same code with `reason: "out_of_memory"` when the guest returns `0`.
- `freeBuffer` — swallows traps so a host-side cleanup fault never shadows the original error being unwound; no-ops on `pointer === 0`.
- `writeBytes` / `readBytes` — typed-array views over `memory.buffer`; `readBytes` returns a copy so subsequent guest allocations cannot resize the view from under the caller.
- `unpackPointerLength` — splits the i64 result the WASM ABI uses to return `(ptr, len)` pairs into a pair of JavaScript numbers.
## Usage: Basic
```ts
import {
allocBuffer,
freeBuffer,
readBytes,
writeBytes,
} from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
const ptr = allocBuffer(exports, bytes.length, "example");
writeBytes(exports.memory, ptr, bytes);
const out = readBytes(exports.memory, ptr, bytes.length);
freeBuffer(exports, ptr);
```
## Usage: Advanced
```ts
import {
allocBuffer,
freeBuffer,
readBytes,
unpackPointerLength,
writeBytes,
} from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
function roundTrip(
exports: import("@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi").WasmAbiExports,
inputBytes: Uint8Array,
pluginName: string,
): Uint8Array {
const reqPtr = allocBuffer(exports, inputBytes.length, pluginName);
try {
writeBytes(exports.memory, reqPtr, inputBytes);
const packed = exports.__suaveplan_handle_request(
reqPtr,
inputBytes.length,
);
const { pointer: respPtr, length: respLen } = unpackPointerLength(packed);
try {
return readBytes(exports.memory, respPtr, respLen);
} finally {
freeBuffer(exports, respPtr);
}
} finally {
freeBuffer(exports, reqPtr);
}
}
```
## API Reference
- `allocBuffer(exports, length, pluginName): number` — throws `SAND_WASM_BRIDGE.MEMORY_ALLOC_FAILED` on trap or `0` return.
- `freeBuffer(exports, pointer): void` — best-effort.
- `writeBytes(memory, pointer, bytes): void`.
- `readBytes(memory, pointer, length): Uint8Array` — copies out of the live buffer.
- `unpackPointerLength(packed: bigint): { pointer: number; length: number }`.
## Implementation Notes
`readBytes` deliberately copies into a fresh `Uint8Array` because `memory.buffer` is detached and replaced whenever the guest grows linear memory (`memory.grow`). A view obtained before a subsequent `__suaveplan_alloc` would silently become invalid; the copy isolates callers from that hazard at the cost of one allocation per response. The `LOW_32_MASK` constant in `unpackPointerLength` is a `bigint` literal so the shift and mask stay in `bigint`-land throughout — mixing `number` and `bigint` here would throw `TypeError`.

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildFixtureWasm } from "../../../tests/fixtures/build-wasm.js";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "../../error-codes/error-codes.js";
import { validateWasmAbi } from "../wasm-abi/abi-validator.js";
import {
allocBuffer,
freeBuffer,
readBytes,
unpackPointerLength,
writeBytes,
} from "./memory.js";
async function compileExports(bytes: Uint8Array) {
const mod = await WebAssembly.compile(bytes);
const instance = await WebAssembly.instantiate(mod, {});
return validateWasmAbi(instance, "ex");
}
describe("memory helpers", () => {
it("allocBuffer returns the bump-pointer offset", {
timeout: 30000,
}, async () => {
const exports = await compileExports(buildFixtureWasm());
const ptr = allocBuffer(exports, 32, "ex");
expect(ptr).toBe(4096);
const ptr2 = allocBuffer(exports, 16, "ex");
expect(ptr2).toBe(4128);
});
it("allocBuffer surfaces zero-return as MEMORY_ALLOC_FAILED", {
timeout: 30000,
}, async () => {
const exports = await compileExports(
buildFixtureWasm({ allocAlwaysZero: true })
);
try {
allocBuffer(exports, 16, "ex");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SandboxWasmBridgeError);
const e = err as SandboxWasmBridgeError;
expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.MEMORY_ALLOC_FAILED);
expect(e.context?.["reason"]).toBe("out_of_memory");
}
});
it("allocBuffer wraps a trap as MEMORY_ALLOC_FAILED with trapKind", {
timeout: 30000,
}, async () => {
const exports = await compileExports(buildFixtureWasm());
// Inject a trapping alloc replacement.
const trapping = {
...exports,
__suaveplan_alloc: () => {
throw new WebAssembly.RuntimeError("unreachable");
},
} as typeof exports;
try {
allocBuffer(trapping, 16, "ex");
throw new Error("expected throw");
} catch (err) {
const e = err as SandboxWasmBridgeError;
expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.MEMORY_ALLOC_FAILED);
expect(e.context?.["trapKind"]).toBe("unreachable");
}
});
it("freeBuffer is a no-op on pointer 0", { timeout: 30000 }, async () => {
const exports = await compileExports(buildFixtureWasm());
freeBuffer(exports, 0);
});
it("freeBuffer swallows traps from the guest", {
timeout: 30000,
}, async () => {
const exports = await compileExports(buildFixtureWasm());
const trapping = {
...exports,
__suaveplan_free: () => {
throw new Error("boom");
},
} as typeof exports;
// Must NOT throw.
freeBuffer(trapping, 4096);
});
it("writeBytes + readBytes round-trip bytes through linear memory", {
timeout: 30000,
}, async () => {
const exports = await compileExports(buildFixtureWasm());
const ptr = allocBuffer(exports, 16, "ex");
const input = new Uint8Array([1, 2, 3, 4, 5]);
writeBytes(exports.memory, ptr, input);
const output = readBytes(exports.memory, ptr, input.length);
expect(Array.from(output)).toEqual(Array.from(input));
// readBytes returns a COPY — mutating the live buffer doesn't bleed.
new Uint8Array(exports.memory.buffer)[ptr] = 99;
expect(output[0]).toBe(1);
});
it("unpackPointerLength splits the packed bigint", {
timeout: 5000,
}, () => {
const packed = (0x1234n << 32n) | 0xabcdn;
expect(unpackPointerLength(packed)).toEqual({
pointer: 0x1234,
length: 0xabcd,
});
});
});

View File

@@ -0,0 +1,110 @@
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "../../error-codes/error-codes.js";
import type { WasmAbiExports } from "../wasm-abi/abi.types.js";
import { classifyTrap } from "./trap-classifier.js";
/**
* High-pointer half-bits used to unpack the i64 `(ptr << 32) | len` value
* returned by `__suaveplan_handle_request` and `__suaveplan_routes`.
*/
const LOW_32_MASK = 0xffff_ffffn;
/**
* Allocate a buffer of `length` bytes in the guest's linear memory and
* return the pointer. Calls `__suaveplan_alloc`; trap-protects against
* the function raising AND against a `0` return value (the canonical
* WASM OOM signal). Surfaces both failure modes as
* `SAND_WASM_BRIDGE.MEMORY_ALLOC_FAILED`.
*/
export function allocBuffer(
exports: WasmAbiExports,
length: number,
pluginName: string
): number {
let offset: number;
try {
offset = exports.__suaveplan_alloc(length);
} catch (cause) {
throw new SandboxWasmBridgeError(
SAND_WASM_BRIDGE_CODES.MEMORY_ALLOC_FAILED,
"error.sand_wasm_bridge.memory_alloc_failed",
{
cause: cause as Error,
context: {
plugin: pluginName,
length,
reason: "trapped",
trapKind: classifyTrap(cause),
},
}
);
}
if (offset === 0) {
throw new SandboxWasmBridgeError(
SAND_WASM_BRIDGE_CODES.MEMORY_ALLOC_FAILED,
"error.sand_wasm_bridge.memory_alloc_failed",
{
context: {
plugin: pluginName,
length,
reason: "out_of_memory",
},
}
);
}
return offset;
}
/**
* Free a buffer previously returned by `allocBuffer` (or by the guest's
* own `__suaveplan_alloc`). Swallows traps — `free` faults during host-
* cleanup do not propagate because they would shadow the original error
* the caller is unwinding.
*/
export function freeBuffer(exports: WasmAbiExports, pointer: number): void {
if (pointer === 0) return;
try {
exports.__suaveplan_free(pointer);
} catch {
// Swallow — see fn-doc rationale.
}
}
/** Write `bytes` into linear memory starting at `pointer`. */
export function writeBytes(
memory: WebAssembly.Memory,
pointer: number,
bytes: Uint8Array
): void {
const view = new Uint8Array(memory.buffer);
view.set(bytes, pointer);
}
/** Read `length` bytes from linear memory starting at `pointer`. */
export function readBytes(
memory: WebAssembly.Memory,
pointer: number,
length: number
): Uint8Array {
const view = new Uint8Array(memory.buffer, pointer, length);
// Copy out — the caller may need the bytes after the next alloc
// resizes the buffer.
return new Uint8Array(view);
}
/**
* Unpack the `bigint` returned by `__suaveplan_handle_request` /
* `__suaveplan_routes` into the matching `(pointer, length)` pair. The
* convention is `(ptr << 32) | len` — high 32 bits are the offset, low
* 32 bits are the length.
*/
export function unpackPointerLength(packed: bigint): {
readonly pointer: number;
readonly length: number;
} {
const length = Number(packed & LOW_32_MASK);
const pointer = Number((packed >> 32n) & LOW_32_MASK);
return { pointer, length };
}

View File

@@ -0,0 +1,66 @@
# Trap classifier
## Purpose
`classifyTrap(cause)` maps a raw `WebAssembly.RuntimeError` (or any thrown value the bridge catches around a guest call) into one of the seven canonical `TrapKind` discriminators the bridge surfaces in `SandboxWasmBridgeError.context.trapKind`. The classification is best-effort and string-based because the WebAssembly JavaScript API does not expose a typed trap reason — the spec only guarantees the runtime-error class and a freeform message.
## Features
- Single function, deterministic output: every input maps to exactly one `TrapKind`, with `"other"` as the safe fallback.
- Engine-agnostic: substring patterns cover the canonical V8, SpiderMonkey, and JSC messages for each trap-emitting WASM instruction.
- Defensive on non-RuntimeError throws: arbitrary host-import faults still resolve to a string-based classification so callers always receive a `trapKind` field.
- Pure — no I/O, no side effects, no allocations beyond the lowercased message buffer.
## Usage: Basic
```ts
import { classifyTrap } from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
try {
instance.exports.handle();
} catch (err) {
console.log(classifyTrap(err));
}
```
## Usage: Advanced
```ts
import {
classifyTrap,
type TrapKind,
} from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
function isFatalTrap(err: unknown): boolean {
const kind: TrapKind = classifyTrap(err);
return kind === "unreachable" || kind === "stack_overflow";
}
function describe(err: unknown): string {
const kind = classifyTrap(err);
switch (kind) {
case "unreachable":
return "guest hit an `unreachable` instruction";
case "out_of_bounds":
return "guest accessed memory outside the allocated range";
case "div_by_zero":
return "guest divided by zero";
case "integer_overflow":
case "invalid_conversion":
return "guest produced an integer-conversion trap";
case "stack_overflow":
return "guest exhausted the call stack";
default:
return "guest trapped";
}
}
```
## API Reference
- `TrapKind` — `"unreachable" | "out_of_bounds" | "div_by_zero" | "integer_overflow" | "invalid_conversion" | "stack_overflow" | "other"`.
- `classifyTrap(cause: unknown): TrapKind` — pure function.
## Implementation Notes
The substring patterns intentionally match multiple phrasings per kind (e.g. `"out of bounds"` and `"out-of-bounds"` and `"memory access"`) so engine-version drift does not flip an existing classification. Adding a new engine (or a new WASM trap instruction) requires extending `classifyByMessage` with the engine-specific message fragment AND appending the new kind to the `TrapKind` union. Both ends of that change are TypeScript-enforced because the switch in `bridge.ts` consumes the union exhaustively.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { classifyTrap } from "./trap-classifier.js";
function rt(message: string): WebAssembly.RuntimeError {
return new WebAssembly.RuntimeError(message);
}
describe("classifyTrap", () => {
it("unreachable trap → 'unreachable'", { timeout: 5000 }, () => {
expect(classifyTrap(rt("unreachable"))).toBe("unreachable");
});
it("out-of-bounds memory access → 'out_of_bounds'", {
timeout: 5000,
}, () => {
expect(classifyTrap(rt("out of bounds memory access"))).toBe(
"out_of_bounds"
);
expect(classifyTrap(rt("memory access out-of-bounds"))).toBe(
"out_of_bounds"
);
});
it("divide-by-zero → 'div_by_zero'", { timeout: 5000 }, () => {
expect(classifyTrap(rt("integer divide by zero"))).toBe("div_by_zero");
expect(classifyTrap(rt("division by zero"))).toBe("div_by_zero");
});
it("integer overflow → 'integer_overflow'", { timeout: 5000 }, () => {
expect(classifyTrap(rt("integer overflow"))).toBe("integer_overflow");
});
it("invalid conversion → 'invalid_conversion'", { timeout: 5000 }, () => {
expect(classifyTrap(rt("invalid conversion to integer"))).toBe(
"invalid_conversion"
);
expect(classifyTrap(rt("float unrepresentable in integer range"))).toBe(
"invalid_conversion"
);
});
it("stack overflow → 'stack_overflow'", { timeout: 5000 }, () => {
expect(classifyTrap(rt("call stack exhausted"))).toBe("stack_overflow");
expect(classifyTrap(rt("Maximum call stack size exceeded"))).toBe(
"stack_overflow"
);
});
it("unknown WASM trap → 'other'", { timeout: 5000 }, () => {
expect(classifyTrap(rt("unrecognized"))).toBe("other");
});
it("non-RuntimeError throws fall through string classification", {
timeout: 5000,
}, () => {
expect(classifyTrap(new Error("out of bounds"))).toBe("out_of_bounds");
expect(classifyTrap(null)).toBe("other");
expect(classifyTrap({})).toBe("other");
});
});

View File

@@ -0,0 +1,60 @@
/** Discrete trap kinds the bridge surfaces in `context.trapKind`. */
export type TrapKind =
| "unreachable"
| "out_of_bounds"
| "div_by_zero"
| "integer_overflow"
| "invalid_conversion"
| "stack_overflow"
| "other";
/**
* Best-effort classification of a `WebAssembly.RuntimeError` (or
* arbitrary thrown value) into one of the seven canonical kinds. The
* mapping reads the message string because the WebAssembly JS API does
* NOT expose a typed trap reason — the spec only guarantees the runtime
* error class and a freeform message. The substring patterns mirror the
* V8 / SpiderMonkey / JSC messages each engine emits for the standard
* trapping instructions.
*/
export function classifyTrap(cause: unknown): TrapKind {
if (cause instanceof WebAssembly.RuntimeError === false) {
// Non-RuntimeError throws (e.g. host import threw a JS Error
// mid-call) are NOT WASM traps; classify as "other" so callers
// can still surface a single `context.trapKind` field.
return classifyByMessage(
(cause as { message?: string } | null)?.message
);
}
return classifyByMessage((cause as WebAssembly.RuntimeError).message);
}
function classifyByMessage(message: string | undefined): TrapKind {
if (message === undefined) return "other";
const lower = message.toLowerCase();
if (lower.includes("unreachable")) return "unreachable";
if (
lower.includes("out of bounds") ||
lower.includes("out-of-bounds") ||
lower.includes("memory access")
) {
return "out_of_bounds";
}
if (
lower.includes("divide by zero") ||
lower.includes("division by zero")
) {
return "div_by_zero";
}
if (lower.includes("integer overflow")) return "integer_overflow";
if (
lower.includes("invalid conversion") ||
lower.includes("float unrepresentable")
) {
return "invalid_conversion";
}
if (lower.includes("call stack") || lower.includes("stack overflow")) {
return "stack_overflow";
}
return "other";
}