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,4 @@
export {
createSandboxLifecycleForwarder,
type SandboxLifecycleForwarder,
} from "./lifecycle.js";

View File

@@ -0,0 +1,81 @@
# lifecycle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the idempotency wrapper around a `VmSandboxHandle`'s
`start` / `dispose` lifecycle. The host's lifecycle driver may invoke
these hooks multiple times (e.g. a hot-reload's stop+start pair, or a
test harness's deterministic shutdown sequence); the wrapper makes the
double-invocation case a silent no-op rather than a throw, and
reflects under-the-hood sandbox throws via the bridge's
`LIFECYCLE_FAILED` code so the host's error boundary sees a uniform
shape.
Splitting the wrapper into its own module keeps the bridge factory
small (no inline state machine) and gives the conformance harness a
direct seam for asserting idempotency without standing up the full
bridge.
## Features
- Idempotent `start` — second call returns without re-invoking
`sandbox.start()`.
- Idempotent `dispose` — second call returns without re-invoking
`sandbox.dispose()`.
- Forbidden start-after-dispose — calling `start()` after `dispose()`
raises `LIFECYCLE_FAILED` so the host can fail the hot-reload
cleanly rather than silently swallowing the request.
- Error wrapping — underlying sandbox throws surface as
`SandboxVmBridgeError` with `code: LIFECYCLE_FAILED` and the
original error preserved as `cause`.
- Inspection helpers — `hasStarted()` and `isDisposed()` for the
bridge's internal request-pipeline guard.
## Usage: Basic
```ts
import { createSandboxLifecycleForwarder } from "@suaveplan/api-sandbox-vm-host-bridge/lifecycle/lifecycle";
const forwarder = createSandboxLifecycleForwarder(sandbox);
await forwarder.start();
forwarder.dispose();
forwarder.dispose(); // no-op
```
## Usage: Advanced
```ts
import { createSandboxLifecycleForwarder } from "@suaveplan/api-sandbox-vm-host-bridge/lifecycle/lifecycle";
const forwarder = createSandboxLifecycleForwarder(sandbox);
try {
await forwarder.start();
} catch (err) {
// err.code === "SAND_VM_BRIDGE.LIFECYCLE_FAILED"
// err.cause is the original SandboxVmError
rollbackJournal.markFailed(err);
}
```
## API Reference
- `interface SandboxLifecycleForwarder` — `start()`, `dispose()`,
`hasStarted()`, `isDisposed()`.
- `createSandboxLifecycleForwarder(sandbox: VmSandboxHandle):
SandboxLifecycleForwarder` — factory; returns a frozen object.
## Implementation Notes
The internal `started` / `disposed` flags are kept in a closure rather
than on the returned object so the sandbox cannot mutate them via a
property-set on the forwarder. The forwarder itself is `Object.freeze`d
so consumers cannot patch `dispose` to skip the cleanup. Errors raised
inside `dispose()` are caught and rewrapped — the alternative (letting
them propagate raw) would mean the host's error boundary sees a mixed
shape (sometimes `SandboxVmError`, sometimes
`SandboxVmBridgeError`); the wrap keeps the boundary's branch arm
tight.

View File

@@ -0,0 +1,99 @@
import type { VmSandboxHandle } from "@suaveplan/api-sandbox-vm";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SandboxVmBridgeError } from "../error-codes/error-codes.js";
import { createSandboxLifecycleForwarder } from "./lifecycle.js";
function fakeHandle(overrides: Partial<VmSandboxHandle> = {}): {
handle: VmSandboxHandle;
counts: { start: number; dispose: number };
} {
const counts = { start: 0, dispose: 0 };
const handle = {
pluginId: "demo",
capabilities: {},
async start() {
counts.start += 1;
},
dispose() {
counts.dispose += 1;
},
isDisposed: () => false,
...overrides,
} as unknown as VmSandboxHandle;
return { handle, counts };
}
describe("createSandboxLifecycleForwarder", () => {
it("start delegates to sandbox.start exactly once", {
timeout: 5000,
}, async () => {
const { handle, counts } = fakeHandle();
const f = createSandboxLifecycleForwarder(handle);
await f.start();
await f.start();
await f.start();
expect(counts.start).toBe(1);
expect(f.hasStarted()).toBe(true);
});
it("dispose is idempotent", { timeout: 5000 }, () => {
const { handle, counts } = fakeHandle();
const f = createSandboxLifecycleForwarder(handle);
f.dispose();
f.dispose();
expect(counts.dispose).toBe(1);
expect(f.isDisposed()).toBe(true);
});
it("start after dispose raises LIFECYCLE_FAILED", {
timeout: 5000,
}, async () => {
const { handle } = fakeHandle();
const f = createSandboxLifecycleForwarder(handle);
f.dispose();
await expect(f.start()).rejects.toMatchObject({
code: "SAND_VM_BRIDGE.LIFECYCLE_FAILED",
});
});
it("wraps sandbox.start throw as LIFECYCLE_FAILED with cause", {
timeout: 5000,
}, async () => {
const cause = new Error("boot exploded");
const { handle } = fakeHandle({
async start() {
throw cause;
},
});
const f = createSandboxLifecycleForwarder(handle);
try {
await f.start();
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(SandboxVmBridgeError);
expect((err as SandboxVmBridgeError).code).toBe(
"SAND_VM_BRIDGE.LIFECYCLE_FAILED"
);
expect((err as SandboxVmBridgeError).cause).toBe(cause);
}
});
it("wraps sandbox.dispose throw as LIFECYCLE_FAILED with cause", {
timeout: 5000,
}, () => {
const cause = new Error("dispose exploded");
const { handle } = fakeHandle({
dispose() {
throw cause;
},
});
const f = createSandboxLifecycleForwarder(handle);
try {
f.dispose();
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(SandboxVmBridgeError);
expect((err as SandboxVmBridgeError).cause).toBe(cause);
}
});
});

