Initial Commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
allocBuffer,
|
||||
freeBuffer,
|
||||
readBytes,
|
||||
unpackPointerLength,
|
||||
writeBytes,
|
||||
} from "./memory.js";
|
||||
export { classifyTrap, type TrapKind } from "./trap-classifier.js";
|
||||
@@ -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`.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user