Initial Commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createSandboxLifecycleForwarder,
|
||||
type SandboxLifecycleForwarder,
|
||||
} from "./lifecycle.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user