View File

@@ -0,0 +1,76 @@
import type { VmSandboxHandle } from "@suaveplan/api-sandbox-vm";
import {
SAND_VM_BRIDGE_CODES,
SandboxVmBridgeError,
} from "../error-codes/error-codes.js";
/**
* Idempotent lifecycle forwarder around a `VmSandboxHandle`. Wraps
* `start` so a second call is a silent no-op (the underlying sandbox
* has already evaluated its module) and wraps `dispose` so a second
* call is also a no-op (the sandbox's own `isDisposed()` would short-
* circuit, but having the guard at the bridge layer means the host
* never sees a stray throw if a future sandbox impl tightens its
* behaviour).
*
* Underlying-handle failures are re-wrapped as
* `SAND_VM_BRIDGE.LIFECYCLE_FAILED` with the original error preserved
* as `cause`.
*/
export interface SandboxLifecycleForwarder {
readonly start: () => Promise<void>;
readonly dispose: () => void;
readonly hasStarted: () => boolean;
readonly isDisposed: () => boolean;
}
/**
* Build the idempotent lifecycle forwarder around a `VmSandboxHandle`.
*/
export function createSandboxLifecycleForwarder(
sandbox: VmSandboxHandle
): SandboxLifecycleForwarder {
let started = false;
let disposed = false;
return Object.freeze({
async start(): Promise<void> {
if (started) return;
if (disposed) {
throw new SandboxVmBridgeError(
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
"cannot start a disposed sandbox bridge"
);
}
try {
await sandbox.start();
} catch (err) {
throw new SandboxVmBridgeError(
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
"sandbox start failed",
{ cause: err }
);
}
started = true;
},
dispose(): void {
if (disposed) return;
disposed = true;
try {
sandbox.dispose();
} catch (err) {
throw new SandboxVmBridgeError(
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
"sandbox dispose failed",
{ cause: err }
);
}
},
hasStarted(): boolean {
return started;
},
isDisposed(): boolean {
return disposed;
},
});
}