From 84e6fc0212d3b01d35b3d6ebc341dafa02bc8fda Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 03:24:17 -0700 Subject: [PATCH 1/3] feat!: implement vine forwarding core + all five transports (e2e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package is now functional end to end, not a scaffold. Implements the docs/DESIGN.md v1 contract: per-leaf forwarding stubs mounted at identical paths (permission-gated by slothlet itself), async callId correlation, settle-once, per-call budget timers, and remote-death force-settle — over an injected Channel seam the core never imports a transport for. Core: - src/lib/{errors,frame,link}.mjs — VineError/VineRemoteError taxonomy (remote VINE_* codes remapped to VINE_REMOTE so a far side cannot spoof link-state), total junk-tolerant frame parsing with prototype-pollution-safe path guards, and the settle-once correlation table. - src/serve.mjs / src/grow.mjs — serve exposes an instance's leaves (hard-excludes slothlet.**, filters by paths, reports excluded); grow mounts one stub per leaf, ownership-scoped teardown, handshake deadline so a silent peer can't hang. Transports (each self-contained, drop-in, conformance-harnessed + full 6-point e2e over its REAL boundary): - loopback (in-process pair), post-message (Worker/MessagePort surface), worker-threads (real Worker, death via terminate), process (real fork, serialization:advanced, death via kill), websocket (real ws server, ephemeral port, death via socket close). ws is an optional peer dep imported only by its module. Uniform send-failure policy across all transports: a medium-refused frame rethrows -> that one call settles VINE_BAD_FRAME (link survives); a dead channel fires onClose -> VINE_GONE; a close race is a no-op. Locked in by tests/regression-send-failure.test.vitest.mjs across all four real transports. Reusable Channel conformance harness at @cldmv/slothlet-vine/testing so consumer transports self-verify. 330 tests, 97.6% stmts / 93.6% branch, lint clean. Adversarially reviewed twice (core, then cross-transport); all findings fixed. Three genuine slothlet bugs found and filed upstream (CLDMV/slothlet#302 proto pollution via add() path, #303 colon-moduleID ownership, #304 .apply record corruption); vine defends against all three internally. BREAKING CHANGE: establishes the stable v1 public API. grow/serve, the Channel transport contract, the wire frame protocol, and the VineError/ VineRemoteError taxonomy are now the committed 1.0 surface. The pre-release stubs that threw NOT_IMPLEMENTED are replaced by working implementations, so any code written against the throwing scaffold now behaves entirely differently. --- README.md | 30 +- docs/DESIGN.md | 172 ++++++ package-lock.json | 30 +- package.json | 15 +- schemas/frame.schema.json | 41 +- src/grow.mjs | 391 ++++++++++++++ src/index.mjs | 66 +-- src/lib/errors.mjs | 199 +++++++ src/lib/frame.mjs | 239 ++++++++ src/lib/link.mjs | 207 +++++++ src/serve.mjs | 262 +++++++++ src/testing/conformance.mjs | 286 ++++++++++ src/transport/loopback.mjs | 165 +++++- src/transport/post-message.mjs | 265 ++++++++- src/transport/process.mjs | 300 ++++++++++- src/transport/websocket.mjs | 325 ++++++++++- src/transport/worker-threads.mjs | 261 ++++++++- tests/conformance-loopback.test.vitest.mjs | 81 +++ tests/e2e-loopback.test.vitest.mjs | 376 +++++++++++++ tests/e2e-post-message.test.vitest.mjs | 493 +++++++++++++++++ tests/e2e-process.test.vitest.mjs | 430 +++++++++++++++ tests/e2e-websocket.test.vitest.mjs | 451 ++++++++++++++++ tests/e2e-worker-threads.test.vitest.mjs | 284 ++++++++++ tests/errors.test.vitest.mjs | 209 +++++++ tests/fixtures/codec-api/codec.mjs | 39 ++ tests/fixtures/grow-api/caller.mjs | 27 + tests/fixtures/proc-serve-child.mjs | 32 ++ tests/fixtures/regression-api/factory.mjs | 28 + tests/fixtures/regression-api/intl.mjs | 23 + tests/fixtures/serve-api/math.mjs | 19 + tests/fixtures/serve-api/tools.mjs | 53 ++ tests/fixtures/wt-func-api/leaf.mjs | 16 + tests/fixtures/wt-serve-worker.mjs | 27 + tests/frame.test.vitest.mjs | 211 ++++++++ tests/grow-serve-units.test.vitest.mjs | 508 ++++++++++++++++++ tests/link.test.vitest.mjs | 226 ++++++++ tests/package-surface.test.vitest.mjs | 136 +++++ tests/regression-send-failure.test.vitest.mjs | 193 +++++++ tests/regressions.test.vitest.mjs | 482 +++++++++++++++++ tests/scaffold.test.vitest.mjs | 72 --- 40 files changed, 7531 insertions(+), 139 deletions(-) create mode 100644 docs/DESIGN.md create mode 100644 src/grow.mjs create mode 100644 src/lib/errors.mjs create mode 100644 src/lib/frame.mjs create mode 100644 src/lib/link.mjs create mode 100644 src/serve.mjs create mode 100644 src/testing/conformance.mjs create mode 100644 tests/conformance-loopback.test.vitest.mjs create mode 100644 tests/e2e-loopback.test.vitest.mjs create mode 100644 tests/e2e-post-message.test.vitest.mjs create mode 100644 tests/e2e-process.test.vitest.mjs create mode 100644 tests/e2e-websocket.test.vitest.mjs create mode 100644 tests/e2e-worker-threads.test.vitest.mjs create mode 100644 tests/errors.test.vitest.mjs create mode 100644 tests/fixtures/codec-api/codec.mjs create mode 100644 tests/fixtures/grow-api/caller.mjs create mode 100644 tests/fixtures/proc-serve-child.mjs create mode 100644 tests/fixtures/regression-api/factory.mjs create mode 100644 tests/fixtures/regression-api/intl.mjs create mode 100644 tests/fixtures/serve-api/math.mjs create mode 100644 tests/fixtures/serve-api/tools.mjs create mode 100644 tests/fixtures/wt-func-api/leaf.mjs create mode 100644 tests/fixtures/wt-serve-worker.mjs create mode 100644 tests/frame.test.vitest.mjs create mode 100644 tests/grow-serve-units.test.vitest.mjs create mode 100644 tests/link.test.vitest.mjs create mode 100644 tests/package-surface.test.vitest.mjs create mode 100644 tests/regression-send-failure.test.vitest.mjs create mode 100644 tests/regressions.test.vitest.mjs delete mode 100644 tests/scaffold.test.vitest.mjs diff --git a/README.md b/README.md index 3cfa844..47b056e 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,43 @@ Vines between slothlet api trees. -[Slothlet](https://github.com/CLDMV/slothlet) composes a folder of modules into an api **tree**. A **vine** connects two trees across an execution boundary — a Web Worker, another thread, another process, or another machine — by mounting **forwarding leaves**: stubs that live at the callee's identical logical path in the caller's tree, so `self.exts.foo.bar()` works the same whether `foo` is co-located or isolated. Slothlet cannot tell a vine leaf from a real one — including its permission identity, so slothlet's own permission system gates every cross-boundary call before it dispatches. +[Slothlet](https://github.com/CLDMV/slothlet) composes a folder of modules into an api **tree**. A **vine** connects two trees across an execution boundary — a Web Worker, another thread, another process, or another machine — by mounting **forwarding leaves**: stubs that live at the callee's identical logical path in the caller's tree, so `self.exts.foo.bar()` works the same whether `foo` is co-located or isolated. Slothlet cannot tell a vine leaf from a real one — including its permission identity, so a rule targeting `exts.foo.bar` gates the forwarding stub exactly as it would gate the real leaf, before it dispatches. + +That gating follows slothlet's own rule about **who is calling**: a call made by a MODULE (`self.exts.foo.bar()`) is checked against the permission rules, and a denied one never runs the stub body, so it never reaches the wire. A call made through the bound handle `slothlet()` returned — the host itself — carries host standing and is not checked. That carve-out is slothlet's design, not a vine gap, but it does mean "permission-gated" describes module-initiated calls; a host that forwards on someone else's behalf is responsible for its own authorization. ## Status -**Pre-implementation scaffold.** The design is proven (the forwarding mechanism runs in production node-side in a consuming project, built entirely on slothlet's public API) and the browser transposition is being spiked. The package publishes nothing yet. +**Feature-complete for v1; not yet published.** `grow`, `serve`, the frame protocol, the error taxonomy, and the reusable Channel conformance harness are in place, and **all five built-in transports are implemented and tested over their real boundaries**: `loopback` (in-process reference), `post-message` (a real `worker_threads` `MessageChannel` structured-clone hop), `worker-threads` (a real `Worker`), `process` (a real forked child over IPC), and `websocket` (a real `ws` connection on an ephemeral port). Every transport runs the shared Channel conformance suite plus the full six-point e2e bar against real slothlet instances. The package publishes nothing yet. + +> **Note on `process`:** the transport declares structured-clone fidelity, which requires the child to be forked with `{ serialization: "advanced" }`. Under Node's default `"json"` serialization, rich types (`Date`, `Map`, `Set`) degrade the same way the websocket JSON codec degrades them; the plain frame envelope works either way. + +The normative protocol lives in [`docs/DESIGN.md`](docs/DESIGN.md); the wire frames are in [`schemas/frame.schema.json`](schemas/frame.schema.json). ## Usage shape (dot notation — the slothlet idiom) ```js import * as vine from "@cldmv/slothlet-vine"; -import { createChannel } from "@cldmv/slothlet-vine/transport/post-message"; +import { createPair } from "@cldmv/slothlet-vine/transport/loopback"; + +const [near, far] = createPair(); + +// serve this instance's leaves to the far side +const serving = await vine.serve(workerApi, far, { paths: ["exts"] }); + +// mount the far tree's leaves into this instance, at identical paths +const link = await vine.grow(hostApi, near, { budgetMs: 5000 }); +await hostApi.exts.pdfViewer.open("a.pdf"); // executes on the serving instance -const channel = createChannel(worker); -vine.grow(api, channel); // mount the far tree's leaves into this instance -vine.serve(api, channel); // serve this instance's leaves to the far side +await link.close(); // stubs unmounted; in-flight calls settle VINE_CLOSED +serving.close(); ``` Single-word leaves, context carried by the namespace — never `growVine()`-style camelCase that repeats the package's own name. ## Design -- **Async-only forwarding**: calls serialize to `{ type: "call", callId, path, args, context }` frames; correlation by `callId`; settle-once; per-call budget timers; a dead far side force-settles every in-flight call with a coded error instead of hanging. -- **The `Channel` seam**: every transport implements `{ send(message), onMessage(handler), close() }`. The bridge consumes only this interface — transports are injected, never imported by the core. +- **Async-only, data-only forwarding**: calls serialize to `{ type: "call", callId, path, args }` frames; correlation by `callId`; settle-once; per-call budget timers; a dead far side force-settles every in-flight call with a coded error instead of hanging. A function-valued argument is refused at the edge (`VINE_DATA_ONLY`) before anything is sent. +- **The `Channel` seam**: every transport implements `{ send(message), onMessage(handler), close?(), onClose?(handler) }` plus a capability declaration. The core consumes only this interface — transports are injected, never imported by it. - **Batteries included, dependencies contained**: built-in transports ship as subpath exports so each one's dependency loads only if imported: - `slothlet-vine/transport/loopback` — in-process pair (tests, simulation) - `slothlet-vine/transport/post-message` — browser `Worker` / `MessagePort` (structured clone; no codec) diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..6253925 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,172 @@ +# slothlet-vine — design & protocol (v1) + +The implementation contract for `@cldmv/slothlet-vine`. Everything here is normative: the core, every built-in transport, and any consumer-written transport implement THIS. The mechanism is a browser-ready transposition of a production-proven node-side design (per-leaf forwarding stubs mounted at identical paths, permission-gated by slothlet itself, async correlation over an injected channel). + +## Vocabulary + +- **vine** — the forwarding layer as a whole (`vine.grow` / `vine.serve`). +- **link** — one live connection between two slothlet instances over one channel. +- **channel** — the transport seam: the ONLY thing the core knows about a transport. +- **stub / forwarding leaf** — a synthetic leaf mounted in the grow-side tree at the callee's identical dotted path; calling it forwards over the link. + +## The Channel contract (transport seam) + +```js +/** + * @typedef {object} Channel + * @property {(message: object) => void} send — deliver one frame to the far side + * @property {(handler: (message: object) => void) => void} onMessage — register the (single) receive handler + * @property {() => void} [close] — tear the transport down + * @property {(handler: (info?: object) => void) => void} [onClose] — register a (single) far-side-death/closure handler + * @property {{ structuredClone?: boolean, codec?: "none"|"json", buffersUntilHandler?: boolean }} [capabilities] + * — `structuredClone`/`codec`: what the medium preserves and how it encodes. `buffersUntilHandler`: + * whether frames that arrive before `onMessage` is registered are buffered (`true`) or may be + * dropped (absent/`false`) — the conformance suite asserts whichever the transport declares. + */ +``` + +Rules: + +- The **core never imports a transport**. Transports are self-contained modules that produce Channels; the core consumes only this interface. Adding a transport = adding one module; consumers may pass ANY object satisfying this contract. +- `send`/`onMessage` carry **plain frame objects**. A transport whose medium structured-clones (postMessage family) passes them through (`capabilities.structuredClone: true`, `codec: "none"`). A byte transport (websocket) owns its own encode/decode internally (`codec: "json"` in v1 — document that JSON degrades `Date`/`Map`/`Set`; a richer codec is a future capability). +- `onMessage`/`onClose` are single-handler registrations (last write wins). Handlers must never throw into the transport; the core wraps its handlers. +- **`send()` has a three-way failure policy, uniform across every transport** (this is what the core's immediate-settle path relies on): + - **The medium REFUSES this frame** — an un-serializable argument the data-only scan cannot see (a `DataCloneError` on the structured-clone family; a synchronous serializer throw from `child.send`; a `JSON.stringify` throw on a `BigInt` for the websocket codec). This is a **per-call** problem: `send()` **rethrows** (throws synchronously), and the core settles just that one call with `VINE_BAD_FRAME`. It does NOT fire `onClose` or kill the link — every other in-flight call is unaffected. + - **The channel is DEAD** — `ERR_IPC_CHANNEL_CLOSED`/`EPIPE`/similar, a socket gone, a port closed. `send()` fires `onClose` (link death; the core force-settles all pending calls `VINE_GONE`) and does NOT rethrow. + - **Close race** — a send after a local `close()`, or a frame crossing a peer close the medium silently drops. A silent no-op; the core tolerates it (the call settles on death or budget). +- Frames may arrive after `close()` was called locally; the core must tolerate (ignore) them. +- Every built-in transport module exports `createChannel(...)` (arguments transport-specific) and may export helpers (e.g. a pair factory). Where a transport spans processes, it also exports what the far side needs (e.g. a child-side `createChannel`). + +## Frames (schema v1 — `schemas/frame.schema.json` is normative) + +All frames are objects with a `type`. Unknown `type`s are ignored (forward compatibility). + +| Frame | Shape | Direction | +| ------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| surface | `{ type: "surface", v: 1, leaves: string[] }` | serve → grow, once on link start (and again if the served surface changes — v1 sends once) | +| call | `{ type: "call", callId: string, path: string, args: unknown[] }` | grow → serve | +| result | `{ type: "result", callId: string, value?: unknown }` | serve → grow | +| error | `{ type: "error", callId: string, error: { name: string, message: string, code?: string, stack?: string } }` | serve → grow | + +- `callId`: unique per grow-side link (monotonic counter + link nonce; never `Math.random` collisions). +- `path`: the dotted leaf path exactly as served (e.g. `exts.pdfViewer.open`). +- Function-valued `args` are **rejected grow-side** before dispatch (`VINE_DATA_ONLY`) — the vine is data-only in v1. So are function-valued **return values**, rejected serve-side with the same code; see [Data-only, both directions](#data-only-both-directions). +- Errors cross as data and are re-thrown grow-side as `VineRemoteError` (name/message/code preserved, remote stack attached as `.remoteStack`). + +## API surface (dot notation — single-word leaves) + +```js +import * as vine from "@cldmv/slothlet-vine"; + +// serve: expose this instance's leaves to the far side of the channel +const serving = await vine.serve(api, channel, { + paths: ["exts"], // dotted prefixes to serve; DEFAULT: all leaves EXCEPT "slothlet.**" (the control plane is NEVER served) + modules: ["ext-1"], // extra moduleIDs to union in (runtime add() mounts) + budgetMs: 30_000 // unused serve-side v1 (documented for symmetry) +}); +// serving: { leaves: string[], excluded: string[], close(): void } + +// grow: mount the far side's leaves into this instance +const link = await vine.grow(api, channel, { + budgetMs: 30_000, // per-call settle budget; exceeded → VINE_BUDGET error + handshakeMs: 30_000, // deadline for the surface frame; Infinity to wait forever + paths: ["exts"] // dotted prefixes to mount (grow-side mirror of serve's) +}); +// link: { id, leaves: string[], skipped: string[], collisions: string[], close(): Promise, closed: Promise<{reason}> } +``` + +Semantics: + +- **serve** answers `call` frames by resolving the dotted path against the live api and invoking it. It re-validates that the path is within the served surface (never trust the wire). Thrown/rejected errors become `error` frames. It sends `surface` immediately on start, derived from the instance's leaf records filtered by `paths` and the hard `slothlet.**` exclusion. +- **grow** awaits the `surface` frame (subject to `handshakeMs`), then mounts one async stub per leaf at the identical dotted path via slothlet's synthetic in-memory add (`api.slothlet.api.add(path, stubFn, { moduleID })`, one shared link moduleID) so **slothlet's own permission system gates stub calls exactly like real leaves**. `link.close()` removes the mounts (`api.slothlet.api.remove(moduleID)`) and settles all pending calls with `VINE_CLOSED`. +- A **channel is directional**: one serve end, one grow end. Bidirectional forwarding = two channels (transports that have paired endpoints expose pairs). +- **Death**: `channel.onClose` (or transport-detected far-side death) force-settles every pending call with `VINE_GONE` and resolves `link.closed`. Pending calls NEVER hang. +- **Budget**: each grow-side call arms a timer (`budgetMs`); expiry settles that call with `VINE_BUDGET` (the late result frame, if it arrives, is ignored — settle-once). +- **Settle-once** everywhere: a callId settles exactly once (result | error | budget | gone | closed); later frames for it are dropped. + +Error codes (all `VineError` subclasses carrying `.code`): `VINE_GONE`, `VINE_BUDGET`, `VINE_CLOSED`, `VINE_DATA_ONLY`, `VINE_BAD_FRAME`, `VINE_NO_LEAF` (call for a path not in the served surface), `VINE_REMOTE`. Remote application errors re-throw as `VineRemoteError` (their own name/message/code) — except a `VINE_*` code, which is never adopted from the wire; see [Errors](#errors) below. + +## Built-in transports (each: one self-contained module + e2e test) + +| Subpath | Boundary | Notes | +| -------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transport/loopback` | same process | `createPair()` → two linked Channels; `setImmediate`/`queueMicrotask` delivery (async like a real boundary). The reference implementation + test workhorse. | +| `transport/post-message` | anything with the `postMessage`/`onmessage`/`close` port surface | wraps a browser `Worker`, `MessagePort`, or node `worker_threads` MessagePort (same surface). `capabilities.structuredClone: true`. | +| `transport/worker-threads` | node worker | parent: wrap a `Worker`; child: wrap `parentPort`. | +| `transport/process` | node child process | parent: wrap a `ChildProcess` (fork, `serialization: "advanced"` recommended); child: wrap `process`. | +| `transport/websocket` | network | wrap a `ws` WebSocket (client or server-accepted socket). `codec: "json"` v1. `ws` is an optional peer dependency — imported ONLY by this module. | + +## Conformance harness (`@cldmv/slothlet-vine/testing`) + +`channelConformance(name, makePair, { test framework injection })` — a reusable suite ANY transport (built-in or consumer) runs against a factory producing a connected channel pair. Verifies: delivery, ordering, multi-frame bursts, large-ish payloads, `onMessage` registered-after-send behavior (frames sent before a handler is registered may be dropped OR buffered — the suite asserts the transport's declared behavior), `close()` idempotence, `onClose` firing on far-side close. Every built-in transport's test file runs this harness PLUS its e2e. + +## E2E test bar (every transport, no exceptions) + +Each transport's test composes a REAL slothlet instance on the serve side (a small api: sync leaf, async leaf, throwing leaf) and a REAL slothlet instance on the grow side, links them over that transport's real boundary (real `Worker`, real forked child, real ws server on an ephemeral port), and asserts: + +1. sync + async round-trips return correct values through `growApi.()`; +2. a thrown remote error re-throws grow-side as `VineRemoteError` with the original message; +3. a slothlet **deny rule** on the grow side blocks the stub call (permission gating works on mounted stubs); +4. `VINE_BUDGET` fires on a deliberately-slow leaf with a small budget; +5. killing the far side (terminate worker / kill child / close socket) settles in-flight calls with `VINE_GONE`; +6. `link.close()` unmounts the stubs (path gone from the api) and later calls fail `VINE_CLOSED`. + +Process/worker child entry files live under `tests/fixtures/`. + +## v1 implementation notes & deviations + +Where the shipped implementation differs from the sketch above, or settles something the sketch left open. These are normative for v1 — the sketch is the intent, this section is what the code does. + +### Signatures + +- **`serve()` is async.** The sketch calls it without `await`. It cannot be synchronous: the surface is read from the loader's records and `api.slothlet.api.leaves()` returns a Promise. `serve()` therefore returns `Promise<{ leaves, excluded, close }>`. `grow()` was always async. +- **`grow(api, channel, { handshakeMs })`** — a deadline for the `surface` frame itself, which the sketch does not specify. Without one, a far side that never publishes leaves hangs `await grow(...)` forever, contradicting the design's own "pending calls NEVER hang" rule. Defaults to `budgetMs`. `Infinity` is the explicit opt-out and waits indefinitely; anything else that is not a positive finite number (`null`, `0`, `-1`, `NaN`, a string) falls back to the default rather than silently meaning "no deadline" — the same reading `budgetMs` gets. +- **`grow(api, channel, { paths })`** — dotted prefixes, the grow-side mirror of `serve`'s. Defence in depth: the serving side filters too, but "the far side already checked" is not a security property. Both read an unsatisfiable array (`[]`, `["", 7]`) as fail-closed — nothing is served / mounted — and ignore a non-array value. +- **`serve(api, channel, { modules })`** — additional moduleIDs whose leaves are unioned into the surface. `leaves(".")` covers the base load only; runtime `api.slothlet.api.add()` mounts are module-scoped and there is no registry of mounted ids to iterate. Unknown ids are skipped, not fatal. + +### Reporting surfaces + +- **`serving.excluded`** — the callable leaves this serve declined to publish, whether refused by the path guard or filtered out by `paths`. A surface that is quietly shorter than expected is otherwise very hard to diagnose from the far side of a boundary. Namespace and data records are not reported: they were never candidates for a callable surface. +- **`link.skipped` / `link.collisions`** — with `link.leaves`, these three lists are **disjoint** and together account for every leaf the far side published. `leaves` are the paths actually mounted and forwarding. `skipped` are far leaves refused locally (unsafe path, outside `paths`, rejected by `add()`, or published after the link had already ended — mounting stops if the far side dies mid-manifest). `collisions` are paths the local instance already occupied: they are **not mounted at all**, the incumbent keeps answering there, and the far leaf is simply unreachable through this link. A vine never passes `forceOverwrite` — clobbering local reality with a remote's idea of the tree is not a trade worth making. +- **`link.close()` is ownership-scoped.** It removes the link's module, then verifies: any path that survived the module-scoped removal is removed again individually — but only if the loader's records still say the link OWNS it. A local module may legitimately have taken a vine path over (`forceOverwrite`, its own moduleID) while the link was up, and slothlet's own `remove(moduleID)` correctly leaves such a takeover in place; the vine must not undo that on the way out. Ownership is read before the module removal, because afterwards the id is unknown and `leaves(id)` throws. + +### Errors + +- **`VINE_REMOTE`** joins the code list: the `.code` of a re-thrown remote error that carried none of its own. +- **Reserved codes are never adopted from the wire.** A remote `code` matching `VINE_*` is remapped to `VINE_REMOTE`, and the far side's own spelling is preserved on `.remoteCode`. Otherwise a peer could send `{ name: "VineError", code: "VINE_CLOSED" }` and satisfy `err instanceof VineError && err.code === CODES.CLOSED` — the documented way to branch on link state — driving a consumer's teardown path from across the boundary. A vine link-state code describes _this_ link and can only be produced locally. The remap is blind to which reserved code arrived, including ones a far side's serve legitimately produced (`VINE_NO_LEAF`, `VINE_DATA_ONLY`): read `.remoteCode` for what the far side said, `.code` for the fact that it came from over there. + +### Transport send-failure classification (the `VINE_BAD_FRAME` vs `VINE_GONE` line) + +Every transport's `send()` honours the three-way policy in the Channel contract above. What "the medium refuses this frame" concretely is, per transport — the un-serializable case is uniform (`VINE_BAD_FRAME`, that one call only, link alive) even though each medium signals it differently: + +| Transport | Un-serializable frame in `send()` | Result | +| --------------------------------- | ----------------------------------------------------------------------- | ---------------- | +| `loopback` | passed BY REFERENCE — nothing to serialize, never refused | (n/a — crosses) | +| `post-message` / `worker-threads` | `postMessage` throws `DataCloneError` → **rethrown** | `VINE_BAD_FRAME` | +| `process` | `child.send` throws synchronously (no dead-channel code) → **rethrown** | `VINE_BAD_FRAME` | +| `websocket` | `JSON.stringify` throws (a `BigInt`) → **rethrown** | `VINE_BAD_FRAME` | + +Dead-channel signals (`ERR_IPC_CHANNEL_CLOSED`/`EPIPE`, socket/port gone) fire `onClose` → `VINE_GONE` instead, and a send across a local close is a silent no-op. The websocket JSON codec's lossy-but-VALID degradations (`Date`→ISO string, `Map`/`Set`→`{}`, `Symbol` dropped) are NOT refusals — the frame still crosses; only an un-encodable value (`BigInt`) is refused. This uniformity is what lets a single bad argument on one call fail just that call rather than tearing down the whole link. + +### Process transport serialization — `fork(..., { serialization: "advanced" })` + +The `process` transport declares `capabilities.structuredClone: true`, but the transport cannot force the child's fork options. That guarantee holds only when the child is forked with `{ serialization: "advanced" }` (the V8 structured-clone serializer). Under the DEFAULT `"json"` serialization, rich types degrade exactly as the websocket JSON codec degrades them (`Date`→string, `Map`/`Set`→`{}`), so a consumer that forwards structured-clone types over `process` MUST fork advanced. The plain JSON-safe frame envelope itself works under either mode. + +### Data-only, both directions + +The sketch states the rule for arguments. It applies to **return values** too, and that half can only be enforced serve-side: a leaf whose return value contains a function anywhere is answered with an `error` frame carrying `VINE_DATA_ONLY` instead of a `result`. Left unchecked, the same call has two different meanings depending on the transport — over a cloning boundary it fails as an opaque `DataCloneError`, while over a by-reference one (loopback, same realm) the function crosses intact and hands the caller a live closure over the other side's scope, which is a hole in the isolation the vine exists to provide. + +### Paths + +- A path segment must be a valid ECMAScript **IdentifierName** (`ID_Start`/`ID_Continue`, `$`, `_`, ZWNJ/ZWJ) and must not be `__proto__`, `constructor`, `prototype`, `slothlet`, `shutdown` or `destroy`. The alphabet is deliberately unicode-aware: slothlet sanitizes file and directory names, but a leaf's name is its EXPORT name, which it does not touch — `export function café() {}` is a real, callable, `leaves()`-reported leaf, and an ASCII-only guard drops it for no security gain. +- **Serve dispatches with `Reflect.apply(leaf, parent, args)`, never `leaf.apply(parent, args)`.** Probed on slothlet 3.14.0, merely reading `.apply` off a leaf materializes it into the loader's records: the leaf is reported as a `namespace` owning a child `.apply` afterwards. Answering a call would otherwise corrupt the record tree it was read from, and a later `serve()` of the same instance would publish `.apply` — `Function.prototype.apply` bound to a real leaf — in place of the leaf. + +### Security notes (v1 limits, deliberate) + +- **No serve-side concurrency cap.** A peer may have any number of calls in flight; each one invokes a real leaf. The boundary is assumed to be one you established (a worker you spawned, a process you forked, a socket you authenticated at the transport layer), not an open port. A hostile peer on such a channel can exhaust the serving side by volume alone. +- **No grow-side surface-size cap.** A `surface` frame may name any number of leaves and each one becomes a mount. The path guard bounds what a leaf may be _called_, not how many arrive. +- Both are non-goals for v1 rather than oversights; a transport that faces an untrusted network should apply its own limits before the frames reach the vine. + +## Non-goals (v1) + +Streaming/callback args (data-only), bidirectional-on-one-channel, reconnection/retry, surface re-publication on live reload, auth handshakes (transport-level concern; same-origin/same-process built-ins don't need one), rich byte codecs. diff --git a/package-lock.json b/package-lock.json index a27d2e3..1779da8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@cldmv/eslint-plugin-jsonv": "^1.0.3", "@cldmv/jsonv": "^1.0.2", "@cldmv/prettier-plugin-jsonv": "^1.0.1", + "@cldmv/slothlet": "^3.14.0", "@cldmv/vitest-runner": "^1.2.0", "@eslint/css": "^1.4.0", "@eslint/js": "^10.0.1", @@ -21,7 +22,8 @@ "eslint": "^10.9.0", "globals": "^17.11.0", "prettier": "^3.9.6", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "ws": "^8.21.3" }, "funding": { "type": "github", @@ -141,8 +143,8 @@ "version": "3.14.0", "resolved": "https://registry.npmjs.org/@cldmv/slothlet/-/slothlet-3.14.0.tgz", "integrity": "sha512-LCPjgj74K7GYgSOOrxKBlpWp36GA86PJPOWwslLplqmaVJ8tzFRZVoWiZVYbxSetOZ7DlLqZFPNcbn1nj96sxA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "slothlet": "bin/slothlet.mjs" }, @@ -698,6 +700,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -706,7 +709,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -3756,6 +3758,28 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 9b9a1b3..4a3930c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "./transport/process": "./src/transport/process.mjs", "./transport/websocket": "./src/transport/websocket.mjs", "./schemas/*": "./schemas/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./testing": "./src/testing/conformance.mjs" }, "scripts": { "build": "echo \"no build step - plain ESM package; stub kept because the reusable coverage-badge job runs npm run build unconditionally\"", @@ -67,12 +68,19 @@ "format:check": "prettier --config .configs/.prettierrc --check ." }, "peerDependencies": { - "@cldmv/slothlet": ">=3.14.0" + "@cldmv/slothlet": ">=3.14.0", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + } }, "devDependencies": { "@cldmv/eslint-plugin-jsonv": "^1.0.3", "@cldmv/jsonv": "^1.0.2", "@cldmv/prettier-plugin-jsonv": "^1.0.1", + "@cldmv/slothlet": "^3.14.0", "@cldmv/vitest-runner": "^1.2.0", "@eslint/css": "^1.4.0", "@eslint/js": "^10.0.1", @@ -82,6 +90,7 @@ "eslint": "^10.9.0", "globals": "^17.11.0", "prettier": "^3.9.6", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "ws": "^8.21.3" } } diff --git a/schemas/frame.schema.json b/schemas/frame.schema.json index a9b207e..48f65e3 100644 --- a/schemas/frame.schema.json +++ b/schemas/frame.schema.json @@ -1,28 +1,53 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/CLDMV/slothlet-vine/schemas/frame.schema.json", - "title": "slothlet-vine frame (v1, DRAFT)", - "description": "The wire frame exchanged over a Channel. DRAFT — finalized with the spike.", + "title": "slothlet-vine frame (v1)", + "description": "The wire frames exchanged over a Channel. Unknown frame types are ignored by receivers (forward compatibility).", "oneOf": [ + { + "type": "object", + "required": ["type", "v", "leaves"], + "properties": { + "type": { "const": "surface" }, + "v": { "const": 1 }, + "leaves": { "type": "array", "items": { "type": "string" } } + } + }, { "type": "object", "required": ["type", "callId", "path", "args"], "properties": { "type": { "const": "call" }, "callId": { "type": "string" }, - "path": { "type": "string", "description": "dotted leaf path, e.g. exts.foo.bar" }, - "args": { "type": "array" }, - "context": { "type": "object" } + "path": { "type": "string", "description": "dotted leaf path exactly as served, e.g. exts.pdfViewer.open" }, + "args": { "type": "array" } } }, { "type": "object", "required": ["type", "callId"], "properties": { - "type": { "enum": ["result", "error"] }, + "type": { "const": "result" }, + "callId": { "type": "string" }, + "value": {} + } + }, + { + "type": "object", + "required": ["type", "callId", "error"], + "properties": { + "type": { "const": "error" }, "callId": { "type": "string" }, - "value": {}, - "error": { "type": "object" } + "error": { + "type": "object", + "required": ["name", "message"], + "properties": { + "name": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "stack": { "type": "string" } + } + } } } ] diff --git a/src/grow.mjs b/src/grow.mjs new file mode 100644 index 0000000..aec9a24 --- /dev/null +++ b/src/grow.mjs @@ -0,0 +1,391 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /src/grow.mjs + * + * The growing end of a vine: take the far side's leaf manifest and mount one forwarding stub per + * leaf at the IDENTICAL dotted path in the local instance, so a caller writing + * `self.exts.pdfViewer.open()` cannot tell which process the implementation lives in. + * + * ## Why identical paths, and what that buys (probed against @cldmv/slothlet 3.14.0) + * + * Stubs are mounted one leaf at a time with the BARE-FUNCTION form, + * `api.slothlet.api.add(fullPath, stub, { moduleID })`, all sharing ONE moduleID per link: + * + * - the recorded permission identity is then the exact path — a rule targeting `far.ns.leaf` + * matched a stub mounted that way, verified — which is the whole point: **slothlet's own + * permission system gates a stub exactly as it gates a real leaf, and a denied call never runs + * the stub body, so it never reaches the wire**; + * - many per-leaf adds may share one moduleID, and a single `remove(moduleID)` unmounts all of them + * (and prunes the namespaces they created) — as long as the id is hyphenated, which is a real and + * silent trap documented at the `moduleID` line below; + * - `remove()` of an id that was never mounted resolves quietly, so close is safe to call twice. + * + * Two behaviours a consumer should know about, both verified rather than assumed: + * + * - **Permission gating covers module→leaf calls, not host→leaf calls.** A call made through the + * bound object `slothlet()` returned carries the host's own standing and is never checked; that + * is slothlet's documented host carve-out, not a vine gap. Rules bite when a MODULE calls the + * stub (`self.far.ns.leaf()` → `PERMISSION_DENIED`). + * - **A path already occupied locally is not overwritten.** slothlet's collision handling keeps the + * incumbent and the add is a silent no-op unless `forceOverwrite` is passed — which a vine never + * does, because clobbering local reality with a remote's idea of the tree is not a trade worth + * making. An occupied path is therefore not mounted at all: it is reported on `link.collisions` + * and stays off `link.leaves`, so the link never claims to forward a path the incumbent answers. + * + * The same respect for local reality governs teardown, where it is easy to get backwards: a path the + * vine mounted may have been taken over since, and `close()` removes only what the link still OWNS. + * See the note on `close()`. + */ +import { CODES, VineError, fromWire } from "./lib/errors.mjs"; +import { callFrame, findFunctionArg, isSafePath, parseFrame } from "./lib/frame.mjs"; +import { PendingTable, assertApi, assertChannel, makeNonce, onCloseSafe } from "./lib/link.mjs"; + +/** Default per-call settle budget, in ms. @type {number} */ +export const DEFAULT_BUDGET_MS = 30_000; + +/** + * Grow a vine from this instance to the far tree on `channel`: await the far side's `surface` frame, + * mount a forwarding stub per leaf, and return the live link. + * + * @param {object} api - The local slothlet instance (the object `slothlet()` returned). + * @param {import("./index.mjs").Channel} channel - The transport seam. + * @param {object} [options] + * @param {number} [options.budgetMs=30000] - Per-call settle budget; expiry settles that call with + * `VINE_BUDGET` and a later result frame for it is dropped (settle-once). + * @param {number} [options.handshakeMs] - Budget for the `surface` frame itself; defaults to + * `budgetMs`. **Addition to `docs/DESIGN.md`**, which specifies no handshake deadline: without one + * a far side that never publishes leaves would hang `await grow(...)` forever, which contradicts + * the design's own "pending calls NEVER hang" rule. `Infinity` is the explicit opt-out and waits + * indefinitely; anything else that is not a positive finite number (`null`, `0`, `-1`, `NaN`, a + * string) falls back to the default rather than quietly meaning "no deadline" — the same reading + * `budgetMs` gets, and the safe one, since the failure mode of a missing deadline is a `grow()` + * that never settles. + * @param {string[]} [options.paths] - Optional dotted prefixes; only far leaves at or under one of + * them are mounted. A local defence in depth — the serving side filters too, but a grow should not + * have to trust that it did. Same fail-closed reading as + * {@link import("./serve.mjs").serve}: an array with no usable prefix mounts nothing; a non-array + * value is ignored. + * @returns {Promise<{ id: string, leaves: string[], skipped: string[], collisions: string[], close: () => Promise, closed: Promise<{reason: string, info?: object}> }>} + * The live link. The three path lists are DISJOINT and together account for every leaf the far + * side published: `leaves` are the paths actually mounted and forwarding; `skipped` are far leaves + * refused locally (unsafe path, outside `paths`, rejected by `add()`, or published after the link + * had already ended); `collisions` are paths the local instance already occupied, which are NOT + * mounted — the incumbent keeps answering there and the far leaf is unreachable through this link. + * @throws {TypeError} When `api` is not a slothlet instance or `channel` is not a Channel. + * @throws {VineError} `VINE_GONE` when the channel closes before the surface arrives, `VINE_BUDGET` + * when the handshake budget elapses first. + * + * @example + * const link = await grow(api, channel, { budgetMs: 5000 }); + * await api.exts.pdfViewer.open("a.pdf"); // runs on the far side + * await link.close(); // stubs unmounted, pending calls settle VINE_CLOSED + */ +export async function grow(api, channel, options = {}) { + assertChannel(channel, "grow"); + assertApi(api, "grow", ["add", "remove"]); + + const budgetMs = Number.isFinite(options.budgetMs) && options.budgetMs > 0 ? Number(options.budgetMs) : DEFAULT_BUDGET_MS; + const handshakeMs = handshakeBudget(options.handshakeMs, budgetMs); + const prefixes = Array.isArray(options.paths) ? options.paths.filter((p) => typeof p === "string" && p.length > 0) : null; + + const nonce = makeNonce(); + // The separator is a HYPHEN, and that is load-bearing. Probed on @cldmv/slothlet 3.14.0: a + // moduleID containing a COLON (`vine:`) is accepted by `add()` but is then silently unknown + // to `remove()` — the call resolves, reports nothing, and every stub stays mounted AND callable. + // A hyphenated id removes cleanly. `close()` verifies the outcome regardless (see below). + const moduleID = `vine-${nonce}`; + const pending = new PendingTable(nonce); + + /** @type {{ closed: boolean, gone: boolean }} The link's terminal state; both are one-way. */ + const state = { closed: false, gone: false }; + + // The receive handler has to be registered BEFORE the handshake promise exists, because a + // synchronous transport can deliver the surface frame during `onMessage()` itself. So the two + // handshake outcomes are CAPTURED first and replayed into the promise when it is created — + // without this, an early surface is swallowed by a placeholder and `await grow(...)` never + // settles at all (the handshake timer sees the handshake as already settled and stands down). + /** @type {{ surface: object|null, error: Error|null }} */ + const captured = { surface: null, error: null }; + /** @type {(surface: {leaves: string[], unsafe: string[]}) => void} */ + let onSurface = (frame) => { + captured.surface = frame; + }; + /** @type {(err: Error) => void} */ + let onSurfaceFailed = (err) => { + captured.error = err; + }; + let surfaceSettled = false; + + let resolveClosed; + /** @type {Promise<{reason: string, info?: object}>} */ + const closedPromise = new Promise((resolve) => { + resolveClosed = resolve; + }); + let closedResolved = false; + + /** + * Resolve `link.closed` exactly once. + * @param {{reason: string, info?: object}} outcome - Why the link ended. + * @returns {void} + */ + function finish(outcome) { + if (closedResolved) return; + closedResolved = true; + resolveClosed(outcome); + } + + channel.onMessage((message) => { + // Never throw into the transport (Channel contract). + try { + const frame = parseFrame(message); + if (frame === null) return; + if (frame.type === "surface") { + if (surfaceSettled) return; // v1 publishes once; a re-publication is not a re-mount. + surfaceSettled = true; + onSurface(frame); + return; + } + // Frames may arrive after a local close, or for an already-settled call (a result racing a + // budget expiry). PendingTable drops both — settle-once is enforced there, not here. + if (frame.type === "result") pending.resolve(frame.callId, frame.value); + else if (frame.type === "error") pending.reject(frame.callId, fromWire(frame.error)); + } catch { + // Defensive: parseFrame is total and the settle path cannot throw. + } + }); + + onCloseSafe(channel, (info) => { + if (state.gone || state.closed) return; + state.gone = true; + pending.settleAll(CODES.GONE, "slothlet-vine: the far side of the link is gone"); + if (!surfaceSettled) { + surfaceSettled = true; + onSurfaceFailed(new VineError(CODES.GONE, "slothlet-vine: the channel closed before the far side published its surface")); + } + finish({ reason: "gone", info }); + }); + + const surface = await new Promise((resolve, reject) => { + onSurface = resolve; + onSurfaceFailed = reject; + if (captured.error) return reject(captured.error); + if (captured.surface) return resolve(captured.surface); + if (Number.isFinite(handshakeMs) && handshakeMs > 0) { + const timer = setTimeout(() => { + if (surfaceSettled) return; + surfaceSettled = true; + reject( + new VineError(CODES.BUDGET, `slothlet-vine: no surface frame within the ${handshakeMs}ms handshake budget`, { + budgetMs: handshakeMs + }) + ); + }, handshakeMs); + if (timer && typeof timer.unref === "function") timer.unref(); + } + }); + + /** @type {string[]} */ + const mounted = []; + /** @type {string[]} */ + const skipped = [...surface.unsafe]; + /** @type {string[]} */ + const collisions = []; + + for (const path of surface.leaves) { + // The far side can die mid-mount — `add()` is async and a surface of any size yields to the + // event loop repeatedly. Mounting the rest would publish stubs for a link that is already over; + // every one of them would refuse with VINE_GONE anyway, so stop and report them as skipped. + if (state.gone || state.closed) { + skipped.push(path); + continue; + } + // Re-validate locally. The serving side filters `slothlet.**` and prototype-walking segments, + // but "the far side already checked" is not a security property. + if (!isSafePath(path) || (prefixes && !prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}.`)))) { + skipped.push(path); + continue; + } + // An occupied path is NOT mounted, and mounting it anyway would be a lie in two directions: + // slothlet keeps the incumbent (the add is a silent no-op without `forceOverwrite`, which a vine + // never passes), so the far leaf is unreachable at that path AND `link.leaves` — documented as + // "the paths actually mounted" — would claim it forwards. Recorded on `collisions` only. + if (resolves(api, path)) { + collisions.push(path); + continue; + } + try { + await api.slothlet.api.add(path, makeStub(path), { moduleID }); + mounted.push(path); + } catch { + // slothlet refuses a reserved or otherwise invalid mount path (INVALID_CONFIG_API_PATH_INVALID). + // One bad leaf must not abort a link that is otherwise fine. + skipped.push(path); + } + } + + /** + * Build the forwarding stub for one leaf. Reached ONLY when slothlet has already allowed the call + * — permission denial happens in the wrapper, before this body runs. + * @param {string} path - The dotted leaf path. + * @returns {(...args: unknown[]) => Promise} The mountable async stub. + */ + function makeStub(path) { + return async function vineStub(...args) { + if (state.gone) { + throw new VineError(CODES.GONE, `slothlet-vine: '${path}' is unreachable — the far side is gone`, { path }); + } + if (state.closed) { + throw new VineError(CODES.CLOSED, `slothlet-vine: '${path}' is unreachable — the link is closed`, { path }); + } + const functionAt = findFunctionArg(args); + if (functionAt !== null) { + throw new VineError(CODES.DATA_ONLY, `slothlet-vine: '${path}' was passed a function at ${functionAt} — the vine is data-only`, { + path, + location: functionAt + }); + } + const callId = pending.nextCallId(); + const settled = pending.open(callId, { path, budgetMs }); + try { + channel.send(callFrame(callId, path, args)); + } catch (err) { + // The frame could not be handed to the transport — an un-cloneable argument the data-only + // scan cannot see (a getter that returns a function, a Proxy hiding its keys), or a dead + // socket. Settle now rather than leaving the entry to time out on its budget. + pending.reject( + callId, + new VineError(CODES.BAD_FRAME, `slothlet-vine: call to '${path}' could not be sent: ${err?.message ?? String(err)}`, { + path, + callId + }) + ); + } + return settled; + }; + } + + return { + id: moduleID, + leaves: mounted, + skipped, + collisions, + closed: closedPromise, + /** + * Tear the link down locally: unmount every stub and settle every in-flight call with + * `VINE_CLOSED`. Idempotent. Like {@link import("./serve.mjs").serve}'s close, it does NOT + * close the channel — the transport belongs to whoever created it. + * + * The unmount is VERIFIED rather than assumed. A `remove()` that silently unmounts nothing is + * a real failure mode (see the moduleID note above), and the difference between "the link is + * closed" and "the paths are gone" is exactly what a consumer relies on here — so any path + * that survives the module-scoped removal is removed again by path. + * + * That fallback is **ownership-scoped**, and it has to be. A path this vine mounted can + * legitimately have been taken over since — a local module claiming it with `forceOverwrite` + * and its own moduleID — and a blind `remove(path)` would then delete local reality on the way + * out. slothlet's own `remove(moduleID)` gets this right (the takeover survives it); the vine + * must not undo that. So ownership is read from the loader's records BEFORE the module-scoped + * removal (after it, the id is unknown and `leaves()` throws) and only still-owned paths are + * removed individually. + * + * Identity comparison was probed as the alternative and REJECTED: slothlet keeps the same + * wrapper function object at a path across a `forceOverwrite` takeover — the resolved value is + * `===` what it was while the vine owned it, yet calling it now runs the local implementation. + * Identity therefore cannot tell owner from usurper; the records can. + * @returns {Promise} Resolves once the stubs are unmounted. + */ + async close() { + if (state.closed) return; + state.closed = true; + try { + const owned = await ownedPaths(api, moduleID, mounted); + await api.slothlet.api.remove(moduleID); + for (const path of mounted) { + if (!owned.has(path) || !resolves(api, path)) continue; + try { + await api.slothlet.api.remove(path); + } catch { + // Best effort: the link is closing and every stub already refuses to dispatch. + } + } + } finally { + // Release the receive closure: it captures `api` and the pending table, and the channel may + // well outlive the link (the transport belongs to whoever created it). Nothing is expected + // on it any more — every pending call is settled on the next line. + try { + channel.onMessage(() => {}); + } catch { + // A transport that refuses a re-registration after close keeps the old handler; harmless. + } + pending.settleAll(CODES.CLOSED, "slothlet-vine: the link was closed"); + finish({ reason: "closed" }); + } + } + }; +} + +/** + * Which of `paths` does the link's module still OWN, according to the loader's own records? + * + * Must be asked while the module is still mounted: a successful `remove(moduleID)` makes the id + * unknown and `leaves(id)` throws `API_LEAVES_UNKNOWN_MODULE`. A path the vine mounted and a local + * module later took over (`forceOverwrite`, its own moduleID) is reassigned in the records and so + * is absent from the answer — which is exactly the distinction the teardown needs. + * + * When ownership cannot be established at all — `leaves()` absent (grow only requires `add` and + * `remove`), throwing, or answering something that is not a record list — every mounted path counts + * as owned. That is the pre-existing behaviour and it keeps the fallback's real purpose intact: a + * `remove(moduleID)` that silently unmounts NOTHING must still leave no callable stub behind. + * @param {object} api - The slothlet instance. + * @param {string} moduleID - The link's module id. + * @param {string[]} paths - The paths this link mounted. + * @returns {Promise>} The subset still owned by the link (or all of them, when unknown). + */ +async function ownedPaths(api, moduleID, paths) { + try { + const records = await api.slothlet.api.leaves(moduleID, { details: true }); + if (!Array.isArray(records)) return new Set(paths); + const owned = new Set(records.map((record) => record?.path).filter((path) => typeof path === "string")); + return new Set(paths.filter((path) => owned.has(path))); + } catch { + return new Set(paths); + } +} + +/** + * Normalize the handshake deadline. Mirrors `budgetMs`'s reading — anything that is not a usable + * positive number falls back to the default rather than silently meaning "no deadline", because a + * missing deadline here is a `grow()` that never settles. `Infinity` is the one explicit opt-out. + * @param {unknown} value - The caller's `handshakeMs`. + * @param {number} fallback - The default (the call budget). + * @returns {number} A positive millisecond budget, or `Infinity` to wait indefinitely. + */ +function handshakeBudget(value, fallback) { + // Number-only, like `budgetMs`: a coercion here could itself throw (a Symbol, a hostile + // `valueOf`), and `grow()` failing with a TypeError out of an options read is not a useful + // diagnosis of "that isn't a number". + if (typeof value !== "number") return fallback; + if (value === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY; + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +/** + * Does a dotted path already resolve to something on the local api? Used only to REPORT collisions — + * the mount itself never overwrites. Any failure while probing counts as "no collision": this is + * diagnostics, and a lazy namespace that objects to being read is not a reason to fail a link. + * @param {object} api - The slothlet instance. + * @param {string} path - Validated dotted path. + * @returns {boolean} True when something already lives at that path. + */ +function resolves(api, path) { + try { + let node = api; + for (const segment of path.split(".")) { + if (node === null || (typeof node !== "object" && typeof node !== "function")) return false; + node = node[segment]; + } + return node !== undefined; + } catch { + return false; + } +} diff --git a/src/index.mjs b/src/index.mjs index 7410761..f1a6652 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -3,41 +3,45 @@ * @Filename: /src/index.mjs * * Vines between slothlet api trees — location-transparent forwarding leaves over an injected - * Channel. PRE-IMPLEMENTATION SCAFFOLD: the API surface below is the design contract; bodies land - * with the browser spike. See README.md. + * Channel. One end {@link serve}s its callable leaves; the other {@link grow}s a forwarding stub per + * leaf at the identical dotted path, so a caller cannot tell which side of the boundary the + * implementation lives on, and slothlet's own permission system gates the stub exactly as it gates + * a real leaf. + * + * The core NEVER imports a transport: it consumes only the {@link Channel} interface below. + * Built-in transports live under `@cldmv/slothlet-vine/transport/*`; a consumer may pass any object + * satisfying the contract. See `docs/DESIGN.md` for the normative protocol and + * `schemas/frame.schema.json` for the wire frames. + * + * @example + * import { grow, serve } from "@cldmv/slothlet-vine"; + * import { createPair } from "@cldmv/slothlet-vine/transport/loopback"; + * + * const [near, far] = createPair(); + * await serve(workerApi, far, { paths: ["exts"] }); + * const link = await grow(hostApi, near, { budgetMs: 5000 }); + * await hostApi.exts.pdfViewer.open("a.pdf"); // executes on the serving instance + * await link.close(); */ /** * The transport seam every vine rides on. Implement this (plus a capability declaration) to plug in - * a custom transport; the bridge consumes ONLY this interface. + * a custom transport; the core consumes ONLY this interface. + * + * `onMessage` and `onClose` are single-handler registrations — last write wins. Handlers must never + * throw into the transport (the core wraps its own), and frames may arrive after `close()` was + * called locally; the core tolerates and ignores them. + * * @typedef {object} Channel - * @property {(message: unknown) => void} send - * @property {(handler: (message: unknown) => void) => void} onMessage - * @property {() => void} [close] + * @property {(message: object) => void} send - Deliver one frame to the far side. + * @property {(handler: (message: object) => void) => void} onMessage - Register the (single) receive handler. + * @property {() => void} [close] - Tear the transport down. + * @property {(handler: (info?: object) => void) => void} [onClose] - Register a (single) far-side-death/closure handler. + * @property {{ structuredClone?: boolean, codec?: "none"|"json", buffersUntilHandler?: boolean }} [capabilities] + * What the medium preserves, how it encodes, and whether frames sent before a handler is + * registered are buffered (true) or may be dropped (absent/false). */ -const NOT_IMPLEMENTED = (name) => { - throw new Error(`@cldmv/slothlet-vine: ${name} is not implemented yet (pre-release scaffold — see the repo README)`); -}; - -/** - * Grow a vine FROM this instance TO a far tree: mount forwarding-stub leaves (from the far side's - * leaf manifest) into `api` over `channel`, permission-gated by slothlet itself. - * @param {object} api — the local slothlet instance - * @param {Channel} channel - * @param {object} [options] - */ -export function grow(___api, ___channel, ___options) { - NOT_IMPLEMENTED("grow"); -} - -/** - * Serve this instance's leaves TO a far tree: answer call frames arriving on `channel` by invoking - * the real leaves (re-checking permissions on this side), and publish the leaf manifest. - * @param {object} api — the local slothlet instance - * @param {Channel} channel - * @param {object} [options] - */ -export function serve(___api, ___channel, ___options) { - NOT_IMPLEMENTED("serve"); -} +export { grow, DEFAULT_BUDGET_MS } from "./grow.mjs"; +export { serve } from "./serve.mjs"; +export { CODES, VineError, VineRemoteError, fromWire, toWire } from "./lib/errors.mjs"; diff --git a/src/lib/errors.mjs b/src/lib/errors.mjs new file mode 100644 index 0000000..252671f --- /dev/null +++ b/src/lib/errors.mjs @@ -0,0 +1,199 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /src/lib/errors.mjs + * + * The vine error taxonomy and its wire form. Every failure a vine produces on its own account is a + * {@link VineError} carrying a stable `.code` from {@link CODES}; a failure produced by the FAR + * side's application code crosses as data and is re-thrown locally as a {@link VineRemoteError} + * that keeps the original `name` / `message` / `code` and carries the far stack as `.remoteStack`. + * + * The one thing a far side may NOT dictate is a vine code: a remote `code` in the reserved `VINE_*` + * namespace is remapped to `VINE_REMOTE` (original on `.remoteCode`), so no peer can make a + * consumer's `err.code === CODES.CLOSED` link-state branch fire. See {@link VineRemoteError}. + * + * See `docs/DESIGN.md` § "API surface" for the normative code list. + */ + +/** + * Stable machine codes carried on `.code`. `REMOTE` is the fallback for a remote application error + * whose own error carried no `code` — every other entry is a vine-produced condition. + * @type {Readonly>} + */ +export const CODES = Object.freeze({ + /** The far side died / its channel closed with the call still in flight. */ + GONE: "VINE_GONE", + /** The per-call settle budget elapsed before a terminal frame arrived. */ + BUDGET: "VINE_BUDGET", + /** `link.close()` was called with the call still in flight, or a call was made after close. */ + CLOSED: "VINE_CLOSED", + /** A function-valued argument was found in the argument graph — the vine is data-only in v1. */ + DATA_ONLY: "VINE_DATA_ONLY", + /** A frame could not be parsed, or an outbound frame could not be handed to the transport. */ + BAD_FRAME: "VINE_BAD_FRAME", + /** A call named a path that is not in the served surface (re-validated serve-side every call). */ + NO_LEAF: "VINE_NO_LEAF", + /** + * `.code` for a remote application error that carried none of its own — and for one whose code + * was in the reserved `VINE_*` namespace, which is never adopted from the wire (the far side's + * spelling is kept on `.remoteCode`). See {@link VineRemoteError}. + */ + REMOTE: "VINE_REMOTE" +}); + +/** + * Fields on a details object that may never be copied onto the error — they are owned by `Error` + * (or set by the constructor) and letting a caller/wire value overwrite them would make the error + * lie about itself. + * @type {Set} + */ +const RESERVED_DETAIL_KEYS = new Set(["name", "message", "stack", "code"]); + +/** + * A failure the vine itself produced. Always carries a `.code` from {@link CODES}; any extra + * `details` (typically `path`, `callId`, `budgetMs`) are copied on as own properties. + * @augments Error + */ +export class VineError extends Error { + /** + * @param {string} code - A {@link CODES} value. + * @param {string} message - Human-readable description. + * @param {Record} [details] - Extra own properties (reserved keys are ignored). + */ + constructor(code, message, details) { + super(message); + /** @type {string} */ + this.name = "VineError"; + /** @type {string} Stable machine code — branch on this, never on `message`. */ + this.code = code; + if (details && typeof details === "object") { + for (const key of Object.keys(details)) { + if (RESERVED_DETAIL_KEYS.has(key)) continue; + this[key] = details[key]; + } + } + } +} + +/** + * Codes reserved to the vine itself. A code matching this may never be adopted as `.code` from the + * wire — see {@link VineRemoteError}. + * @type {RegExp} + */ +const RESERVED_CODE = /^VINE_/; + +/** + * A far-side application error, re-thrown locally. It deliberately impersonates the original error: + * `.name` and `.message` are the remote's own, and so is `.code` — so existing + * `err.code === "E_THING"` checks keep working across the boundary. Because `.name` is the REMOTE + * name, use `instanceof VineRemoteError` (or `.remoteStack`) — not `.name` — to tell a forwarded + * error from a local one. + * + * ## The one code that is NOT adopted: `VINE_*` + * + * `.code` is adopted from the wire with a single exception — **any remote code in the reserved + * `VINE_*` namespace is remapped to `VINE_REMOTE`**, and the far side's own spelling is kept on + * `.remoteCode`. Without that, a far side (hostile, or merely running its own vine and forwarding a + * failure verbatim) could send `{ name: "VineError", code: "VINE_CLOSED" }` and the resulting error + * would satisfy `err instanceof VineError && err.code === CODES.CLOSED` — the documented way to + * branch on link state — and drive a consumer's teardown path from across the boundary. A vine + * link-state code means *this* link's state; it can only ever be produced locally. + * + * The remap is deliberately blind to which `VINE_*` code arrived, including ones the far side's own + * serve legitimately produced (`VINE_NO_LEAF`, `VINE_DATA_ONLY`): read `.remoteCode` to see what the + * far side said, and `.code` to know it came from over there. + * @augments VineError + */ +export class VineRemoteError extends VineError { + /** + * @param {{ name?: string, message?: string, code?: string, stack?: string }} wire - Wire error shape. + */ + constructor(wire) { + // Every read is guarded: `wire` is attacker-controlled data off the channel, and a throwing + // getter here would escape into the receive handler, where the call it was settling would be + // left pending until its budget expires — the exact hang the taxonomy exists to prevent. + const safe = wire && typeof wire === "object" ? wire : {}; + const remoteCode = readString(safe, "code"); + super(remoteCode === undefined || RESERVED_CODE.test(remoteCode) ? CODES.REMOTE : remoteCode, readString(safe, "message") ?? ""); + const name = readString(safe, "name"); + /** @type {string} The remote error's own `name` (NOT "VineRemoteError"). */ + this.name = name !== undefined && name !== "" ? name : "Error"; + /** @type {string|undefined} The far side's own `code`, verbatim — including a reserved `VINE_*` one. */ + this.remoteCode = remoteCode; + /** @type {string|undefined} The far side's stack, kept off `.stack` so the local trace stays local. */ + this.remoteStack = readString(safe, "stack"); + } +} + +/** + * Project any thrown value onto the wire error shape (`schemas/frame.schema.json` → error.error). + * Total: a thrown string, `null`, or an exotic object all produce a valid shape rather than throwing + * inside the error path. The stack crosses on purpose — a forwarded failure is otherwise unreadable + * on the growing side, which is where it surfaces. + * @param {unknown} err - The thrown value. + * @returns {{ name: string, message: string, code?: string, stack?: string }} Wire error. + */ +export function toWire(err) { + if (err === null || (typeof err !== "object" && typeof err !== "function")) { + // A thrown primitive IS the message — including the literal "null" / "undefined", which are + // more useful to a reader than an empty string would be. + return { name: "Error", message: err === null ? "null" : err === undefined ? "undefined" : safeString(err, "Error") }; + } + // Every read is guarded: `err` is whatever the leaf threw, and a throwing accessor here would + // replace a reportable failure with an unreportable one on the error path itself. + try { + /** @type {{ name: string, message: string, code?: string, stack?: string }} */ + const wire = { name: safeString(err.name, "Error"), message: safeString(err.message, "") }; + if (typeof err.code === "string") wire.code = err.code; + else if (typeof err.code === "number") wire.code = String(err.code); + if (typeof err.stack === "string") wire.stack = err.stack; + return wire; + } catch { + return { name: "Error", message: "" }; + } +} + +/** + * Rebuild a throwable from the wire error shape — the inverse of {@link toWire}. TOTAL: a hostile + * wire value (throwing getters, an unstringifiable primitive) still produces an error, because the + * caller this settles must never be left pending on a malformed rejection. + * @param {unknown} wire - Wire error (tolerated: anything). + * @returns {VineRemoteError} The re-thrown-shaped error. + */ +export function fromWire(wire) { + if (wire && typeof wire === "object") return new VineRemoteError(wire); + // Mirrors toWire's reading of a non-object throw: the value IS the message, and the literal + // "null" / "undefined" is more useful to a reader than an empty string. + return new VineRemoteError({ message: wire === null ? "null" : wire === undefined ? "undefined" : safeString(wire, "") }); +} + +/** + * Read one property as a string, tolerating a hostile source: a throwing getter, an exotic Proxy, or + * a non-string value all answer `undefined` instead of propagating. + * @param {object} source - The (untrusted) object to read from. + * @param {string} key - Property name. + * @returns {string|undefined} The string value, or undefined when absent / not a string / unreadable. + */ +function readString(source, key) { + try { + const value = source[key]; + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +/** + * Coerce a value to a string without ever invoking a hostile `toString` twice or throwing. + * @param {unknown} value - Candidate. + * @param {string} fallback - Used when `value` is absent or not coercible. + * @returns {string} A string. + */ +function safeString(value, fallback) { + if (typeof value === "string") return value; + if (value === undefined || value === null) return fallback; + try { + return String(value); + } catch { + return fallback; + } +} diff --git a/src/lib/frame.mjs b/src/lib/frame.mjs new file mode 100644 index 0000000..c57a9c2 --- /dev/null +++ b/src/lib/frame.mjs @@ -0,0 +1,239 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /src/lib/frame.mjs + * + * The wire frames (`schemas/frame.schema.json` is normative) plus the two guards that stand between + * a hostile or merely buggy far side and this process: {@link parseFrame}, which is TOTAL — it + * returns a typed frame or `null` and never throws, whatever junk arrives — and {@link isSafePath}, + * which refuses dotted paths that would let a remote surface listing write through + * `Object.prototype`. + * + * ## Why the path guard is not theoretical (probed against @cldmv/slothlet 3.14.0) + * + * `api.slothlet.api.add("__proto__.x", fn)` and `add("constructor.prototype.pwn", fn)` are both + * ACCEPTED by slothlet, and both land the function on `Object.prototype` — `({}).x` becomes that + * function process-wide. Since the leaf list in a `surface` frame comes from the FAR side, an + * unguarded `grow` would hand a remote peer prototype pollution for free. Slothlet does guard its + * own reserved roots (`slothlet` / `shutdown` / `destroy` are refused with + * `INVALID_CONFIG_API_PATH_INVALID`), so the gap is exactly the prototype chain — which is what + * {@link UNSAFE_SEGMENTS} closes. + */ +import { toWire } from "./errors.mjs"; + +/** The frame schema version carried on the `surface` frame. @type {number} */ +export const FRAME_VERSION = 1; + +/** + * Path segments that are never mountable. `__proto__` / `constructor` / `prototype` walk the + * prototype chain (see the file header); `slothlet` is the control plane, which is NEVER served or + * mounted, and `shutdown` / `destroy` are the instance's own teardown handles. + * @type {Set} + */ +export const UNSAFE_SEGMENTS = new Set(["__proto__", "constructor", "prototype", "slothlet", "shutdown", "destroy"]); + +/** + * The alphabet a segment must be drawn from: the ECMAScript **IdentifierName** production + * (`ID_Start`/`ID_Continue` plus `$`, `_` and the two zero-width joiners), which is exactly the set + * of names a JavaScript `export` can carry. + * + * It was `/^[\w$]+$/u` — ASCII-only — and that was wrong. slothlet sanitizes FILE and directory + * names onto an ASCII alphabet, but a leaf's name comes from its EXPORT name, which it does not + * touch: `export function café() {}` is a real, callable, `leaves()`-reported leaf whose path an + * ASCII-only guard silently refuses. A refusal there is not a security win — the segment never + * reaches a prototype key, which is what {@link UNSAFE_SEGMENTS} exists to stop — it just drops a + * legitimate leaf on the floor. Property lookup does no unicode normalization, so no member of this + * alphabet can collide with a reserved name that is not literally spelled that way. + * + * The joiners are written as escapes on purpose — as literal characters they are invisible in the + * source and read as a typo. `U+200C` = ZWNJ, `U+200D` = ZWJ, both legal in an IdentifierPart. + * @type {RegExp} + */ +const SAFE_SEGMENT = /^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/u; + +/** + * A path segment is mountable when it is a valid JavaScript identifier name (see + * {@link SAFE_SEGMENT}) and is not one of {@link UNSAFE_SEGMENTS}. Still deliberately narrower than + * "any string key": a name outside the identifier alphabet is not a path a slothlet instance could + * have produced, and accepting it would only widen the guard's own attack surface. + * @param {unknown} segment - Candidate segment. + * @returns {boolean} True when the segment may be mounted / invoked. + */ +export function isSafeSegment(segment) { + return typeof segment === "string" && SAFE_SEGMENT.test(segment) && !UNSAFE_SEGMENTS.has(segment); +} + +/** + * A dotted leaf path is safe when it is non-empty and every segment is. + * @param {unknown} path - Candidate dotted path (e.g. `exts.pdfViewer.open`). + * @returns {boolean} True when the whole path may be mounted / invoked. + */ +export function isSafePath(path) { + if (typeof path !== "string" || path.length === 0) return false; + const segments = path.split("."); + for (const segment of segments) if (!isSafeSegment(segment)) return false; + return true; +} + +/** + * Locate the first function anywhere in an argument graph. The vine is data-only in v1, so a + * function argument is refused AT THE EDGE with a named error rather than allowed to reach the + * transport codec, where it would surface as an unattributed clone crash. + * + * Cycle-safe, and it never invokes user code: accessor properties are skipped rather than read + * (a getter that returns a function is not detectable without running it, and running it is worse). + * `Reflect.ownKeys` is used so symbol-keyed and non-enumerable members are covered. + * @param {unknown[]} args - The call's arguments. + * @returns {string|null} A human-readable location (`arg[0].onDone`) or null when the graph is data-only. + */ +export function findFunctionArg(args) { + if (!Array.isArray(args)) return "arguments"; + const seen = new Set(); + + /** + * @param {unknown} value - Current node. + * @param {string} where - Location of `value` in the graph. + * @returns {string|null} Location of the first function found, else null. + */ + function walk(value, where) { + if (typeof value === "function") return where; + if (value === null || typeof value !== "object") return null; + if (seen.has(value)) return null; + seen.add(value); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const hit = walk(value[i], `${where}[${i}]`); + if (hit) return hit; + } + return null; + } + // Map/Set carry their payload in iteration order, not as own properties. + if (value instanceof Map) { + for (const [key, item] of value) { + const hit = walk(item, `${where}.get(${String(key)})`) || walk(key, `${where}.key`); + if (hit) return hit; + } + return null; + } + if (value instanceof Set) { + let i = 0; + for (const item of value) { + const hit = walk(item, `${where}.item[${i++}]`); + if (hit) return hit; + } + return null; + } + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || typeof descriptor.get === "function") continue; + const hit = walk(descriptor.value, `${where}.${String(key)}`); + if (hit) return hit; + } + return null; + } + + for (let i = 0; i < args.length; i++) { + const hit = walk(args[i], `arg[${i}]`); + if (hit) return hit; + } + return null; +} + +/** + * Build the `surface` frame — the served leaf manifest, sent once when a serve starts. + * @param {string[]} leaves - Dotted callable paths being served. + * @returns {{ type: "surface", v: number, leaves: string[] }} The frame. + */ +export function surfaceFrame(leaves) { + return { type: "surface", v: FRAME_VERSION, leaves: [...leaves] }; +} + +/** + * Build a `call` frame. + * @param {string} callId - Correlation id, unique per grow-side link. + * @param {string} path - The dotted leaf path exactly as served. + * @param {unknown[]} args - Data-only arguments. + * @returns {{ type: "call", callId: string, path: string, args: unknown[] }} The frame. + */ +export function callFrame(callId, path, args) { + return { type: "call", callId, path, args }; +} + +/** + * Build a `result` frame. `value` is always present (possibly `undefined`) so the receiver never has + * to distinguish "no value" from "undefined value". + * @param {string} callId - Correlation id being settled. + * @param {unknown} value - The leaf's resolved value. + * @returns {{ type: "result", callId: string, value: unknown }} The frame. + */ +export function resultFrame(callId, value) { + return { type: "result", callId, value }; +} + +/** + * Build an `error` frame from a thrown value. + * @param {string} callId - Correlation id being settled. + * @param {unknown} err - Whatever the leaf threw. + * @returns {{ type: "error", callId: string, error: object }} The frame. + */ +export function errorFrame(callId, err) { + return { type: "error", callId, error: toWire(err) }; +} + +/** + * TOTAL, tolerant frame validator. Returns a normalized frame or `null`; it NEVER throws, and an + * unknown `type` is `null` rather than an error — forward compatibility is a receiver obligation + * (`docs/DESIGN.md` § Frames). + * + * Normalization worth knowing about: + * - a `surface` frame keeps only leaves that pass {@link isSafePath}; the rejects are reported on + * `.unsafe` so a caller can log the divergence instead of silently serving less than it thinks; + * - a `call` frame with an unsafe `path` is rejected outright (`null`) — there is no safe partial + * reading of "invoke this"; + * - `args` is copied into a fresh array, so a later mutation of the received object cannot change + * what is about to be invoked. + * @param {unknown} message - Whatever arrived on the channel. + * @returns {object|null} A normalized frame, or null when the message is not a frame this version handles. + */ +export function parseFrame(message) { + try { + if (message === null || typeof message !== "object" || Array.isArray(message)) return null; + const type = message.type; + if (typeof type !== "string") return null; + + if (type === "surface") { + if (message.v !== FRAME_VERSION) return null; + if (!Array.isArray(message.leaves)) return null; + /** @type {string[]} */ + const leaves = []; + /** @type {string[]} */ + const unsafe = []; + for (const leaf of message.leaves) { + if (isSafePath(leaf)) leaves.push(leaf); + else unsafe.push(typeof leaf === "string" ? leaf : String(leaf)); + } + return { type: "surface", v: FRAME_VERSION, leaves, unsafe }; + } + + const callId = message.callId; + if (typeof callId !== "string" || callId.length === 0) return null; + + if (type === "call") { + if (!isSafePath(message.path)) return null; + if (!Array.isArray(message.args)) return null; + return { type: "call", callId, path: message.path, args: [...message.args] }; + } + if (type === "result") { + return { type: "result", callId, value: message.value }; + } + if (type === "error") { + const error = message.error; + if (error === null || typeof error !== "object") return null; + return { type: "error", callId, error }; + } + return null; + } catch { + // A hostile object (throwing getter on `type`, `Array.isArray`-defeating proxy, …) is junk, + // not an exception the link should propagate. + return null; + } +} diff --git a/src/lib/link.mjs b/src/lib/link.mjs new file mode 100644 index 0000000..fa85223 --- /dev/null +++ b/src/lib/link.mjs @@ -0,0 +1,207 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /src/lib/link.mjs + * + * The correlation machinery shared by every link: a settle-once pending table with per-call budget + * timers and a bulk `settleAll` for the two ways a link ends (local close → `VINE_CLOSED`, far-side + * death → `VINE_GONE`). + * + * The invariant this file exists to hold is **settle-once**: a callId is resolved or rejected + * exactly once — by a `result`, an `error`, its budget timer, or a bulk settle — and every later + * terminal for it is dropped. Without it, a late `result` arriving after a budget expiry would + * "un-fail" a call the caller has already handled as failed. + * + * The second invariant is that a pending call NEVER hangs: every entry is armed with a timer, so + * even a far side that answers nothing and never closes still settles the caller. + */ +import { CODES, VineError } from "./errors.mjs"; + +/** Fallback discriminator when `crypto.randomUUID` is unavailable. @type {number} */ +let linkSequence = 0; + +/** + * A per-link nonce. `crypto.randomUUID()` where available (node ≥ 19, every modern browser); the + * fallback still mixes a PROCESS-LOCAL counter with the clock, so two links created in the same + * process cannot collide even if the random component did. + * @returns {string} An opaque nonce. + */ +export function makeNonce() { + const webcrypto = globalThis.crypto; + if (webcrypto && typeof webcrypto.randomUUID === "function") return webcrypto.randomUUID(); + return `${Date.now().toString(36)}-${(linkSequence++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * The settle-once correlation table for one link. + */ +export class PendingTable { + /** + * @param {string} nonce - The link nonce; callIds are `#` so ids are unique per + * link AND unattributable to another link's counter. + */ + constructor(nonce) { + /** @type {string} */ + this.nonce = nonce; + /** @type {number} Monotonic counter — never `Math.random` per call. */ + this._seq = 0; + /** @type {Map} */ + this._pending = new Map(); + } + + /** @returns {number} Currently-unsettled call count (tests + telemetry). */ + get size() { + return this._pending.size; + } + + /** @returns {string} The next correlation id for this link. */ + nextCallId() { + return `${this.nonce}#${++this._seq}`; + } + + /** + * Register a pending call and arm its budget timer. + * @param {string} callId - Correlation id from {@link nextCallId}. + * @param {object} params + * @param {string} params.path - The leaf path, for the budget error message. + * @param {number} params.budgetMs - Settle budget in ms; a non-finite or non-positive value arms no + * timer (an explicit "no deadline" opt-out — callers should pass a positive budget). + * @returns {Promise} Settles exactly once. + */ + open(callId, { path, budgetMs }) { + return new Promise((resolve, reject) => { + let timer = null; + if (Number.isFinite(budgetMs) && budgetMs > 0) { + timer = setTimeout(() => { + this.reject( + callId, + new VineError(CODES.BUDGET, `slothlet-vine: call to '${path}' exceeded its ${budgetMs}ms budget`, { path, callId, budgetMs }) + ); + }, budgetMs); + // Never hold the event loop open for an in-flight forwarded call; the caller's own + // awaiting keeps the process alive for as long as it actually cares. + if (timer && typeof timer.unref === "function") timer.unref(); + } + this._pending.set(callId, { resolve, reject, timer, path }); + }); + } + + /** + * @param {string} callId - Correlation id. + * @returns {boolean} True while the call is unsettled. + */ + has(callId) { + return this._pending.has(callId); + } + + /** + * Settle a call with a value. A duplicate terminal is dropped. + * @param {string} callId - Correlation id. + * @param {unknown} value - The resolved value. + * @returns {boolean} True when this call settled the entry. + */ + resolve(callId, value) { + const entry = this._take(callId); + if (!entry) return false; + entry.resolve(value); + return true; + } + + /** + * Settle a call with an error. A duplicate terminal is dropped. + * @param {string} callId - Correlation id. + * @param {unknown} err - The rejection reason. + * @returns {boolean} True when this call settled the entry. + */ + reject(callId, err) { + const entry = this._take(callId); + if (!entry) return false; + entry.reject(err); + return true; + } + + /** + * Force-settle every pending call — the link ended. Drains the table BEFORE rejecting so a + * synchronous `catch` handler that re-enters cannot see a half-drained table. + * @param {string} code - A {@link CODES} value for the whole batch. + * @param {string} message - Human-readable reason. + * @returns {number} How many calls were settled. + */ + settleAll(code, message) { + const entries = [...this._pending.entries()]; + this._pending.clear(); + for (const [callId, entry] of entries) { + if (entry.timer) clearTimeout(entry.timer); + entry.reject(new VineError(code, message, { path: entry.path, callId })); + } + return entries.length; + } + + /** + * Remove and return a pending entry, clearing its timer — the single choke point that makes + * settling once-only. + * @param {string} callId - Correlation id. + * @returns {{ resolve: Function, reject: Function, timer: unknown, path: string }|null} The entry, or null when already settled. + */ + _take(callId) { + const entry = this._pending.get(callId); + if (!entry) return null; + this._pending.delete(callId); + if (entry.timer) clearTimeout(entry.timer); + return entry; + } +} + +/** + * Assert that a value satisfies the Channel contract's mandatory half (`send` + `onMessage`). + * Thrown as a `TypeError` rather than a `VineError`: this is a wiring mistake in the consumer's own + * code, not a link condition a caller could branch on. + * @param {unknown} channel - Candidate channel. + * @param {string} who - The calling function's name, for the message. + * @returns {void} + * @throws {TypeError} When the object does not satisfy the contract. + */ +export function assertChannel(channel, who) { + if (channel === null || typeof channel !== "object" || typeof channel.send !== "function" || typeof channel.onMessage !== "function") { + throw new TypeError(`@cldmv/slothlet-vine: ${who}() needs a Channel — an object with send(message) and onMessage(handler)`); + } +} + +/** + * Assert that a value looks like a live slothlet instance with runtime mutations available. + * @param {unknown} api - Candidate slothlet api. + * @param {string} who - The calling function's name, for the message. + * @param {string[]} needs - Required `api.slothlet.api.*` method names. + * @returns {void} + * @throws {TypeError} When the object is not a usable slothlet instance. + */ +export function assertApi(api, who, needs) { + const surface = api && typeof api === "object" ? api.slothlet?.api : undefined; + const missing = surface ? needs.filter((name) => typeof surface[name] !== "function") : needs; + if (missing.length > 0) { + throw new TypeError( + `@cldmv/slothlet-vine: ${who}() needs a slothlet instance exposing api.slothlet.api.{${missing.join(", ")}} ` + + `— is 'api' the object returned by slothlet(), with api.mutations enabled?` + ); + } +} + +/** + * Register a handler on `channel.onClose` if the transport offers one, wrapping it so a throwing + * handler can never propagate into transport code (Channel contract: "Handlers must never throw + * into the transport; the core wraps its handlers"). + * @param {object} channel - The channel. + * @param {(info?: object) => void} handler - The close handler. + * @returns {boolean} True when the transport supports close notification. + */ +export function onCloseSafe(channel, handler) { + if (typeof channel.onClose !== "function") return false; + channel.onClose((info) => { + try { + handler(info); + } catch { + // A consumer-visible failure here would be reported as a transport fault; the link is + // already ending, and every pending call is settled by the handler's first statements. + } + }); + return true; +} diff --git a/src/serve.mjs b/src/serve.mjs new file mode 100644 index 0000000..50377de --- /dev/null +++ b/src/serve.mjs @@ -0,0 +1,262 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /src/serve.mjs + * + * The serving end of a vine: publish this instance's callable leaves to the far side of a channel, + * then answer `call` frames by invoking the real leaf. + * + * ## Where the surface comes from, and why + * + * `docs/DESIGN.md` allows either enumerating from the loader's records + * (`api.slothlet.api.leaves`) or walking the live api object, and asks for the choice to be + * documented. This implementation uses the RECORDS. Both options were probed against + * @cldmv/slothlet 3.14.0: + * + * - **Walking the live object is wrong under `mode: "lazy"`.** An un-materialized namespace is a + * CALLABLE proxy with no own keys, so a walk of a lazy instance reports `deep` as a leaf and never + * sees `deep.tools.slow` at all. It also can't tell a namespace from a leaf without invoking + * materialization as a side effect of merely being served. + * - **`leaves(".", { details: true })` is complete under lazy** (it settles the owned subtree and + * answers from the loader's records) and it labels every path `namespace` / `function` / `data`, + * so data leaves — `export const answer = 42` — are excluded from a CALLABLE surface for free. + * Verified: a lazy instance answered `["deep.nested.more.x", "deep.tools.slow", "math.add"]`. + * + * The one thing records cannot do is enumerate the WHOLE tree. `leaves(".")` covers the base load + * only; runtime `api.slothlet.api.add()` mounts are module-scoped and there is no registry of + * mounted moduleIDs to iterate (`api.slothlet.api.modules` is the module-DISCOVERY helper, not a + * mount registry). That is what {@link serve}'s `modules` option is for: name the runtime mounts to + * include and their leaves are unioned in, still from the records. + * + * A second records quirk worth knowing: a mount made with the BARE-FUNCTION form + * (`add(path, fn, { moduleID })`) is recorded with `kind: "data"`, so it is absent from + * `leaves(id)`'s callable answer and present as `data` under `{ details: true }`. Mounts made with + * the `{ exports }` form are recorded as `function` correctly. Vine-grown stubs use the bare form + * (see `grow.mjs`), which means a grown surface is NOT re-served onward by default — chaining a + * vine through a middle instance is out of scope for v1 either way. + */ +import { CODES, VineError } from "./lib/errors.mjs"; +import { errorFrame, findFunctionArg, isSafePath, parseFrame, resultFrame, surfaceFrame } from "./lib/frame.mjs"; +import { assertApi, assertChannel } from "./lib/link.mjs"; + +/** + * Serve this instance's leaves to the far side of `channel`. + * + * **Deviation from `docs/DESIGN.md`, stated plainly:** the design sketch calls this without `await`. + * It cannot be synchronous — the surface is read from the loader's records and + * `api.slothlet.api.leaves()` is async — so `serve` returns a Promise for the documented + * `{ leaves, close }` object. Everything else matches the sketch; `await` the call. + * + * @param {object} api - The local slothlet instance (the object `slothlet()` returned). + * @param {import("./index.mjs").Channel} channel - The transport seam. + * @param {object} [options] + * @param {string[]} [options.paths] - Dotted prefixes to serve. A leaf is served when it equals a + * prefix or sits under it. Omit for every callable leaf of the base load. An ARRAY that yields no + * usable prefix (`[]`, `["", 7]`) serves NOTHING — a filter that cannot be satisfied is not the + * same as no filter, and the fail-closed reading is the safe one for a surface. A non-array value + * is ignored. + * @param {string[]} [options.modules] - Additional moduleIDs (or mount endpoints) whose leaves are + * unioned into the surface — the way to serve runtime `api.slothlet.api.add()` mounts, which + * `leaves(".")` does not cover. Unknown ids are skipped rather than fatal. + * @param {number} [options.budgetMs] - Accepted and IGNORED in v1; the budget is a grow-side + * concern. Documented for symmetry with {@link import("./grow.mjs").grow} per the design. + * @returns {Promise<{ leaves: string[], excluded: string[], close: () => void }>} The live serving + * handle. `leaves` is what the far side is offered; `excluded` is every CALLABLE leaf that was + * dropped on the way there — refused by {@link isSafePath} or filtered out by `paths` — so a leaf + * that quietly failed to appear is visible rather than a mystery. (Namespace and data records are + * not "dropped": they were never candidates for a callable surface.) + * @throws {TypeError} When `api` is not a slothlet instance or `channel` is not a Channel. + * + * @example + * const serving = await serve(api, channel, { paths: ["exts"] }); + * serving.leaves; // ["exts.pdfViewer.open", …] + * serving.excluded; // ["math.add", …] — real leaves this serve chose not to publish + * serving.close(); // stop answering (the channel itself is NOT torn down — see close()) + */ +export async function serve(api, channel, options = {}) { + assertChannel(channel, "serve"); + assertApi(api, "serve", ["leaves"]); + + const { leaves, excluded } = await collectLeaves(api, options); + const served = new Set(leaves); + let closed = false; + + channel.onMessage((message) => { + // The Channel contract forbids throwing into the transport, and this handler is the ONLY + // thing standing between a malformed frame and the transport's own dispatch loop. + try { + if (closed) return; + const frame = parseFrame(message); + if (frame === null || frame.type !== "call") return; + void answer(frame); + } catch { + // parseFrame is total and answer() never throws synchronously; this is belt-and-braces. + } + }); + + /** + * Invoke one call frame and send back exactly one terminal frame. + * @param {{ callId: string, path: string, args: unknown[] }} frame - The parsed call. + * @returns {Promise} Resolves once a terminal frame has been attempted. + */ + async function answer(frame) { + const { callId, path, args } = frame; + try { + // NEVER trust the wire: the path is re-validated against the served set on every call, so + // a peer that learned a path from an earlier, wider surface (or invented one) cannot reach + // a leaf this serve does not publish. + if (!served.has(path)) { + throw new VineError(CODES.NO_LEAF, `slothlet-vine: '${path}' is not in the served surface`, { path }); + } + const value = await invoke(api, path, args); + // Data-only cuts BOTH ways, and this is the half a grow side cannot enforce. Over a cloning + // transport a returned function fails as an opaque DataCloneError; over a by-reference one + // (loopback, same realm) it sails straight through and hands the caller a live closure over + // this side's scope — the same call, two semantics, one of them a hole in the isolation the + // vine exists to provide. Refused here, named, before anything is sent. + const functionAt = findFunctionArg([value]); + if (functionAt !== null) { + const location = `value${functionAt.slice("arg[0]".length)}`; + throw new VineError(CODES.DATA_ONLY, `slothlet-vine: '${path}' returned a function at ${location} — the vine is data-only`, { + path, + location + }); + } + if (!closed) send(resultFrame(callId, value)); + } catch (err) { + if (!closed) send(errorFrame(callId, err)); + } + } + + /** + * Hand a frame to the transport, degrading a send failure (an un-cloneable return value, a socket + * that just died) into an error frame rather than an unhandled rejection. If the substitute also + * fails the far side's budget timer settles the call — which is exactly why a budget is mandatory. + * @param {object} frame - The frame to send. + * @returns {void} + */ + function send(frame) { + try { + channel.send(frame); + } catch (err) { + // A surface frame that cannot be sent has no callId to answer on, and an error frame that + // cannot be sent cannot be replaced by another error frame. + if (frame.type === "error" || typeof frame.callId !== "string") return; + try { + channel.send( + errorFrame( + frame.callId, + new VineError(CODES.BAD_FRAME, `slothlet-vine: result could not be sent: ${err?.message ?? String(err)}`) + ) + ); + } catch { + // The channel is unusable. The grow side settles this call on its budget. + } + } + } + + // Publish the surface immediately. v1 sends it exactly once: a grow mounts from one manifest and + // re-publication is not a re-mount. Sent AFTER the receive handler is registered so a far side + // that answers instantly cannot race an unregistered channel. + send(surfaceFrame(leaves)); + + return { + leaves, + excluded, + /** + * Stop answering. This detaches the vine ONLY — it deliberately does not call + * `channel.close()`, because the transport is owned by whoever created it (one channel may + * outlive one serving, and a Channel handed in by a consumer is not ours to tear down). + * Close the channel yourself when you want the boundary gone. + * @returns {void} + */ + close() { + closed = true; + } + }; +} + +/** + * Read the callable surface from the instance's own records and apply both filters: the caller's + * `paths` prefixes and the unconditional exclusions (`slothlet.**`, the instance teardown handles, + * and anything {@link isSafePath} refuses). + * + * Both halves of that decision are returned. A silently-shorter surface is one of the harder things + * to debug from the far side of a boundary — "the leaf is right there and the vine says it isn't" — + * so every callable record this function declines to publish is reported on `excluded`, whichever + * filter declined it. + * @param {object} api - The slothlet instance. + * @param {{ paths?: string[], modules?: string[] }} options - Serve options. + * @returns {Promise<{ leaves: string[], excluded: string[] }>} Sorted, de-duplicated dotted leaf + * paths: the published surface, and the callable leaves dropped by a filter or the safety guard. + */ +async function collectLeaves(api, options) { + const prefixes = Array.isArray(options.paths) ? options.paths.filter((p) => typeof p === "string" && p.length > 0) : null; + const found = new Set(); + const dropped = new Set(); + + for (const key of [".", ...(Array.isArray(options.modules) ? options.modules : [])]) { + let records; + try { + records = await api.slothlet.api.leaves(key, { details: true }); + } catch { + // An unknown moduleID throws API_LEAVES_UNKNOWN_MODULE. Serving a surface is not the place + // to be fatal about one stale id in a list — skip it and serve the rest. + continue; + } + if (!Array.isArray(records)) continue; + for (const record of records) { + if (record?.kind !== "function") continue; + const path = record.path; + // `slothlet.**` is excluded by slothlet itself for `leaves(".")`, but a named module could + // in principle report anything, and isSafePath's UNSAFE_SEGMENTS covers the control plane. + if (!isSafePath(path)) { + dropped.add(typeof path === "string" ? path : String(path)); + continue; + } + if (prefixes && !prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}.`))) { + dropped.add(path); + continue; + } + found.add(path); + } + } + // A path reachable through one module key and filtered out under another is SERVED — the union + // wins, and it is not also reported as excluded. + return { leaves: [...found].sort(), excluded: [...dropped].filter((path) => !found.has(path)).sort() }; +} + +/** + * Resolve a dotted path against the LIVE api and invoke it. The leaf is called as + * `parent[last](...args)` — the same shape as an ordinary `api.math.add(1, 2)` — so `this` is the + * namespace the leaf lives on, exactly as a local caller would produce. + * + * The dispatch is `Reflect.apply(leaf, parent, args)`, NOT `leaf.apply(parent, args)`, and that is + * not a style choice. Probed on @cldmv/slothlet 3.14.0: merely READING `.apply` off a leaf + * materializes it into the loader's records — `intl.café` is reported as `function` before the call + * and as a `namespace` with a child `intl.café.apply` (`function`) after it. Serving a leaf would + * therefore corrupt the record tree it was read from: a later `serve()` of the same instance would + * publish `intl.café.apply` in place of `intl.café`, handing the far side `Function.prototype.apply` + * bound to a real leaf. `Reflect.apply` reads no property and leaves the records untouched (also + * verified) — and, incidentally, cannot be hijacked by a leaf that shadows `apply` with its own. + * @param {object} api - The slothlet instance. + * @param {string} path - Validated dotted path. + * @param {unknown[]} args - Call arguments. + * @returns {Promise} The leaf's resolved value. + * @throws {VineError} `VINE_NO_LEAF` when the path no longer resolves to a function. + */ +async function invoke(api, path, args) { + const segments = path.split("."); + const last = segments.pop(); + let parent = api; + for (const segment of segments) { + parent = parent?.[segment]; + if (parent === null || (typeof parent !== "object" && typeof parent !== "function")) { + throw new VineError(CODES.NO_LEAF, `slothlet-vine: '${path}' no longer resolves on the served instance`, { path }); + } + } + const leaf = parent?.[last]; + if (typeof leaf !== "function") { + throw new VineError(CODES.NO_LEAF, `slothlet-vine: '${path}' is not a callable leaf on the served instance`, { path }); + } + return await Reflect.apply(leaf, parent, args); +} diff --git a/src/testing/conformance.mjs b/src/testing/conformance.mjs new file mode 100644 index 0000000..f099c1d --- /dev/null +++ b/src/testing/conformance.mjs @@ -0,0 +1,286 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /src/testing/conformance.mjs + * + * The reusable Channel conformance suite. ANY transport — built-in or consumer-written — runs this + * against a factory that produces a connected channel pair, and every built-in transport's test file + * runs it alongside its own e2e. + * + * The harness imports NO test framework. `describe` / `it` / `expect` are INJECTED, so a consumer on + * vitest, node:test, jest or mocha runs the same suite by handing in their own three functions and + * this package never grows a runner dependency. + * + * @example + * import { describe, it, expect } from "vitest"; + * import { createPair } from "@cldmv/slothlet-vine/transport/loopback"; + * import { channelConformance } from "@cldmv/slothlet-vine/testing"; + * + * channelConformance("loopback", () => createPair(), { describe, it, expect }); + */ + +/** How long a conformance assertion waits for an async delivery before giving up. @type {number} */ +const WAIT_MS = 2000; + +/** + * Run the Channel conformance suite against a transport. + * + * The pair factory may be sync or async and may return either `[a, b]` or + * `{ a, b, cleanup? }` — a transport that owns real resources (a worker, a socket, a server) returns + * the object form so the suite can tear them down between cases. + * + * @param {string} name - Transport name, used in the suite title. + * @param {() => ([object, object] | {a: object, b: object, cleanup?: () => unknown} | Promise<[object, object] | {a: object, b: object, cleanup?: () => unknown}>)} makePair + * Produces one connected channel pair per test. + * @param {{ describe: Function, it: Function, expect: Function }} t - The injected test framework. + * @returns {void} + */ +export function channelConformance(name, makePair, t) { + const { describe, it, expect } = t; + + /** + * Normalize whatever the factory returned into `{ a, b, cleanup }`. + * @returns {Promise<{a: object, b: object, cleanup: () => Promise}>} The pair plus teardown. + */ + async function pair() { + const made = await makePair(); + const a = Array.isArray(made) ? made[0] : made.a; + const b = Array.isArray(made) ? made[1] : made.b; + const extra = Array.isArray(made) ? undefined : made.cleanup; + return { + a, + b, + async cleanup() { + try { + a.close?.(); + b.close?.(); + } catch { + // close() is being exercised elsewhere; teardown must not mask the real assertion. + } + if (typeof extra === "function") await extra(); + } + }; + } + + describe(`Channel conformance: ${name}`, () => { + it("delivers a frame from a to b", async () => { + const { a, b, cleanup } = await pair(); + try { + const received = collect(b); + a.send({ type: "call", callId: "c1", path: "x.y", args: [1] }); + const [frame] = await received.take(1); + expect(frame.callId).toBe("c1"); + expect(frame.path).toBe("x.y"); + } finally { + await cleanup(); + } + }); + + it("delivers in the other direction too", async () => { + const { a, b, cleanup } = await pair(); + try { + const received = collect(a); + b.send({ type: "result", callId: "c1", value: "pong" }); + const [frame] = await received.take(1); + expect(frame.value).toBe("pong"); + } finally { + await cleanup(); + } + }); + + it("delivers asynchronously — never synchronously inside send()", async () => { + const { a, b, cleanup } = await pair(); + try { + let seen = false; + b.onMessage(() => { + seen = true; + }); + a.send({ type: "result", callId: "c1", value: 1 }); + expect(seen).toBe(false); + await settle(); + expect(seen).toBe(true); + } finally { + await cleanup(); + } + }); + + it("preserves order across a burst", async () => { + const { a, b, cleanup } = await pair(); + try { + const received = collect(b); + for (let i = 0; i < 50; i++) a.send({ type: "result", callId: `c${i}`, value: i }); + const frames = await received.take(50); + expect(frames.map((f) => f.value)).toEqual(Array.from({ length: 50 }, (_, i) => i)); + } finally { + await cleanup(); + } + }); + + it("interleaves bursts from both ends without loss", async () => { + const { a, b, cleanup } = await pair(); + try { + const atB = collect(b); + const atA = collect(a); + for (let i = 0; i < 20; i++) { + a.send({ type: "call", callId: `a${i}`, path: "p", args: [i] }); + b.send({ type: "result", callId: `b${i}`, value: i }); + } + const [toB, toA] = await Promise.all([atB.take(20), atA.take(20)]); + expect(toB.map((f) => f.callId)).toEqual(Array.from({ length: 20 }, (_, i) => `a${i}`)); + expect(toA.map((f) => f.callId)).toEqual(Array.from({ length: 20 }, (_, i) => `b${i}`)); + } finally { + await cleanup(); + } + }); + + it("carries a large-ish payload intact", async () => { + const { a, b, cleanup } = await pair(); + try { + const received = collect(b); + const big = { text: "x".repeat(200_000), list: Array.from({ length: 5000 }, (_, i) => i), nested: { deep: { ok: true } } }; + a.send({ type: "call", callId: "big", path: "p", args: [big] }); + const [frame] = await received.take(1); + expect(frame.args[0].text.length).toBe(200_000); + expect(frame.args[0].list.length).toBe(5000); + expect(frame.args[0].nested.deep.ok).toBe(true); + } finally { + await cleanup(); + } + }); + + it("honours its declared pre-handler behaviour (buffersUntilHandler)", async () => { + const { a, b, cleanup } = await pair(); + try { + // Sent while `b` has no handler at all. A transport that DECLARES buffering must replay + // it; one that does not is asserted only on the frames sent after registration, because + // dropping is an equally valid contract — what is not valid is being undeclared. + a.send({ type: "result", callId: "early", value: "early" }); + await settle(); + const received = collect(b); + a.send({ type: "result", callId: "late", value: "late" }); + const buffers = b.capabilities?.buffersUntilHandler === true; + const frames = await received.take(buffers ? 2 : 1); + const ids = frames.map((f) => f.callId); + if (buffers) expect(ids).toEqual(["early", "late"]); + else expect(ids).toEqual(["late"]); + } finally { + await cleanup(); + } + }); + + it("lets the last onMessage registration win", async () => { + const { a, b, cleanup } = await pair(); + try { + const first = []; + b.onMessage((m) => first.push(m)); + const received = collect(b); + a.send({ type: "result", callId: "c1", value: 1 }); + await received.take(1); + expect(first).toEqual([]); + } finally { + await cleanup(); + } + }); + + it("insulates the transport from a throwing handler", async () => { + const { a, b, cleanup } = await pair(); + try { + b.onMessage(() => { + throw new Error("handler blew up"); + }); + expect(() => a.send({ type: "result", callId: "c1", value: 1 })).not.toThrow(); + await settle(); + // The channel is still usable after a handler threw. + const received = collect(b); + a.send({ type: "result", callId: "c2", value: 2 }); + const [frame] = await received.take(1); + expect(frame.callId).toBe("c2"); + } finally { + await cleanup(); + } + }); + + it("fires the far side's onClose when an end closes", async () => { + const { a, b, cleanup } = await pair(); + try { + if (typeof a.onClose !== "function" || typeof b.close !== "function") return; + let fired = 0; + a.onClose(() => { + fired++; + }); + b.close(); + await waitFor(() => fired > 0); + expect(fired).toBe(1); + } finally { + await cleanup(); + } + }); + + it("close() is idempotent and send() after close does not throw", async () => { + const { a, b, cleanup } = await pair(); + try { + if (typeof b.close !== "function") return; + b.close(); + expect(() => b.close()).not.toThrow(); + expect(() => b.send({ type: "result", callId: "c1", value: 1 })).not.toThrow(); + expect(() => a.send({ type: "result", callId: "c2", value: 2 })).not.toThrow(); + await settle(); + } finally { + await cleanup(); + } + }); + + it("declares its capabilities", async () => { + const { a, cleanup } = await pair(); + try { + expect(typeof a.send).toBe("function"); + expect(typeof a.onMessage).toBe("function"); + const caps = a.capabilities ?? {}; + expect(["none", "json", undefined]).toContain(caps.codec); + } finally { + await cleanup(); + } + }); + }); + + /** + * Attach a collecting handler to a channel. + * @param {object} channel - The channel to listen on. + * @returns {{ frames: object[], take: (n: number) => Promise }} Collector. + */ + function collect(channel) { + const frames = []; + channel.onMessage((message) => frames.push(message)); + return { + frames, + /** + * @param {number} n - How many frames to wait for. + * @returns {Promise} The first `n` frames. + */ + async take(n) { + await waitFor(() => frames.length >= n); + return frames.slice(0, n); + } + }; + } + + /** + * Poll until a predicate holds, or fail loudly rather than hanging the suite. + * @param {() => boolean} predicate - The condition. + * @returns {Promise} Resolves once true. + */ + async function waitFor(predicate) { + const deadline = Date.now() + WAIT_MS; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`slothlet-vine conformance: timed out after ${WAIT_MS}ms waiting for the transport`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + + /** + * Let any pending microtask/macrotask delivery run. + * @returns {Promise} Resolves on the next macrotask. + */ + function settle() { + return new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/src/transport/loopback.mjs b/src/transport/loopback.mjs index 8690d62..2bd07e2 100644 --- a/src/transport/loopback.mjs +++ b/src/transport/loopback.mjs @@ -2,8 +2,169 @@ * @Project: @cldmv/slothlet-vine * @Filename: /src/transport/loopback.mjs * - * The loopback transport — a Channel implementation. PRE-IMPLEMENTATION SCAFFOLD; lands with the spike. + * The loopback transport: two Channels wired to each other inside one process. It is the reference + * implementation of the Channel contract and the workhorse the conformance harness and the e2e bar + * run against — a real link, minus the real boundary. + * + * Two properties are deliberate: + * + * - **Delivery is asynchronous** (`queueMicrotask`), never a direct call into the peer's handler. + * A synchronous loopback would let `send()` re-enter the caller's own stack and would make the + * suite pass on ordering guarantees a real postMessage/socket boundary does not give. + * - **Frames sent before the peer registers a handler are BUFFERED**, not dropped, and that promise + * is declared on `capabilities.buffersUntilHandler` so the conformance suite can assert the + * behaviour this transport claims rather than one behaviour for all transports. It matters in + * practice: `serve()` publishes its surface immediately, and a `grow()` that has not finished + * awaiting its own setup must still receive it. + * + * Frames are passed BY REFERENCE — same process, no clone step — so every value survives exactly + * (`capabilities.structuredClone: true`, `codec: "none"`). Consumers who want a real serialization + * boundary should test against a transport that has one. + */ + +/** + * Create a connected pair of loopback Channels. This is the primary surface: a loopback endpoint is + * meaningless without its peer, so the pair — not a single channel — is the unit. + * @returns {[object, object]} Two Channels; anything `a.send()`s arrives at `b`'s handler and vice versa. + * + * @example + * const [a, b] = createPair(); + * b.onMessage((m) => console.log("b got", m)); + * a.send({ type: "ping" }); + */ +export function createPair() { + const a = makeEndpoint(); + const b = makeEndpoint(); + a._peer = b; + b._peer = a; + return [a, b]; +} + +/** + * Create ONE loopback channel, with its peer reachable as `.peer`. Provided so every built-in + * transport module exports `createChannel(...)` as the design requires; {@link createPair} is the + * natural surface for loopback and the one to prefer. + * @returns {object} A Channel whose `.peer` is the other end. + * + * @example + * const channel = createChannel(); + * const serving = await serve(api, channel.peer); + * const link = await grow(otherApi, channel); */ export function createChannel() { - throw new Error("@cldmv/slothlet-vine: transport/loopback is not implemented yet (pre-release scaffold)"); + const [a, b] = createPair(); + a.peer = b; + b.peer = a; + return a; +} + +/** + * Build one unconnected endpoint. `_peer` is filled in by {@link createPair}. + * @returns {object} A Channel implementation. + */ +function makeEndpoint() { + /** @type {{ _peer: object|null, closed: boolean, handler: Function|null, onCloseHandler: Function|null, buffer: unknown[] }} */ + const endpoint = { + _peer: null, + closed: false, + handler: null, + onCloseHandler: null, + buffer: [], + + capabilities: { + structuredClone: true, + codec: "none", + /** Frames that arrive before `onMessage` are queued and replayed, never dropped. */ + buffersUntilHandler: true + }, + + /** + * Deliver one frame to the peer. A send on (or to) a closed endpoint is a silent no-op — the + * core is required to tolerate frames crossing a close, so the transport must not turn that + * race into an exception. + * @param {object} message - The frame. + * @returns {void} + */ + send(message) { + if (endpoint.closed) return; + const peer = endpoint._peer; + if (!peer || peer.closed) return; + queueMicrotask(() => { + if (peer.closed) return; + peer._deliver(message); + }); + }, + + /** + * Register the (single) receive handler; a second registration replaces the first. Anything + * buffered while no handler was registered is replayed, in order, on the next microtask. + * @param {(message: object) => void} handler - The receive handler. + * @returns {void} + */ + onMessage(handler) { + endpoint.handler = typeof handler === "function" ? handler : null; + if (!endpoint.handler || endpoint.buffer.length === 0) return; + const queued = endpoint.buffer; + endpoint.buffer = []; + queueMicrotask(() => { + for (const message of queued) endpoint._invoke(message); + }); + }, + + /** + * Register the (single) far-side-closure handler; a second registration replaces the first. + * @param {(info?: object) => void} handler - The close handler. + * @returns {void} + */ + onClose(handler) { + endpoint.onCloseHandler = typeof handler === "function" ? handler : null; + }, + + /** + * Tear this end down. Idempotent. Fires the PEER's `onClose` — `onClose` is the far-side-death + * notification, so a locally-initiated close is not reported back to its own initiator. (A + * transport whose medium also reports the local close may fire both; the core tolerates it.) + * @returns {void} + */ + close() { + if (endpoint.closed) return; + endpoint.closed = true; + endpoint.buffer = []; + const peer = endpoint._peer; + if (!peer || peer.closed) return; + queueMicrotask(() => { + if (typeof peer.onCloseHandler === "function") { + try { + peer.onCloseHandler({ reason: "peer-closed" }); + } catch { + // A consumer handler must never surface as a transport fault. + } + } + }); + }, + + /** + * Accept an inbound frame: dispatch it, or buffer it until a handler exists. + * @param {unknown} message - The frame. + * @returns {void} + */ + _deliver(message) { + if (endpoint.handler) endpoint._invoke(message); + else endpoint.buffer.push(message); + }, + + /** + * Run the handler with the transport insulated from anything it throws. + * @param {unknown} message - The frame. + * @returns {void} + */ + _invoke(message) { + try { + endpoint.handler?.(message); + } catch { + // Contract: handlers never throw into the transport. + } + } + }; + return endpoint; } diff --git a/src/transport/post-message.mjs b/src/transport/post-message.mjs index 9ff1c8b..ae763b3 100644 --- a/src/transport/post-message.mjs +++ b/src/transport/post-message.mjs @@ -2,8 +2,267 @@ * @Project: @cldmv/slothlet-vine * @Filename: /src/transport/post-message.mjs * - * The post-message transport — a Channel implementation. PRE-IMPLEMENTATION SCAFFOLD; lands with the spike. + * The post-message transport: a Channel over the `postMessage` port surface shared by a browser + * `Worker`, a browser `MessagePort`, and a node `worker_threads` `MessagePort` / `Worker`. Every one + * of those exposes the same three things — `postMessage(frame)`, a `message` event + * (`addEventListener('message', fn)` or the `onmessage=` setter), and (mostly) `close()` — so ONE + * module serves them all. The medium structured-clones the frame, so frames cross as plain objects + * with no codec of our own (`capabilities.structuredClone: true`, `codec: "none"`). + * + * Two properties are deliberate and declared: + * + * - **`buffersUntilHandler: false`.** The underlying `message` listener is attached EAGERLY at + * {@link createChannel} time and dispatches to a nullable inner handler; a frame that arrives + * before `onMessage()` has been called finds no inner handler and is dropped. (Attaching the + * listener lazily instead would let the port's own pre-listener buffer replay those frames — a + * `worker_threads` MessagePort does buffer — which would be a different, undeclared contract. The + * eager-with-null-dispatch shape is what makes the `false` honest.) The core's `grow()` registers + * its receive handler synchronously before the far side's asynchronously-delivered `surface` frame + * can arrive, so nothing is lost in practice. + * - **Death detection is only what the port surface actually delivers**, and it varies by medium — + * see {@link createChannel}. We never fake a signal we cannot observe. + */ + +/** The close-signalling events wired by default. Extra ones (e.g. `"exit"`, `"error"`) are opt-in. @type {string[]} */ +const DEFAULT_DEATH_EVENTS = ["close", "messageerror"]; + +/** + * Wrap a `postMessage` port as a Channel. + * + * Works over any object exposing `postMessage(frame)` plus a `message` event — a browser `Worker`, + * a browser `MessagePort`, or a node `worker_threads` `MessagePort` / `Worker`. The `message` event + * is taken through `addEventListener('message', fn)` when the port has it (node MessagePort, browser + * Worker/MessagePort all do) and otherwise through the `onmessage=` setter. + * + * ## Death detection is medium-specific — this is the honest matrix + * + * `onClose` fires only on a signal the port genuinely emits. What each medium actually delivers: + * + * - **node `worker_threads` `MessagePort`** — emits a real `'close'` event on the peer when the + * OTHER side closes (verified), plus `'messageerror'` on a failed deserialize. This is the case + * with genuine peer-death detection, and it is what this module's own e2e rides. + * - **node `worker_threads` `Worker`** (the main-thread handle) — has no `'close'`; it emits + * `'exit'` when the worker stops. Pass `{ deathEvents: ["exit"] }` to observe it. + * - **browser `Worker`** — has no `'close'` and no `'exit'`; only `'error'` (an uncaught error + * inside the worker) and `'messageerror'`. A `terminate()` from the main thread is a LOCAL action + * and fires no event, so main-thread-initiated death is not observable through the port. Pass + * `{ deathEvents: ["error"] }` for the best-effort signal that IS available. + * - **browser `MessagePort`** — `close()` is local-only per the HTML spec: closing one port does + * NOT notify the other, so there is NO peer-death detection at all. (This is exactly where the + * node MessagePort differs — node propagates a `'close'` to the peer, the browser does not.) + * `'messageerror'` is still observed. + * + * The core never depends on `onClose` for correctness — a pending call also settles on its budget — + * so a medium without death detection degrades to slower settling, never a hang. + * + * @param {object} port - Anything with `postMessage(frame)` and a `message` event. + * @param {object} [options] - Transport options. + * @param {string[]} [options.deathEvents] - Extra event names to treat as a close signal, UNIONed + * with the defaults (`"close"`, `"messageerror"`). Use `["exit"]` for a node worker handle, + * `["error"]` for a browser `Worker`. + * @returns {object} A Channel: `{ send, onMessage, close, onClose, capabilities }`. + * + * @example + * // node worker_threads, two instances on the main thread over one MessageChannel: + * import { MessageChannel } from "node:worker_threads"; + * const { port1, port2 } = new MessageChannel(); + * const serving = await serve(workerApi, createChannel(port2)); + * const link = await grow(hostApi, createChannel(port1)); + * + * @example + * // browser Web Worker (main-thread side), death observed best-effort via 'error': + * const channel = createChannel(new Worker("./serve.js"), { deathEvents: ["error"] }); + */ +export function createChannel(port, options = {}) { + if (port === null || typeof port !== "object" || typeof port.postMessage !== "function") { + throw new TypeError( + "@cldmv/slothlet-vine: transport/post-message needs a port with postMessage(frame) (a Worker, MessagePort, or worker_threads port)" + ); + } + + const deathEvents = mergeDeathEvents(options.deathEvents); + const useAddEventListener = typeof port.addEventListener === "function"; + + /** @type {boolean} Once true, every dispatcher is inert and `send` is a no-op. */ + let closed = false; + /** @type {((message: object) => void)|null} The single receive handler (last write wins). */ + let messageHandler = null; + /** @type {((info?: object) => void)|null} The single close handler (last write wins). */ + let closeHandler = null; + /** @type {boolean} `onClose` fires at most once, even if several death events arrive. */ + let closeFired = false; + /** @type {Array<[string, Function]>} Attached AddEventListener pairs, for removal on close(). */ + const attached = []; + + /** + * Receive one message event: unwrap `event.data` and hand it to the current inner handler, with + * the port insulated from anything that handler throws. + * @param {{ data?: object }} event - The message event (or, defensively, a raw frame). + * @returns {void} + */ + function onMessageEvent(event) { + if (closed) return; + const handler = messageHandler; + if (typeof handler !== "function") return; + const data = event !== null && typeof event === "object" && "data" in event ? event.data : event; + try { + handler(data); + } catch { + // Contract: a consumer handler must never surface as a transport fault. + } + } + + /** + * Build the listener for one death event. Fires the close handler at most once. + * @param {string} reason - The event name, reported as `{ reason }`. + * @returns {() => void} The event listener. + */ + function makeDeathListener(reason) { + return function onDeathEvent() { + if (closed || closeFired) return; + closeFired = true; + const handler = closeHandler; + if (typeof handler !== "function") return; + try { + handler({ reason }); + } catch { + // A consumer handler must never surface as a transport fault. + } + }; + } + + if (useAddEventListener) { + port.addEventListener("message", onMessageEvent); + attached.push(["message", onMessageEvent]); + for (const event of deathEvents) { + const listener = makeDeathListener(event); + port.addEventListener(event, listener); + attached.push([event, listener]); + } + } else { + // Legacy `onX=` surface (a port with no addEventListener). Message delivery is universal via + // `onmessage`; death detection is limited to whichever `on` setters the port exposes. + port.onmessage = onMessageEvent; + for (const event of deathEvents) { + const prop = `on${event}`; + if (prop in port) port[prop] = makeDeathListener(event); + } + } + + return { + /** Structured-clone medium, no codec of our own, and no pre-handler buffering. */ + capabilities: { structuredClone: true, codec: "none", buffersUntilHandler: false }, + + /** + * Post one frame to the far side. The object crosses by structured clone — passed straight to + * `postMessage`, never serialized here. A send on a closed channel is a silent no-op. A + * `postMessage` throw is classified: a `DataCloneError` (the medium REFUSING an un-cloneable + * frame) is re-raised so the core settles just that call `VINE_BAD_FRAME`; any other throw is a + * close race and is swallowed, the core being required to tolerate frames that cross a close. + * @param {object} message - The frame. + * @returns {void} + * @throws {DOMException} Re-raises a `DataCloneError` so the core settles that call `VINE_BAD_FRAME`. + */ + send(message) { + if (closed) return; + try { + port.postMessage(message); + } catch (err) { + // A DataCloneError is the structured-clone algorithm REFUSING this frame (an un-cloneable + // argument the data-only scan cannot see) — a per-call fault, not link death, so re-raise + // it: the core settles just that call VINE_BAD_FRAME and the link stays alive. Anything + // else is a close race (a port torn down under us); swallow it — the core is required to + // tolerate a frame crossing a close, and the pending call settles on death or budget. + if (isCloneRefusal(err)) throw err; + } + }, + + /** + * Register the (single) receive handler; a second registration replaces the first. A + * non-function clears it. Frames that arrived before the first registration were dropped + * (`buffersUntilHandler: false`), not queued. + * @param {(message: object) => void} handler - The receive handler. + * @returns {void} + */ + onMessage(handler) { + messageHandler = typeof handler === "function" ? handler : null; + }, + + /** + * Register the (single) far-side-death handler; a second registration replaces the first. It + * fires only on a signal the port medium actually delivers — see {@link createChannel} for + * the per-medium matrix. + * @param {(info?: object) => void} handler - The close handler. + * @returns {void} + */ + onClose(handler) { + closeHandler = typeof handler === "function" ? handler : null; + }, + + /** + * Tear this end down: detach every listener, then close the port. Idempotent. Listeners are + * detached BEFORE `port.close()` so a medium that reports the local close (a node MessagePort + * fires its own `'close'`) cannot re-enter our death path on the way out. + * @returns {void} + */ + close() { + if (closed) return; + closed = true; + messageHandler = null; + if (useAddEventListener) { + for (const [event, listener] of attached) { + try { + port.removeEventListener(event, listener); + } catch { + // Detaching is best-effort; a port that refuses removal must not fault close(). + } + } + } else { + try { + port.onmessage = null; + } catch { + // Best-effort; a read-only accessor must not fault close(). + } + for (const event of deathEvents) { + const prop = `on${event}`; + try { + if (prop in port) port[prop] = null; + } catch { + // Best-effort. + } + } + } + try { + port.close?.(); + } catch { + // A port with no close(), or one that throws on it, must not fault close(). + } + } + }; +} + +/** + * Is this `postMessage` throw a structured-clone REFUSAL (an un-cloneable frame), as opposed to a + * close race? The structured-clone algorithm rejects an un-cloneable value with a `DataCloneError` + * (a `DOMException` named `"DataCloneError"`) in every host that implements it. A refusal is a + * per-call fault the core turns into `VINE_BAD_FRAME`; everything else is swallowed as a close race. + * @param {unknown} err - The thrown error. + * @returns {boolean} True when the error is a structured-clone refusal. + */ +function isCloneRefusal(err) { + return err !== null && typeof err === "object" && err.name === "DataCloneError"; +} + +/** + * Union the caller's extra death events with the defaults, de-duplicated. A non-array is ignored. + * @param {unknown} extra - Caller-supplied extra event names. + * @returns {string[]} The event names to wire. */ -export function createChannel() { - throw new Error("@cldmv/slothlet-vine: transport/post-message is not implemented yet (pre-release scaffold)"); +function mergeDeathEvents(extra) { + if (!Array.isArray(extra)) return [...DEFAULT_DEATH_EVENTS]; + const seen = new Set(DEFAULT_DEATH_EVENTS); + for (const event of extra) { + if (typeof event === "string" && event !== "") seen.add(event); + } + return [...seen]; } diff --git a/src/transport/process.mjs b/src/transport/process.mjs index 6a9c1a2..67cd7ed 100644 --- a/src/transport/process.mjs +++ b/src/transport/process.mjs @@ -2,8 +2,302 @@ * @Project: @cldmv/slothlet-vine * @Filename: /src/transport/process.mjs * - * The process transport — a Channel implementation. PRE-IMPLEMENTATION SCAFFOLD; lands with the spike. + * The node `child_process` IPC transport — a Channel implementation over a forked child's IPC channel. + * It has TWO endpoints, one per side of the boundary, and each side wraps a different object: + * + * - **Parent** — {@link createChannel}`(child)` wraps a `ChildProcess` returned by `fork(...)`. It + * sends with `child.send(frame)`, receives on `child.on("message", …)`, and detects the child's + * death on `child.on("exit"|"disconnect"|"error", …)` — real death detection, not a heartbeat. + * - **Child** — {@link createParentChannel}`()` wraps the child's own `process` global. It sends with + * `process.send(frame)`, receives on `process.on("message", …)`, and detects the parent going away + * on `process.on("disconnect", …)`. + * + * A channel is DIRECTIONAL (one serve end, one grow end); a forked child gives you exactly the pair + * you need — grow on the parent over `createChannel(child)`, serve in the child over + * `createParentChannel()`. + * + * ## Serialization — fork with `{ serialization: "advanced" }` + * + * Node IPC has two serialization modes. The default, `"json"`, round-trips a frame through + * `JSON.stringify`/`JSON.parse`, which SILENTLY DEGRADES the structured-clone types: a `Date` becomes + * an ISO string, a `Map`/`Set` becomes `{}`, a `Buffer` becomes `{ type: "Buffer", data: [...] }`. + * `"advanced"` uses the V8 structured-clone serializer, which preserves all of those with fidelity — + * so a consumer that forwards `Date`/`Map`/`Set`/`Buffer` payloads MUST fork with + * `fork(modulePath, args, { serialization: "advanced" })`, and both sides then agree. + * + * This transport DECLARES `capabilities.structuredClone: true` because that is the mode it is meant to + * run under and the one the e2e exercises. The honest caveat: the transport cannot force the far + * side's fork options, so under the DEFAULT `"json"` serialization that guarantee does not hold. In + * vine v1 the wire frames are plain JSON-safe objects (strings, numbers, arrays, nested plain + * objects), so `"json"` still works for the protocol itself — `"advanced"` is the recommended mode, + * required only once a leaf's arguments or return value carry a structured-clone type. `codec: "none"` + * either way: the medium clones for us, so this module never encodes/decodes frames itself. + * + * ## Ownership + * + * The parent's `close()` detaches its listeners and, if the child is still connected, calls + * `child.disconnect()` — it does NOT `child.kill()`. Whoever forked the child owns its lifecycle; a + * transport tearing the process down would be reaching past its boundary. Death detection stays live + * regardless: a killed or crashed child surfaces on `onClose` via `exit`/`disconnect`/`error`. The + * child's `close()` detaches its listeners and leaves the IPC channel alone for the same reason — the + * parent owns the connection, and the parent already learns of the child's exit on its own `exit` + * event, so the child need not disconnect itself. + */ + +/** + * Wrap a forked `ChildProcess` (parent side of the boundary). Send/receive ride the child's IPC + * channel; `onClose` fires the first time the child dies or disconnects. + * + * @param {import("node:child_process").ChildProcess} child - The process returned by `fork(...)`. + * @returns {object} A Channel: `{ send, onMessage, close, onClose, capabilities }`. + * @throws {TypeError} When `child` is not a ChildProcess-shaped object (no `send`/`on`). + * + * @example + * import { fork } from "node:child_process"; + * import { grow } from "@cldmv/slothlet-vine"; + * import { createChannel } from "@cldmv/slothlet-vine/transport/process"; + * + * const child = fork("./serve-child.mjs", [], { serialization: "advanced" }); + * const link = await grow(hostApi, createChannel(child)); + * // … + * await link.close(); // unmounts the stubs (does NOT close the channel) + * child.kill(); // the parent owns the child's lifecycle + */ +export function createChannel(child) { + if (child === null || typeof child !== "object" || typeof child.send !== "function" || typeof child.on !== "function") { + throw new TypeError( + "@cldmv/slothlet-vine: transport/process createChannel(child) needs a ChildProcess from fork() — an object with send() and on()" + ); + } + return makeEndpoint(child, "parent"); +} + +/** + * Wrap the child's own `process` (child side of the boundary). Send/receive ride the process's IPC + * channel to its parent; `onClose` fires when the parent disconnects (or the channel otherwise + * closes). Named for what it connects TO — the parent — so a reader on the child side is not left + * guessing which end this is. + * + * `proc` defaults to the live `process` and is the object a real child wraps; it is a parameter only + * so the endpoint can be driven against a fake in tests without attaching to the real IPC channel. + * Production code calls it with no arguments. + * + * @param {NodeJS.Process|object} [proc=process] - The process to wrap (defaults to the current one). + * @returns {object} A Channel: `{ send, onMessage, close, onClose, capabilities }`. + * @throws {TypeError} When `proc` has no `send` (not forked with an IPC channel). + * + * @example + * // serve-child.mjs — the file passed to fork() + * import slothlet from "@cldmv/slothlet"; + * import { serve } from "@cldmv/slothlet-vine"; + * import { createParentChannel } from "@cldmv/slothlet-vine/transport/process"; + * + * const api = await slothlet({ base: "./api" }); + * await serve(api, createParentChannel()); + */ +export function createParentChannel(proc = process) { + if (proc === null || typeof proc !== "object" || typeof proc.send !== "function" || typeof proc.on !== "function") { + throw new TypeError( + "@cldmv/slothlet-vine: transport/process createParentChannel() must run in a process forked with an IPC channel (process.send is undefined otherwise)" + ); + } + return makeEndpoint(proc, "child"); +} + +/** + * Build one IPC endpoint over `target`. The two sides differ only in which events signal far-side + * death and whether `close()` disconnects: the parent watches `exit`/`disconnect`/`error` and + * disconnects a still-connected child on close; the child watches `disconnect` and leaves the channel + * to the parent (see the ownership note in the module header). + * + * The receive listener is attached NOW, at construction, not lazily in `onMessage`. On the parent that + * is immediately after `fork()`, before the child can emit anything, so no early frame is lost to a + * missing listener; frames that nonetheless arrive before `onMessage` registers a handler are dropped + * (`buffersUntilHandler: false`) rather than queued. + * + * @param {object} target - A `ChildProcess` (parent) or `process` (child). + * @param {"parent"|"child"} side - Which end this is. + * @returns {object} The Channel. + */ +function makeEndpoint(target, side) { + const isParent = side === "parent"; + + /** @type {((message: object) => void)|null} */ + let handler = null; + /** @type {((info?: object) => void)|null} */ + let onCloseHandler = null; + let closed = false; + let deathFired = false; + + /** + * Fire the far-side-death/closure handler at most once. A send that fails on a dead channel routes + * here too, so a caller that only ever `send`s still learns the link is gone. + * @param {object} [info] - Why the far side is gone (`reason`, and `code`/`signal`/`error` when known). + * @returns {void} + */ + function fireClose(info) { + if (deathFired) return; + deathFired = true; + if (typeof onCloseHandler !== "function") return; + try { + onCloseHandler(info); + } catch { + // Channel contract: a consumer handler must never surface as a transport fault. + } + } + + /** + * Dispatch one inbound frame to the registered handler. Frames arriving before a handler exists, or + * after a local `close()`, are dropped — the core tolerates a dropped post-close frame, and this + * transport declares it does not buffer pre-handler. + * @param {object} message - The frame. + * @returns {void} + */ + function onMessageListener(message) { + if (closed || typeof handler !== "function") return; + try { + handler(message); + } catch { + // Channel contract: handlers never throw into the transport. + } + } + + /** @returns {void} */ + function onExit(code, signal) { + fireClose({ reason: "exit", code, signal }); + } + /** @returns {void} */ + function onDisconnect() { + fireClose({ reason: "disconnect" }); + } + /** @param {Error} err @returns {void} */ + function onError(err) { + fireClose({ reason: "error", error: err }); + } + + target.on("message", onMessageListener); + target.on("disconnect", onDisconnect); + if (isParent) { + target.on("exit", onExit); + // An 'error' listener also keeps a spawn/kill/send failure from throwing as an unhandled 'error'. + target.on("error", onError); + } + + /** + * Detach every listener this endpoint attached. Idempotent in practice (removeListener of an absent + * listener is a no-op). + * @returns {void} + */ + function detach() { + target.removeListener("message", onMessageListener); + target.removeListener("disconnect", onDisconnect); + if (isParent) { + target.removeListener("exit", onExit); + target.removeListener("error", onError); + } + } + + return { + capabilities: { structuredClone: true, codec: "none", buffersUntilHandler: false }, + + /** + * Hand one frame to the IPC channel. A `child.send` failure has TWO distinct causes and this + * transport keeps them apart — conflating them was the defect this classification fixes: + * + * - **The channel is DEAD** (`ERR_IPC_CHANNEL_CLOSED`/`EPIPE`/…, or `connected === false`, or an + * asynchronous delivery error reported through the callback). That is link death: `fireClose`, + * the same signal a real `exit` produces, so a caller that only ever `send`s still learns the + * link is gone. Not thrown out of the transport — the core requires a frame crossing a close to + * be tolerated. + * - **The V8 serializer REFUSED this frame** (an un-cloneable argument the data-only scan cannot + * see — a `Symbol`, a value hiding a function). `child.send` throws that SYNCHRONOUSLY with no + * dead-channel code. It is a PER-CALL fault, not link death, so the throw is RE-RAISED: the + * core's send wrapper catches it and settles just that one call `VINE_BAD_FRAME` while the link + * and every other in-flight call stay alive. + * + * Serialization refusals surface synchronously (here), so the async callback path is only ever a + * channel-delivery failure — always treated as death. + * @param {object} message - The frame. + * @returns {void} + * @throws {Error} Re-raises a synchronous serialization refusal so the core settles that call + * `VINE_BAD_FRAME`; a dead-channel throw is caught here and surfaced through `onClose` instead. + */ + send(message) { + if (closed) return; + if (target.connected === false) { + fireClose({ reason: "disconnect" }); + return; + } + try { + target.send(message, (err) => { + // Reached only asynchronously, for a delivery failure on a channel that closed under us + // (a serialization refusal is the synchronous throw handled below, never a callback). So + // a callback error is a dead channel: report it as death. + if (err) fireClose({ reason: "error", error: err }); + }); + } catch (err) { + if (isDeadChannelError(err)) { + fireClose({ reason: "error", error: err }); + } else { + // A serialization refusal — per-call, not link death. Let the core settle VINE_BAD_FRAME. + throw err; + } + } + }, + + /** + * Register the (single) receive handler; a later registration replaces the earlier one. + * @param {(message: object) => void} fn - The receive handler. + * @returns {void} + */ + onMessage(fn) { + handler = typeof fn === "function" ? fn : null; + }, + + /** + * Register the (single) far-side-death/closure handler; a later registration replaces the + * earlier one. + * @param {(info?: object) => void} fn - The close handler. + * @returns {void} + */ + onClose(fn) { + onCloseHandler = typeof fn === "function" ? fn : null; + }, + + /** + * Tear this end down: detach the listeners so no further frame or death event dispatches. On the + * PARENT, additionally `child.disconnect()` a still-connected child to close the IPC channel — + * but never `child.kill()`, because whoever forked the child owns its lifecycle. On the CHILD, + * leave the channel to the parent (the parent already learns of the child's exit on its own + * side). Idempotent. + * @returns {void} + */ + close() { + if (closed) return; + closed = true; + detach(); + if (isParent) { + try { + if (target.connected) target.disconnect(); + } catch { + // Already disconnected / never connected — nothing to tear down. + } + } + } + }; +} + +/** Node error codes that mean the IPC channel itself is gone — not a per-frame serialization refusal. @type {Set} */ +const DEAD_CHANNEL_CODES = new Set(["ERR_IPC_CHANNEL_CLOSED", "ERR_IPC_DISCONNECTED", "EPIPE", "ERR_STREAM_DESTROYED"]); + +/** + * Classify a synchronous `child.send` throw: is the channel dying, or is the serializer refusing this + * one frame? A dead-channel code means link death (→ `onClose`); anything else — chiefly the code-less + * `Error` the V8 serializer throws for an un-cloneable value — is a per-call refusal the caller must + * re-raise so the core settles that call `VINE_BAD_FRAME`. + * @param {unknown} err - The thrown error. + * @returns {boolean} True when the error signals a dead IPC channel. */ -export function createChannel() { - throw new Error("@cldmv/slothlet-vine: transport/process is not implemented yet (pre-release scaffold)"); +function isDeadChannelError(err) { + return err !== null && typeof err === "object" && typeof err.code === "string" && DEAD_CHANNEL_CODES.has(err.code); } diff --git a/src/transport/websocket.mjs b/src/transport/websocket.mjs index 4a2b40e..a0e8585 100644 --- a/src/transport/websocket.mjs +++ b/src/transport/websocket.mjs @@ -2,8 +2,327 @@ * @Project: @cldmv/slothlet-vine * @Filename: /src/transport/websocket.mjs * - * The websocket transport — a Channel implementation. PRE-IMPLEMENTATION SCAFFOLD; lands with the spike. + * The websocket transport — a {@link Channel} over a single `ws` WebSocket. This is the one BYTE + * transport in v1: the medium carries strings, so the channel owns its own encode/decode + * (`capabilities.codec: "json"`) rather than relying on structured clone the way the postMessage + * family does. + * + * ## The v1 JSON codec — and what it degrades (honest limitations) + * + * Frames cross as `JSON.stringify(frame)` and are rebuilt with `JSON.parse`. That is faithful for the + * data-only, plain-object frame shapes the vine actually sends, but JSON is lossy for richer values a + * leaf's args/return might contain: + * + * - `Date` → an ISO **string** (not a `Date`); the grow side receives the string. + * - `Map` / `Set` → `{}` (their entries are lost entirely). + * - `Symbol` → dropped: a symbol-valued property vanishes, a symbol array element becomes `null`. + * - `undefined` object properties and array holes → dropped / `null`. + * - `TypedArray` / `ArrayBuffer` / `Buffer` → a plain object of indices, not the buffer. + * + * Those are lossy-but-VALID degradations — the frame still crosses. A `BigInt` is different: it + * THROWS in `JSON.stringify`, so the codec cannot encode the frame at all. That is a per-call REFUSAL + * (not a degradation and not a dead socket): `send()` re-raises it and the core settles just that call + * `VINE_BAD_FRAME`, consistent with the structured-clone transports rejecting an un-cloneable frame — + * the link and every other in-flight call stay alive. + * + * These are inherent to `codec: "json"`; a richer byte codec is a future capability + * (see `docs/DESIGN.md` § Non-goals). Consumers who need `Date`/`Map`/`Set` fidelity should use a + * structured-clone transport (postMessage family) or wait for a richer codec. + * + * ## Capabilities & the choices behind them + * + * `{ structuredClone: false, codec: "json", buffersUntilHandler: false }`. + * + * - **`buffersUntilHandler: false`** — a message that arrives before `onMessage` has a handler is + * DROPPED, not replayed. `ws` does not queue emitted events; honouring that honestly is more + * truthful than faking a buffer this medium does not have. It is safe in practice because both + * `serve()` and `grow()` register their receive handler synchronously, before the socket can + * deliver anything (a client socket only starts delivering after its async `open`). + * - **Send before `OPEN` is BUFFERED, then flushed on `open`.** A client `new WebSocket(url)` connects + * asynchronously, so `send()` may be called on a `CONNECTING` socket; queuing until `open` (rather + * than erroring) is the faithful choice for a socket that simply is not ready yet. A send on a + * `CLOSING`/`CLOSED` socket is a silent no-op — the core is required to tolerate frames crossing a + * close, so the transport must not turn that race into a throw. + * - **`close()` CLOSES the underlying socket** (not merely detaching listeners). A `ws` socket is 1:1 + * with its channel, so a socket with no channel is dead weight; more decisively, the Channel + * conformance suite asserts that closing one end fires the OTHER end's `onClose`, and over a real + * socket that is only observable if `close()` actually closes the socket. This is the one place the + * websocket transport diverges from the "detach only" option the port-wrapping transports may take. + * - **A locally-initiated `close()` does not fire this end's own `onClose`.** `onClose` is the + * far-side-death notification (mirroring loopback); only the far end's close/error, or a network + * drop, reports through it. + * + * ## The optional `ws` peer dependency + * + * `ws` is an OPTIONAL peer dependency, imported by NOTHING in the core — only here, and only lazily. + * {@link createChannel} wraps a socket the caller already constructed, so it needs no import (a live + * `ws` socket is itself proof `ws` is installed). {@link connect} is the one entry point that + * CONSTRUCTS a client socket, so it is the one that imports `ws` — and it is where a clear + * "install the optional peer dependency 'ws'" error is thrown when the import fails. + */ + +/** WHATWG WebSocket `readyState` values (`ws` conforms). @type {number} */ +const CONNECTING = 0; +/** @type {number} */ +const OPEN = 1; + +/** One decoder for every inbound binary frame — `ws` delivers text as a `Buffer` by default. */ +const DECODER = new TextDecoder(); + +/** + * Wrap an existing `ws` WebSocket in a {@link Channel}. Accepts either a client socket + * (`new WebSocket(url)`) or a socket handed to a `WebSocketServer` `'connection'` handler — they + * share the same instance surface (`send`, `on`, `close`, `readyState`). + * + * @param {object} socket - A `ws` WebSocket instance (client or server-accepted). + * @param {object} [options] - Reserved for forward compatibility (none in v1). + * @returns {object} A Channel: `{ send, onMessage, close, onClose, capabilities }`. + * @throws {TypeError} When `socket` does not expose the `ws` instance surface. + * + * @example + * import { WebSocketServer } from "ws"; + * import { createChannel } from "@cldmv/slothlet-vine/transport/websocket"; + * import { serve } from "@cldmv/slothlet-vine"; + * + * const wss = new WebSocketServer({ port: 0 }); + * wss.on("connection", async (socket) => { + * await serve(api, createChannel(socket), { paths: ["exts"] }); + * }); + */ +export function createChannel(socket, options) { + void options; + if (!socket || typeof socket.send !== "function" || typeof socket.on !== "function" || typeof socket.close !== "function") { + throw new TypeError( + "@cldmv/slothlet-vine: transport/websocket createChannel(socket) requires a `ws` WebSocket instance (send/on/close/readyState)." + ); + } + + /** The single receive handler; last `onMessage` registration wins. @type {Function|null} */ + let messageHandler = null; + /** The single far-side-death handler. @type {Function|null} */ + let closeHandler = null; + /** Frames sent while the socket was still `CONNECTING`, flushed on `open`. @type {string[]} */ + const pendingSends = []; + /** True once this end initiated `close()` — suppresses this end's own `onClose`. */ + let localClosing = false; + /** True once `onClose` has fired — it fires at most once (close OR error, whichever first). */ + let closeNotified = false; + + /** + * Flush anything queued while the socket was connecting. Bound once as the `'open'` listener. + * @returns {void} + */ + function flushPending() { + if (socket.readyState !== OPEN) return; + while (pendingSends.length > 0) { + const text = pendingSends.shift(); + try { + socket.send(text); + } catch { + // The socket died between `open` and this flush; the core tolerates a lost frame. + } + } + } + + /** + * Notify the far-side-death handler exactly once, unless this end initiated the close. Guarded so + * a throwing consumer handler never surfaces as a transport fault. + * @param {object} [info] - Reason info passed to the handler. + * @returns {void} + */ + function notifyClose(info) { + if (closeNotified || localClosing) return; + closeNotified = true; + if (typeof closeHandler === "function") { + try { + closeHandler(info); + } catch { + // Contract: handlers never throw into the transport. + } + } + } + + /** + * Dispatch one inbound socket message to the registered handler. Suppressed after a local `close()` + * — an inbound frame that arrives once this end has torn down is dropped, matching the other four + * transports (the core tolerates a dropped post-close frame). + * @param {unknown} data - The raw `'message'` payload. + * @returns {void} + */ + function onSocketMessage(data) { + if (localClosing || !messageHandler) return; // buffersUntilHandler: false — nothing to deliver to yet. + const text = toText(data); + if (text === null) return; + let frame; + try { + frame = JSON.parse(text); + } catch { + return; // A malformed payload is dropped, never fed to the handler or thrown into the socket. + } + try { + messageHandler(frame); + } catch { + // Contract: handlers never throw into the transport. + } + } + + /** + * @param {number} [code] - The close code. + * @param {unknown} [reason] - The close reason payload. + * @returns {void} + */ + function onSocketClose(code, reason) { + notifyClose({ reason: "peer-closed", code, detail: reasonText(reason) }); + } + + /** + * A socket error is a real death (network drop, server crash) — report it, then let the following + * 'close' be a no-op (closeNotified latches). + * @param {unknown} err - The socket error. + * @returns {void} + */ + function onSocketError(err) { + notifyClose({ reason: "error", error: err instanceof Error ? err.message : String(err) }); + } + + socket.on("open", flushPending); + socket.on("message", onSocketMessage); + socket.on("close", onSocketClose); + socket.on("error", onSocketError); + + return { + capabilities: { structuredClone: false, codec: "json", buffersUntilHandler: false }, + + /** + * Encode one frame and deliver it. Buffered until `open` if the socket is still connecting; a + * silent no-op on a closing/closed socket. A `JSON.stringify` throw is the JSON codec REFUSING + * this frame (a `BigInt` in the graph) — a per-call fault, not a dead socket, so it is re-raised: + * the core settles just that call `VINE_BAD_FRAME` and the socket stays alive. (Lossy-but-valid + * degradation — `Date`→string, `Map`/`Set`→`{}` — is NOT a refusal and still crosses; see the + * module header.) + * @param {object} frame - The plain frame object. + * @returns {void} + * @throws {TypeError} Re-raises a `JSON.stringify` failure (e.g. a `BigInt`) so the core settles + * that call `VINE_BAD_FRAME`. + */ + send(frame) { + const text = JSON.stringify(frame); // a BigInt throws here — a per-call refusal, let it propagate. + if (text === undefined) return; + if (socket.readyState === CONNECTING) { + pendingSends.push(text); + return; + } + if (socket.readyState !== OPEN) return; // CLOSING/CLOSED — tolerate, no throw. + try { + socket.send(text); + } catch { + // The socket transitioned to a bad state under us; the core tolerates a lost frame. + } + }, + + /** + * Register the (single) receive handler; a later registration replaces the earlier one. A + * non-function clears it. Frames that arrived before a handler existed were dropped. + * @param {(message: object) => void} handler - The receive handler. + * @returns {void} + */ + onMessage(handler) { + messageHandler = typeof handler === "function" ? handler : null; + }, + + /** + * Register the (single) far-side-death handler; a later registration replaces the earlier one. + * @param {(info?: object) => void} handler - The close/death handler. + * @returns {void} + */ + onClose(handler) { + closeHandler = typeof handler === "function" ? handler : null; + }, + + /** + * Tear this end down: detach every listener this channel attached — releasing the handler + * closures and stopping any further inbound dispatch or death report — then close the underlying + * socket (1:1 ownership). Idempotent, and it does not fire this end's own `onClose`: a + * locally-initiated close is not a far-side death. + * @returns {void} + */ + close() { + if (localClosing) return; + localClosing = true; + messageHandler = null; + pendingSends.length = 0; + try { + socket.removeListener("open", flushPending); + socket.removeListener("message", onSocketMessage); + socket.removeListener("close", onSocketClose); + socket.removeListener("error", onSocketError); + } catch { + // A socket that refuses listener removal is already tearing down; nothing left to detach. + } + try { + socket.close(); + } catch { + // Already closing/closed, or the socket rejected a redundant close — nothing to do. + } + } + }; +} + +/** + * Construct a client channel to a `ws://` / `wss://` URL. This is the one place the transport imports + * the optional `ws` peer dependency — and the one that throws a clear, install-me error when `ws` is + * absent. Sends before the socket finishes connecting are buffered by the channel, so the returned + * channel is usable immediately without awaiting `open`. + * + * @param {string} url - The websocket URL to connect to. + * @param {object} [options] - Options forwarded to the `ws` WebSocket constructor. + * @returns {Promise} A Channel over the freshly-created client socket. + * @throws {Error} When the optional peer dependency `ws` is not installed. + * + * @example + * import { connect } from "@cldmv/slothlet-vine/transport/websocket"; + * import { grow } from "@cldmv/slothlet-vine"; + * + * const channel = await connect("ws://127.0.0.1:8710"); + * const link = await grow(hostApi, channel, { budgetMs: 5000 }); + */ +export async function connect(url, options) { + let ws; + try { + ws = await import("ws"); + } catch (cause) { + throw new Error( + "@cldmv/slothlet-vine: transport/websocket requires the optional peer dependency 'ws'. Install it with `npm install ws`.", + { cause } + ); + } + const WebSocket = ws.WebSocket ?? ws.default; + return createChannel(new WebSocket(url, options)); +} + +/** + * Normalize a `ws` `'message'` payload into a UTF-8 string. `ws` delivers text as a `Buffer` by + * default, and binary as `Buffer` / `ArrayBuffer` / an array of `Buffer` fragments. + * @param {unknown} data - The raw `'message'` payload. + * @returns {string|null} The decoded text, or `null` when it cannot be decoded. + */ +function toText(data) { + try { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return DECODER.decode(data); + if (ArrayBuffer.isView(data)) return DECODER.decode(data); // Buffer / TypedArray / DataView + if (Array.isArray(data)) return data.map((part) => toText(part) ?? "").join(""); // fragmented binary + return null; + } catch { + return null; + } +} + +/** + * Decode a `ws` `'close'` reason (a `Buffer`) into a string for the close info, tolerating anything. + * @param {unknown} reason - The close reason payload. + * @returns {string} The reason text (possibly empty). */ -export function createChannel() { - throw new Error("@cldmv/slothlet-vine: transport/websocket is not implemented yet (pre-release scaffold)"); +function reasonText(reason) { + return toText(reason) ?? ""; } diff --git a/src/transport/worker-threads.mjs b/src/transport/worker-threads.mjs index f9e02c5..5847d62 100644 --- a/src/transport/worker-threads.mjs +++ b/src/transport/worker-threads.mjs @@ -2,8 +2,263 @@ * @Project: @cldmv/slothlet-vine * @Filename: /src/transport/worker-threads.mjs * - * The worker-threads transport — a Channel implementation. PRE-IMPLEMENTATION SCAFFOLD; lands with the spike. + * The `node:worker_threads` transport — a Channel over the thread boundary, with REAL death + * detection. It has two endpoints, one per side of the boundary: + * + * - **Parent side** — {@link createChannel}`(worker)` wraps a live `worker_threads.Worker`. Frames + * ride `worker.postMessage` / `worker.on("message")`, and `onClose` fires when the worker actually + * dies: `"exit"` (any code) or `"error"`. This is the transport's advantage over the browser + * `postMessage` family — a worker thread ending is a real, observable event, so a pending call is + * force-settled `VINE_GONE` the moment the thread is gone rather than hanging on its budget. + * - **Child side** — {@link createParentChannel}`()` wraps `worker_threads.parentPort` (a + * `MessagePort`). It takes an optional port so two ports of a `worker_threads.MessageChannel` can + * be paired in-process for the conformance suite (a real structured-clone boundary, no second + * thread). + * + * Both sides declare `{ structuredClone: true, codec: "none", buffersUntilHandler: false }`: + * + * - **`structuredClone: true`, `codec: "none"`** — the medium structured-clones, so frames are + * handed to `postMessage` verbatim (never JSON). `Date` / `Map` / `Set` survive; only the + * documented data-only rule (no functions) bounds what may cross. + * - **`buffersUntilHandler: false`** — a `MessagePort` in Node buffers messages posted before a + * `"message"` listener exists, so this module attaches its OWN listener eagerly (at construction) + * and DROPS any frame that arrives before the core registers its handler. Dropping rather than + * Node-buffering is what makes the declaration honest, and it is safe for the vine: `grow()` and + * `serve()` both register `onMessage` synchronously — before the event loop can deliver the first + * worker message — so the surface frame is never among the dropped. + * + * Ownership: {@link createChannel}`.close()` detaches its listeners but NEVER terminates the worker — + * the caller made the worker and owns its lifecycle. {@link createParentChannel}`.close()` closes the + * port it wraps, because there the port IS the transport. + */ +import { parentPort as defaultParentPort } from "node:worker_threads"; + +/** The capabilities every worker-threads endpoint declares. @type {{structuredClone: boolean, codec: string, buffersUntilHandler: boolean}} */ +const CAPABILITIES = Object.freeze({ structuredClone: true, codec: "none", buffersUntilHandler: false }); + +/** + * PARENT side. Wrap a `worker_threads.Worker` as a Channel whose far side is the code running inside + * the worker (which wraps its own `parentPort` with {@link createParentChannel}). + * + * `onClose` fires on real thread death — the worker's `"exit"` event (whatever the exit code) or its + * `"error"` event — whichever comes first, exactly once. `close()` detaches the listeners and does + * NOT call `worker.terminate()`: the worker's lifecycle belongs to whoever created it. Detecting the + * worker dying is the point, so `onClose` stays live until you close the channel. + * + * @param {import("node:worker_threads").Worker} worker - A live Worker instance. + * @returns {object} A Channel: `{ send, onMessage, close, onClose, capabilities }`. + * @throws {TypeError} When `worker` is not an object exposing `postMessage` and `on`. + * + * @example + * import { Worker } from "node:worker_threads"; + * import { grow } from "@cldmv/slothlet-vine"; + * import { createChannel } from "@cldmv/slothlet-vine/transport/worker-threads"; + * + * const worker = new Worker(new URL("./serve-worker.mjs", import.meta.url)); + * const link = await grow(hostApi, createChannel(worker), { budgetMs: 5000 }); + */ +export function createChannel(worker) { + if ( + worker === null || + (typeof worker !== "object" && typeof worker !== "function") || + typeof worker.postMessage !== "function" || + typeof worker.on !== "function" + ) { + throw new TypeError( + "@cldmv/slothlet-vine: transport/worker-threads createChannel(worker) needs a worker_threads.Worker (an object with postMessage() and on())" + ); + } + return makeChannel(worker, { deathEvents: ["exit", "error"], ownsTarget: false }); +} + +/** + * CHILD side. Wrap `worker_threads.parentPort` (or any `MessagePort`) as a Channel whose far side is + * the parent that spawned this worker (which wraps the `Worker` with {@link createChannel}). + * + * `onClose` fires on the port's `"close"` event — the parent tearing the channel down. `close()` + * closes the wrapped port: inside a worker that is the child's half of the transport, and pairing two + * `MessageChannel` ports (the conformance use) makes closing one the way to notify the other. + * + * The `port` parameter defaults to the ambient `parentPort`, so a worker calls it with no arguments; + * the parameter exists so two ends of a `worker_threads.MessageChannel` can be wrapped and paired in + * one process for the Channel conformance suite. + * + * @param {import("node:worker_threads").MessagePort} [port=parentPort] - The port to wrap. + * @returns {object} A Channel: `{ send, onMessage, close, onClose, capabilities }`. + * @throws {TypeError} When no usable port is available (called outside a worker with no `port`). + * + * @example + * // inside serve-worker.mjs + * import slothlet from "@cldmv/slothlet"; + * import { serve } from "@cldmv/slothlet-vine"; + * import { createParentChannel } from "@cldmv/slothlet-vine/transport/worker-threads"; + * + * const api = await slothlet({ base: SERVE_DIR }); + * await serve(api, createParentChannel()); + */ +export function createParentChannel(port = defaultParentPort) { + if (port === null || typeof port !== "object" || typeof port.postMessage !== "function" || typeof port.on !== "function") { + throw new TypeError( + "@cldmv/slothlet-vine: transport/worker-threads createParentChannel() must run inside a worker (no parentPort) or be given a MessagePort" + ); + } + return makeChannel(port, { deathEvents: ["close"], ownsTarget: true }); +} + +/** + * Build a Channel over a message target (a `Worker` or a `MessagePort`). The two exported endpoints + * differ only in which events mean "the far side is gone" and whether closing owns the target. + * + * A single, always-attached `"message"` listener keeps the target flowing from construction, so the + * pre-handler drop (not Node's buffer) is what backs `buffersUntilHandler: false`. Every consumer + * callback is insulated: a throwing `onMessage`/`onClose` handler can never surface as a transport + * fault, per the Channel contract. + * + * @param {object} target - The `Worker` or `MessagePort` to wrap. + * @param {{ deathEvents: string[], ownsTarget: boolean }} config + * `deathEvents` — target events that fire `onClose` (once); `ownsTarget` — whether `close()` also + * tears the target down (`target.close()`), which the child-side port owns and the parent-side + * worker does not. + * @returns {object} The Channel. + */ +function makeChannel(target, { deathEvents, ownsTarget }) { + /** @type {((message: object) => void) | null} The single receive handler; null until the core registers one. */ + let handler = null; + /** @type {((info?: object) => void) | null} The single far-side-death handler. */ + let onCloseHandler = null; + let closed = false; + let deathFired = false; + + /** + * The one persistent inbound listener. Delivers to the core's handler, or drops the frame when + * none is registered yet — the deliberate `buffersUntilHandler: false` behaviour. + * @param {object} message - The inbound frame. + * @returns {void} + */ + const onMessageRaw = (message) => { + if (closed || handler === null) return; + try { + handler(message); + } catch { + // Channel contract: a consumer handler must never throw into the transport. + } + }; + + /** + * Fire the far-side-death handler exactly once. Bound per death event so it can be detached. + * @param {object} [info] - Why the far side is considered gone. + * @returns {void} + */ + const fireClose = (info) => { + if (deathFired || closed) return; + deathFired = true; + if (typeof onCloseHandler === "function") { + try { + onCloseHandler(info); + } catch { + // A consumer close handler must never surface as a transport fault. + } + } + }; + + /** @type {Array<[string, (arg?: unknown) => void]>} The death listeners actually attached, for clean detach. */ + const deathListeners = []; + for (const event of deathEvents) { + /** + * @param {unknown} [arg] - The event payload (an exit code, an Error, or nothing for "close"). + * @returns {void} + */ + const listener = (arg) => { + if (event === "exit") fireClose({ reason: "exit", code: arg }); + else if (event === "error") fireClose({ reason: "error", error: arg }); + else fireClose({ reason: "peer-closed" }); + }; + deathListeners.push([event, listener]); + target.on(event, listener); + } + + target.on("message", onMessageRaw); + + return { + capabilities: CAPABILITIES, + + /** + * Deliver one frame to the far side. A send on a closed channel is a silent no-op. A + * `postMessage` throw is classified: a `DataCloneError` (the medium REFUSING an un-cloneable + * frame — an argument the data-only scan cannot see) is re-raised so the core settles just that + * call `VINE_BAD_FRAME` and the link stays alive; any other throw is a close race (a terminated + * worker, a closed port) and is swallowed, the core being required to tolerate frames crossing a + * close (the pending call settles on death or budget). + * @param {object} message - The frame (passed to `postMessage` verbatim; structured-cloned). + * @returns {void} + * @throws {DOMException} Re-raises a `DataCloneError` so the core settles that call `VINE_BAD_FRAME`. + */ + send(message) { + if (closed) return; + try { + target.postMessage(message); + } catch (err) { + // Structured-clone refusal → per-call BAD_FRAME (rethrow); everything else is a close race. + if (isCloneRefusal(err)) throw err; + } + }, + + /** + * Register the (single) receive handler; a later registration replaces the earlier one. A + * non-function clears it. Frames that arrived before this point were dropped, not buffered. + * @param {(message: object) => void} fn - The receive handler. + * @returns {void} + */ + onMessage(fn) { + handler = typeof fn === "function" ? fn : null; + }, + + /** + * Register the (single) far-side-death handler; a later registration replaces the earlier one. + * @param {(info?: object) => void} fn - The close handler. + * @returns {void} + */ + onClose(fn) { + onCloseHandler = typeof fn === "function" ? fn : null; + }, + + /** + * Tear this end down. Idempotent. Detaches every listener — so a subsequent worker death is not + * reported to a link that already closed locally — and, for the child-side port that owns its + * target, closes the port too. NEVER terminates a parent-side worker: that lifecycle belongs to + * whoever created it. + * @returns {void} + */ + close() { + if (closed) return; + closed = true; + handler = null; + onCloseHandler = null; + try { + target.removeListener("message", onMessageRaw); + for (const [event, listener] of deathListeners) target.removeListener(event, listener); + } catch { + // A target that refuses listener removal is already tearing down; nothing left to detach. + } + if (ownsTarget && typeof target.close === "function") { + try { + target.close(); + } catch { + // Already closed by the far side, or mid-teardown; the transport is gone either way. + } + } + } + }; +} + +/** + * Is this `postMessage` throw a structured-clone REFUSAL (an un-cloneable frame), as opposed to a + * close race? The structured-clone algorithm rejects an un-cloneable value with a `DataCloneError` + * (a `DOMException` named `"DataCloneError"`). A refusal is a per-call fault the core turns into + * `VINE_BAD_FRAME`; everything else is swallowed as a close race. + * @param {unknown} err - The thrown error. + * @returns {boolean} True when the error is a structured-clone refusal. */ -export function createChannel() { - throw new Error("@cldmv/slothlet-vine: transport/worker-threads is not implemented yet (pre-release scaffold)"); +function isCloneRefusal(err) { + return err !== null && typeof err === "object" && err.name === "DataCloneError"; } diff --git a/tests/conformance-loopback.test.vitest.mjs b/tests/conformance-loopback.test.vitest.mjs new file mode 100644 index 0000000..2adbd39 --- /dev/null +++ b/tests/conformance-loopback.test.vitest.mjs @@ -0,0 +1,81 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/conformance-loopback.test.vitest.mjs + * + * The loopback transport against the shared Channel conformance suite — the same suite every other + * built-in transport, and any consumer-written one, has to pass. + */ +import { describe, it, expect } from "vitest"; +import { createChannel, createPair } from "../src/transport/loopback.mjs"; +import { channelConformance } from "../src/testing/conformance.mjs"; + +channelConformance("loopback", () => createPair(), { describe, it, expect }); + +describe("loopback specifics", () => { + it("declares pass-through capabilities", () => { + const [a, b] = createPair(); + for (const channel of [a, b]) { + expect(channel.capabilities).toEqual({ structuredClone: true, codec: "none", buffersUntilHandler: true }); + } + }); + + it("passes frames BY REFERENCE — same process, no clone step", async () => { + const [a, b] = createPair(); + const sent = { type: "result", callId: "c1", value: { deep: {} } }; + const seen = []; + b.onMessage((m) => seen.push(m)); + a.send(sent); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen[0]).toBe(sent); + }); + + it("createChannel() hands back one end with its peer attached", async () => { + const channel = createChannel(); + expect(typeof channel.send).toBe("function"); + expect(typeof channel.peer.send).toBe("function"); + const seen = []; + channel.peer.onMessage((m) => seen.push(m)); + channel.send({ type: "result", callId: "c1", value: 1 }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen).toHaveLength(1); + }); + + it("replays everything buffered before onMessage, in order", async () => { + const [a, b] = createPair(); + for (let i = 0; i < 5; i++) a.send({ type: "result", callId: `c${i}`, value: i }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const seen = []; + b.onMessage((m) => seen.push(m)); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen.map((f) => f.value)).toEqual([0, 1, 2, 3, 4]); + }); + + it("ignores a non-function onMessage/onClose registration", async () => { + const [a, b] = createPair(); + expect(() => b.onMessage(null)).not.toThrow(); + expect(() => b.onClose("nope")).not.toThrow(); + a.send({ type: "result", callId: "c1", value: 1 }); + a.close(); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + it("drops a frame sent to an already-closed peer", async () => { + const [a, b] = createPair(); + const seen = []; + b.onMessage((m) => seen.push(m)); + b.close(); + a.send({ type: "result", callId: "c1", value: 1 }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen).toEqual([]); + }); + + it("drops a frame whose peer closes between send and delivery", async () => { + const [a, b] = createPair(); + const seen = []; + b.onMessage((m) => seen.push(m)); + a.send({ type: "result", callId: "c1", value: 1 }); + b.close(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen).toEqual([]); + }); +}); diff --git a/tests/e2e-loopback.test.vitest.mjs b/tests/e2e-loopback.test.vitest.mjs new file mode 100644 index 0000000..3b711cd --- /dev/null +++ b/tests/e2e-loopback.test.vitest.mjs @@ -0,0 +1,376 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/e2e-loopback.test.vitest.mjs + * + * The full e2e bar from `docs/DESIGN.md` over the loopback transport, with a REAL slothlet instance + * on each side: value round-trips, remote-error re-throw, permission gating on a mounted stub, + * budget expiry, far-side death, and link teardown. + * + * This is the reference e2e every other transport's test file mirrors against its own real boundary. + */ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import slothlet from "@cldmv/slothlet"; + +import { grow, serve } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError } from "../src/lib/errors.mjs"; +import { createPair } from "../src/transport/loopback.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_DIR = path.join(here, "fixtures", "serve-api"); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); + +/** Instances + links to tear down after each test. @type {Array<() => Promise>} */ +let teardown = []; + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +/** + * Stand up a full vine over loopback: a serving instance from the serve fixtures, a growing instance + * from the grow fixtures, and a link between them. + * @param {object} [options] + * @param {object} [options.permissions] - Permission config for the GROW-side instance. + * @param {object} [options.growOptions] - Options forwarded to `grow()`. + * @param {object} [options.serveOptions] - Options forwarded to `serve()`. + * @returns {Promise<{serveApi: object, growApi: object, link: object, serving: object, near: object, far: object}>} The wired pair. + */ +async function wire({ permissions, growOptions, serveOptions } = {}) { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + const growApi = await slothlet({ base: GROW_DIR, silent: true, ...(permissions ? { permissions } : {}) }); + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + + const [near, far] = createPair(); + const serving = await serve(serveApi, far, serveOptions); + const link = await grow(growApi, near, { budgetMs: 5000, ...growOptions }); + teardown.push(async () => { + await link.close(); + serving.close(); + }); + return { serveApi, growApi, link, serving, near, far }; +} + +describe("e2e over loopback — the served surface", () => { + it("publishes only CALLABLE leaves, never data leaves or the control plane", async () => { + const { serving, link } = await wire(); + expect(serving.leaves).toEqual(["math.add", "tools.boom", "tools.echo", "tools.secret", "tools.secretCallCount", "tools.slow"]); + expect(serving.leaves).not.toContain("math.answer"); + expect(serving.leaves.some((leaf) => leaf.startsWith("slothlet"))).toBe(false); + expect(link.leaves).toEqual(serving.leaves); + expect(link.skipped).toEqual([]); + expect(link.collisions).toEqual([]); + expect(link.id).toMatch(/^vine-/); + expect(link.id).not.toContain(":"); + }); + + it("honours a paths prefix filter", async () => { + const { serving, link } = await wire({ serveOptions: { paths: ["tools"] } }); + expect(serving.leaves.every((leaf) => leaf.startsWith("tools."))).toBe(true); + expect(link.leaves).not.toContain("math.add"); + }); + + it("mounts the stubs at the identical dotted paths", async () => { + const { growApi } = await wire(); + expect(typeof growApi.math.add).toBe("function"); + expect(typeof growApi.tools.echo).toBe("function"); + }); +}); + +describe("e2e over loopback — point 1: sync + async round-trips", () => { + it("returns the right value for a sync far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.math.add(2, 3)).toBe(5); + }); + + it("returns the right value for an async far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.tools.echo("hi")).toBe("echo:hi"); + }); + + it("round-trips through a real MODULE caller, not just the host handle", async () => { + const { growApi } = await wire(); + expect(await growApi.caller.echo("via-self")).toBe("echo:via-self"); + }); + + it("keeps concurrent calls correlated", async () => { + const { growApi } = await wire(); + const results = await Promise.all([growApi.math.add(1, 1), growApi.tools.echo("a"), growApi.math.add(10, 5), growApi.tools.echo("b")]); + expect(results).toEqual([2, "echo:a", 15, "echo:b"]); + }); + + it("refuses a function argument at the edge, before anything is sent (VINE_DATA_ONLY)", async () => { + const { growApi } = await wire(); + await expect(growApi.tools.echo({ onDone: () => {} })).rejects.toMatchObject({ + code: CODES.DATA_ONLY, + path: "tools.echo", + location: "arg[0].onDone" + }); + }); +}); + +describe("e2e over loopback — point 2: remote errors re-throw as VineRemoteError", () => { + it("preserves the far error's name, message and code", async () => { + const { growApi } = await wire(); + let caught; + try { + await growApi.tools.boom(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.name).toBe("BoomError"); + expect(caught.message).toBe("kaboom from the far side"); + expect(caught.code).toBe("E_BOOM"); + expect(caught.remoteStack).toContain("kaboom from the far side"); + }); +}); + +describe("e2e over loopback — point 3: slothlet's permission gate covers mounted stubs", () => { + it("denies a module's call to a denied stub, and the call never reaches the far side", async () => { + const { growApi } = await wire({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "caller.**", target: "tools.secret", effect: "deny" }] } + }); + + let caught; + try { + await growApi.caller.secret(); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect(caught.code).toBe("PERMISSION_DENIED"); + expect(caught).not.toBeInstanceOf(VineError); + + // The gate fires BEFORE the stub body runs, so nothing crossed the boundary: the far side's + // own counter is the proof, read back over the same vine. + expect(await growApi.tools.secretCallCount()).toBe(0); + + // A leaf the same caller IS permitted to reach still works — the deny is targeted, not a + // blanket failure of the grown surface. + expect(await growApi.caller.echo("ok")).toBe("echo:ok"); + expect(await growApi.tools.secretCallCount()).toBe(0); + }); + + it("lets the same call through when no rule denies it", async () => { + const { growApi } = await wire({ permissions: { defaultPolicy: "allow", rules: [] } }); + expect(await growApi.caller.secret()).toBe("top-secret"); + expect(await growApi.tools.secretCallCount()).toBe(1); + }); +}); + +describe("e2e over loopback — point 4: VINE_BUDGET", () => { + it("settles a slow call with VINE_BUDGET and ignores the late result", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 50 } }); + let caught; + try { + await growApi.tools.slow(400); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineError); + expect(caught.code).toBe(CODES.BUDGET); + expect(caught.path).toBe("tools.slow"); + expect(caught.budgetMs).toBe(50); + + // The far side answers later; settle-once means the frame is dropped and the link stays sane. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(await growApi.math.add(1, 1)).toBe(2); + expect(link.leaves).toContain("tools.slow"); + }); + + it("does not fire the budget for a call that answers in time", async () => { + const { growApi } = await wire({ growOptions: { budgetMs: 2000 } }); + expect(await growApi.tools.slow(20)).toBe("slow:20"); + }); +}); + +describe("e2e over loopback — point 5: far-side death settles in-flight calls with VINE_GONE", () => { + it("settles pending calls and resolves link.closed", async () => { + const { growApi, link, far } = await wire({ growOptions: { budgetMs: 10_000 } }); + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 20)); + + far.close(); // the far side dies mid-call + + await expect(inFlight).rejects.toMatchObject({ code: CODES.GONE }); + await expect(link.closed).resolves.toMatchObject({ reason: "gone" }); + }); + + it("fails a call made after the far side died, without waiting for a budget", async () => { + const { growApi, far } = await wire({ growOptions: { budgetMs: 10_000 } }); + far.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const started = Date.now(); + await expect(growApi.math.add(1, 1)).rejects.toMatchObject({ code: CODES.GONE }); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe("e2e over loopback — point 6: link.close() unmounts and settles VINE_CLOSED", () => { + it("removes the stubs from the api and settles in-flight calls", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 10_000 } }); + expect(typeof growApi.tools.echo).toBe("function"); + + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 20)); + await link.close(); + + await expect(inFlight).rejects.toMatchObject({ code: CODES.CLOSED }); + expect(growApi.tools).toBeUndefined(); + expect(growApi.math).toBeUndefined(); + await expect(link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("is idempotent, and the grow instance's OWN leaves survive", async () => { + const { growApi, link } = await wire(); + await link.close(); + await link.close(); + expect(typeof growApi.caller.echo).toBe("function"); + }); +}); + +describe("e2e over loopback — serve-side re-validation and misuse", () => { + it("answers VINE_NO_LEAF for a path outside the served surface, however the frame was forged", async () => { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + const [near, far] = createPair(); + const serving = await serve(serveApi, far, { paths: ["math"] }); + teardown.push(async () => serving.close()); + + const answers = []; + near.onMessage((frame) => answers.push(frame)); + near.send({ type: "call", callId: "forged", path: "tools.secret", args: [] }); + await waitFor(() => answers.some((frame) => frame.callId === "forged")); + + const answer = answers.find((frame) => frame.callId === "forged"); + expect(answer.type).toBe("error"); + expect(answer.error.code).toBe(CODES.NO_LEAF); + }); + + it("ignores junk frames and unknown frame types entirely", async () => { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + const [near, far] = createPair(); + const serving = await serve(serveApi, far); + teardown.push(async () => serving.close()); + + const answers = []; + near.onMessage((frame) => answers.push(frame)); + for (const junk of [null, 7, "hello", { type: "nonsense" }, { type: "call", callId: "x", path: "__proto__.x", args: [] }]) { + near.send(junk); + } + near.send({ type: "call", callId: "real", path: "math.add", args: [1, 2] }); + await waitFor(() => answers.some((frame) => frame.callId === "real")); + expect(answers.filter((frame) => frame.type !== "surface" && frame.callId !== "real")).toEqual([]); + expect(answers.find((frame) => frame.callId === "real").value).toBe(3); + }); + + it("stops answering after serving.close()", async () => { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + const [near, far] = createPair(); + const serving = await serve(serveApi, far); + serving.close(); + + const answers = []; + near.onMessage((frame) => answers.push(frame)); + near.send({ type: "call", callId: "after-close", path: "math.add", args: [1, 2] }); + await new Promise((resolve) => setTimeout(resolve, 50)); + // The surface went out before close(); what must NOT arrive is an answer to the call. + expect(answers.filter((frame) => frame.type !== "surface")).toEqual([]); + }); + + it("rejects a non-slothlet api and a non-Channel channel with a TypeError", async () => { + const [near] = createPair(); + await expect(serve({}, near)).rejects.toBeInstanceOf(TypeError); + await expect(grow({}, near)).rejects.toBeInstanceOf(TypeError); + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + await expect(serve(serveApi, {})).rejects.toBeInstanceOf(TypeError); + await expect(grow(serveApi, { send() {} })).rejects.toBeInstanceOf(TypeError); + }); +}); + +describe("e2e over loopback — grow-side handshake", () => { + it("gives up on the handshake budget when the far side never publishes a surface", async () => { + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + const [near] = createPair(); + await expect(grow(growApi, near, { handshakeMs: 30 })).rejects.toMatchObject({ code: CODES.BUDGET }); + }); + + it("fails the handshake with VINE_GONE when the far side closes first", async () => { + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + const [near, far] = createPair(); + const growing = grow(growApi, near, { handshakeMs: 5000 }); + far.close(); + await expect(growing).rejects.toMatchObject({ code: CODES.GONE }); + }); + + it("reports far leaves it refuses to mount on link.skipped", async () => { + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + const [near, far] = createPair(); + far.send({ type: "surface", v: 1, leaves: ["ok.leaf", "__proto__.pwn", "outside.leaf"] }); + const link = await grow(growApi, near, { paths: ["ok"] }); + teardown.push(async () => link.close()); + + expect(link.leaves).toEqual(["ok.leaf"]); + expect(link.skipped).toEqual(expect.arrayContaining(["__proto__.pwn", "outside.leaf"])); + expect({}.pwn).toBeUndefined(); + }); + + it("reports a path the grow instance already occupies on link.collisions, without clobbering it", async () => { + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + const [near, far] = createPair(); + far.send({ type: "surface", v: 1, leaves: ["caller.echo"] }); + const link = await grow(growApi, near, { handshakeMs: 5000 }); + teardown.push(async () => link.close()); + expect(link.collisions).toEqual(["caller.echo"]); + }); +}); + +/** + * Poll until a predicate holds, failing loudly rather than hanging the suite. + * @param {() => boolean} predicate - The condition to wait for. + * @returns {Promise} Resolves once true. + */ +async function waitFor(predicate) { + const deadline = Date.now() + 3000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for the far side"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/tests/e2e-post-message.test.vitest.mjs b/tests/e2e-post-message.test.vitest.mjs new file mode 100644 index 0000000..2fe0c18 --- /dev/null +++ b/tests/e2e-post-message.test.vitest.mjs @@ -0,0 +1,493 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/e2e-post-message.test.vitest.mjs + * + * The post-message transport against BOTH the shared Channel conformance suite AND the full e2e bar + * from `docs/DESIGN.md`, run over a REAL structured-clone postMessage boundary. + * + * ## The boundary, and why it is faithful + * + * Both the conformance pair and the e2e link ride a node `worker_threads` `MessageChannel` + * (`{ port1, port2 }`). Unlike loopback — which passes frames BY REFERENCE inside one realm — a + * `worker_threads` MessageChannel serializes every frame with the structured-clone algorithm even + * when both ports live on the same thread: the receiver gets a COPY (verified: `ev.data !== sent`), + * `Date`/`Map` survive, and a frame containing a function throws `DataCloneError` at `postMessage` + * exactly as it would across a real worker. Delivery is genuinely asynchronous (a macrotask), and + * the port's own `message`/`close` events are the real ones the transport wraps in production. It is + * the SAME port surface a browser `Worker`, a browser `MessagePort`, and a real `worker_threads` + * `Worker` expose — so a same-thread MessageChannel exercises the transport's cloning boundary and + * async delivery without the extra process a real `Worker` would add, and (the point that decides it + * for the death test) node's MessagePort propagates a `'close'` to the peer, giving point 5 a real + * far-side-death signal to settle on. + */ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { MessageChannel } from "node:worker_threads"; +import slothlet from "@cldmv/slothlet"; + +import { grow, serve } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError } from "../src/lib/errors.mjs"; +import { createChannel } from "../src/transport/post-message.mjs"; +import { channelConformance } from "../src/testing/conformance.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_DIR = path.join(here, "fixtures", "serve-api"); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); +const REGRESSION_DIR = path.join(here, "fixtures", "regression-api"); + +// ── Channel conformance ──────────────────────────────────────────────────────────────────────── +// A fresh MessageChannel per pair; the harness closes both ends (which closes the ports) itself. +channelConformance( + "post-message (worker_threads MessageChannel)", + () => { + const { port1, port2 } = new MessageChannel(); + return { a: createChannel(port1), b: createChannel(port2) }; + }, + { describe, it, expect } +); + +// ── e2e over the real port boundary ────────────────────────────────────────────────────────────── + +/** Instances, links and ports to tear down after each test. @type {Array<() => Promise>} */ +let teardown = []; + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +/** + * Stand up a full vine over a worker_threads MessageChannel: a serving instance from `serveDir`, a + * growing instance from the grow fixtures, and a link between them. `grow()` is started BEFORE + * `serve()` runs so its receive handler is registered before the surface frame is posted — the + * post-message transport declares `buffersUntilHandler: false`, so a surface delivered before the + * handler exists would be dropped rather than replayed. + * @param {object} [options] + * @param {object} [options.permissions] - Permission config for the GROW-side instance. + * @param {object} [options.growOptions] - Options forwarded to `grow()`. + * @param {object} [options.serveOptions] - Options forwarded to `serve()`. + * @param {string} [options.serveDir] - Which serve fixture directory to load. + * @returns {Promise<{serveApi: object, growApi: object, link: object, serving: object, near: object, far: object}>} The wired pair. + */ +async function wire({ permissions, growOptions, serveOptions, serveDir = SERVE_DIR } = {}) { + const serveApi = await slothlet({ base: serveDir, silent: true }); + const growApi = await slothlet({ base: GROW_DIR, silent: true, ...(permissions ? { permissions } : {}) }); + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + + const { port1, port2 } = new MessageChannel(); + const near = createChannel(port1); + const far = createChannel(port2); + + const growing = grow(growApi, near, { budgetMs: 5000, ...growOptions }); + const serving = await serve(serveApi, far, serveOptions); + const link = await growing; + teardown.push(async () => { + await link.close(); + serving.close(); + near.close(); + far.close(); + }); + return { serveApi, growApi, link, serving, near, far }; +} + +describe("e2e over post-message — the served surface", () => { + it("publishes only CALLABLE leaves and mounts them at identical paths", async () => { + const { serving, link, growApi } = await wire(); + expect(serving.leaves).toEqual(["math.add", "tools.boom", "tools.echo", "tools.secret", "tools.secretCallCount", "tools.slow"]); + expect(serving.leaves).not.toContain("math.answer"); + expect(link.leaves).toEqual(serving.leaves); + expect(link.skipped).toEqual([]); + expect(link.collisions).toEqual([]); + expect(typeof growApi.math.add).toBe("function"); + expect(typeof growApi.tools.echo).toBe("function"); + }); +}); + +describe("e2e over post-message — point 1: sync + async round-trips", () => { + it("returns the right value for a sync far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.math.add(2, 3)).toBe(5); + }); + + it("returns the right value for an async far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.tools.echo("hi")).toBe("echo:hi"); + }); + + it("round-trips through a real MODULE caller, not just the host handle", async () => { + const { growApi } = await wire(); + expect(await growApi.caller.echo("via-self")).toBe("echo:via-self"); + }); + + it("keeps concurrent calls correlated across the clone boundary", async () => { + const { growApi } = await wire(); + const results = await Promise.all([growApi.math.add(1, 1), growApi.tools.echo("a"), growApi.math.add(10, 5), growApi.tools.echo("b")]); + expect(results).toEqual([2, "echo:a", 15, "echo:b"]); + }); + + it("refuses a function ARGUMENT at the edge, before anything is posted (VINE_DATA_ONLY)", async () => { + const { growApi } = await wire(); + await expect(growApi.tools.echo({ onDone: () => {} })).rejects.toMatchObject({ + code: CODES.DATA_ONLY, + path: "tools.echo", + location: "arg[0].onDone" + }); + }); +}); + +describe("e2e over post-message — point 2: remote errors re-throw as VineRemoteError", () => { + it("preserves the far error's name, message and code", async () => { + const { growApi } = await wire(); + let caught; + try { + await growApi.tools.boom(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.name).toBe("BoomError"); + expect(caught.message).toBe("kaboom from the far side"); + expect(caught.code).toBe("E_BOOM"); + expect(caught.remoteStack).toContain("kaboom from the far side"); + }); +}); + +describe("e2e over post-message — point 3: slothlet's permission gate covers mounted stubs", () => { + it("denies a module's call to a denied stub, and the call never crosses the boundary", async () => { + const { growApi } = await wire({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "caller.**", target: "tools.secret", effect: "deny" }] } + }); + + let caught; + try { + await growApi.caller.secret(); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect(caught.code).toBe("PERMISSION_DENIED"); + expect(caught).not.toBeInstanceOf(VineError); + + // The gate fires BEFORE the stub body runs, so nothing was posted: the far side's own counter, + // read back over the same vine, is the proof. + expect(await growApi.tools.secretCallCount()).toBe(0); + + // A leaf the same caller IS permitted to reach still works — the deny is targeted. + expect(await growApi.caller.echo("ok")).toBe("echo:ok"); + expect(await growApi.tools.secretCallCount()).toBe(0); + }); + + it("lets the same call through when no rule denies it", async () => { + const { growApi } = await wire({ permissions: { defaultPolicy: "allow", rules: [] } }); + expect(await growApi.caller.secret()).toBe("top-secret"); + expect(await growApi.tools.secretCallCount()).toBe(1); + }); +}); + +describe("e2e over post-message — point 4: VINE_BUDGET", () => { + it("settles a slow call with VINE_BUDGET and ignores the late result", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 50 } }); + let caught; + try { + await growApi.tools.slow(400); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineError); + expect(caught.code).toBe(CODES.BUDGET); + expect(caught.path).toBe("tools.slow"); + expect(caught.budgetMs).toBe(50); + + // The far side answers later; settle-once means the frame is dropped and the link stays sane. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(await growApi.math.add(1, 1)).toBe(2); + expect(link.leaves).toContain("tools.slow"); + }); + + it("does not fire the budget for a call that answers in time", async () => { + const { growApi } = await wire({ growOptions: { budgetMs: 2000 } }); + expect(await growApi.tools.slow(20)).toBe("slow:20"); + }); +}); + +describe("e2e over post-message — point 5: far-side death settles in-flight calls with VINE_GONE", () => { + it("settles pending calls and resolves link.closed when the far port closes", async () => { + const { growApi, link, far } = await wire({ growOptions: { budgetMs: 10_000 } }); + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 20)); + + far.close(); // closing the serve-side port fires 'close' on the grow-side port + + await expect(inFlight).rejects.toMatchObject({ code: CODES.GONE }); + await expect(link.closed).resolves.toMatchObject({ reason: "gone" }); + }); + + it("fails a call made after the far side died, without waiting for a budget", async () => { + const { growApi, far } = await wire({ growOptions: { budgetMs: 10_000 } }); + far.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const started = Date.now(); + await expect(growApi.math.add(1, 1)).rejects.toMatchObject({ code: CODES.GONE }); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe("e2e over post-message — point 6: link.close() unmounts and settles VINE_CLOSED", () => { + it("removes the stubs from the api and settles in-flight calls", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 10_000 } }); + expect(typeof growApi.tools.echo).toBe("function"); + + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 20)); + await link.close(); + + await expect(inFlight).rejects.toMatchObject({ code: CODES.CLOSED }); + expect(growApi.tools).toBeUndefined(); + expect(growApi.math).toBeUndefined(); + await expect(link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("is idempotent, and the grow instance's OWN leaves survive", async () => { + const { growApi, link } = await wire(); + await link.close(); + await link.close(); + expect(typeof growApi.caller.echo).toBe("function"); + }); +}); + +describe("e2e over post-message — data-only return values are rejected serve-side, never posted", () => { + it("surfaces a function-valued return as VINE_REMOTE / remoteCode VINE_DATA_ONLY (serve rejects before send)", async () => { + // A raw DataCloneError would prove the frame reached postMessage; instead the serve side finds + // the function first and answers with an error frame, so grow re-throws it as a remote error. + const { growApi } = await wire({ serveDir: REGRESSION_DIR }); + let caught; + try { + await growApi.factory.make(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.code).toBe(CODES.REMOTE); + expect(caught.remoteCode).toBe(CODES.DATA_ONLY); + expect(caught.message).toContain("data-only"); + }); + + it("still round-trips ordinary data untouched over the clone boundary", async () => { + const { growApi } = await wire({ serveDir: REGRESSION_DIR }); + expect(await growApi.factory.plain()).toEqual({ ok: 1, list: [1, 2, 3] }); + }); +}); + +// ── Transport-specific unit assertions (the branches the wired e2e cannot reach on its own) ─────── + +describe("post-message transport specifics", () => { + it("declares its capabilities on both ends", () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1); + const b = createChannel(port2); + for (const channel of [a, b]) { + expect(channel.capabilities).toEqual({ structuredClone: true, codec: "none", buffersUntilHandler: false }); + } + a.close(); + b.close(); + }); + + it("rejects a non-port with a TypeError", () => { + expect(() => createChannel(null)).toThrow(TypeError); + expect(() => createChannel({})).toThrow(TypeError); + expect(() => createChannel({ postMessage: 7 })).toThrow(TypeError); + }); + + it("structured-clones the frame — the receiver gets a copy, not the same reference", async () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1); + const b = createChannel(port2); + const sent = { type: "result", callId: "c1", value: { deep: { n: 1 } } }; + const seen = []; + b.onMessage((m) => seen.push(m)); + a.send(sent); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toHaveLength(1); + expect(seen[0]).not.toBe(sent); + expect(seen[0]).toEqual(sent); + a.close(); + b.close(); + }); + + it("drops frames that arrive before onMessage is registered (buffersUntilHandler: false)", async () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1); + const b = createChannel(port2); + a.send({ type: "result", callId: "early", value: "early" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const seen = []; + b.onMessage((m) => seen.push(m.callId)); + a.send({ type: "result", callId: "late", value: "late" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toEqual(["late"]); + a.close(); + b.close(); + }); + + it("send() after close is a silent no-op, and close() is idempotent", async () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1); + const b = createChannel(port2); + const seen = []; + b.onMessage((m) => seen.push(m)); + a.close(); + expect(() => a.close()).not.toThrow(); + expect(() => a.send({ type: "result", callId: "c1", value: 1 })).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toEqual([]); + b.close(); + }); + + it("ignores a non-function onMessage / onClose registration", async () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1); + const b = createChannel(port2); + expect(() => b.onMessage(null)).not.toThrow(); + expect(() => b.onClose("nope")).not.toThrow(); + a.send({ type: "result", callId: "c1", value: 1 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + a.close(); + b.close(); + }); + + it("insulates the transport from a throwing receive handler", async () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1); + const b = createChannel(port2); + b.onMessage(() => { + throw new Error("handler blew up"); + }); + expect(() => a.send({ type: "result", callId: "c1", value: 1 })).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const seen = []; + b.onMessage((m) => seen.push(m.callId)); + a.send({ type: "result", callId: "c2", value: 2 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toEqual(["c2"]); + a.close(); + b.close(); + }); + + it("swallows a postMessage that throws rather than faulting the core", () => { + let calls = 0; + const flakyPort = { + postMessage() { + calls++; + throw new Error("port refused the frame"); + }, + addEventListener() {}, + removeEventListener() {}, + close() {} + }; + const channel = createChannel(flakyPort); + expect(() => channel.send({ type: "result", callId: "c1", value: 1 })).not.toThrow(); + expect(calls).toBe(1); + channel.close(); + }); + + it("works over a legacy onmessage= port with no addEventListener, and detaches on close", async () => { + // A port that exposes ONLY the `onX=` setter surface — the fallback path a browser-legacy or + // minimal port takes. Message delivery must still work; close() must null the setter. + const listeners = { onmessage: null, onmessageerror: null }; + const legacyPort = { + postMessage(frame) { + // Echo the frame back to our own onmessage, wrapped as an event, on the next tick. + queueMicrotask(() => listeners.onmessage?.({ data: frame })); + }, + set onmessage(fn) { + listeners.onmessage = fn; + }, + get onmessage() { + return listeners.onmessage; + }, + set onmessageerror(fn) { + listeners.onmessageerror = fn; + }, + get onmessageerror() { + return listeners.onmessageerror; + }, + close() {} + }; + const channel = createChannel(legacyPort); + const seen = []; + channel.onMessage((m) => seen.push(m.callId)); + channel.send({ type: "result", callId: "legacy", value: 1 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toEqual(["legacy"]); + expect(typeof listeners.onmessage).toBe("function"); + channel.close(); + expect(listeners.onmessage).toBeNull(); + }); + + it("unwraps a raw frame (no event.data wrapper) from a minimal port, and fires its onmessageerror death setter", async () => { + // A minimal legacy port that hands the frame straight to onmessage — no MessageEvent wrapper — + // exercises the defensive `: event` fallback, and its onmessageerror setter drives death detection. + const listeners = { onmessage: null, onmessageerror: null }; + const rawPort = { + postMessage(frame) { + queueMicrotask(() => listeners.onmessage?.(frame)); // raw object, no `.data` + }, + set onmessage(fn) { + listeners.onmessage = fn; + }, + get onmessage() { + return listeners.onmessage; + }, + set onmessageerror(fn) { + listeners.onmessageerror = fn; + }, + get onmessageerror() { + return listeners.onmessageerror; + } + // no close() — close() must tolerate the missing method + }; + const channel = createChannel(rawPort); + const seen = []; + channel.onMessage((m) => seen.push(m.callId)); + channel.send({ type: "result", callId: "raw", value: 1 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toEqual(["raw"]); + + let deathReason; + channel.onClose((info) => { + deathReason = info?.reason; + }); + listeners.onmessageerror(); // the medium reports a failed deserialize + expect(deathReason).toBe("messageerror"); + expect(() => channel.close()).not.toThrow(); // no close() on the port + }); + + it("fires onClose exactly once with the event reason, and lets extra deathEvents opt in", async () => { + const { port1, port2 } = new MessageChannel(); + const a = createChannel(port1, { deathEvents: ["exit"] }); // union with the defaults; harmless here + const b = createChannel(port2); + let fired = 0; + let reason; + a.onClose((info) => { + fired++; + reason = info?.reason; + }); + b.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fired).toBe(1); + expect(reason).toBe("close"); + a.close(); + }); +}); diff --git a/tests/e2e-process.test.vitest.mjs b/tests/e2e-process.test.vitest.mjs new file mode 100644 index 0000000..8ffb585 --- /dev/null +++ b/tests/e2e-process.test.vitest.mjs @@ -0,0 +1,430 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/e2e-process.test.vitest.mjs + * + * The process (child_process IPC) transport: the shared Channel conformance suite PLUS the full e2e + * bar from `docs/DESIGN.md`, the latter over a REAL forked child process. + * + * ## Two boundaries, on purpose + * + * - **Conformance** runs against an IN-MEMORY duplex that mimics the exact surface the transport + * consumes — `send(msg, cb)`, `on("message"|"exit"|"disconnect"|"error")`, `connected`, + * `disconnect()` — with structured-clone delivery on `setImmediate` (advanced-serialization + * fidelity, asynchronous like a real IPC hop). Forking a fresh child for each of the ~15 conformance + * cases would be needlessly heavy and slow, and the conformance suite tests the CHANNEL contract, not + * the OS boundary; the fake reproduces the surface faithfully, so the contract is exercised honestly. + * Both fake ends are wrapped with `createChannel` (the parent-side endpoint) so `close()`/`onClose` + * have their disconnecting semantics. + * - **The 6-point e2e** uses a REAL `fork(...)` with `{ serialization: "advanced" }`: the serve side + * boots a real slothlet instance in the child (`tests/fixtures/proc-serve-child.mjs`) and the grow + * side runs in this process. Nothing here is faked — killing the child is a real SIGTERM, and the + * parent detects the death over the real IPC channel. + * + * The child-side endpoint (`createParentChannel`) is covered directly by a specifics test against the + * fake (attaching it to the real vitest IPC would corrupt vitest's own result channel) and end-to-end + * inside the real forked child. + */ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { fork } from "node:child_process"; +import { EventEmitter } from "node:events"; +import slothlet from "@cldmv/slothlet"; + +import { grow } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError } from "../src/lib/errors.mjs"; +import { channelConformance } from "../src/testing/conformance.mjs"; +import { createChannel, createParentChannel } from "../src/transport/process.mjs"; + +// The SERVE side boots inside the forked child (see fixtures/proc-serve-child.mjs); the grow side — +// and therefore the only slothlet instance this process builds — is the grow-api fixture. +const here = path.dirname(fileURLToPath(import.meta.url)); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); +const CHILD = path.join(here, "fixtures", "proc-serve-child.mjs"); + +/* ──────────────────────────────────────────────────────────────────────────────────────────────── + * In-memory duplex mimicking the ChildProcess IPC surface, for conformance. + * ──────────────────────────────────────────────────────────────────────────────────────────────── */ + +/** + * A pair of connected fakes, each mimicking the slice of `ChildProcess` the transport touches. A + * `send` on one delivers, structured-cloned and on the next `setImmediate`, as a `"message"` event on + * the other; `disconnect()` on either flips both to disconnected and emits `"disconnect"` on both. + * @returns {{ a: EventEmitter, b: EventEmitter }} The two fake endpoints. + */ +function makeFakeChildPair() { + const a = new EventEmitter(); + const b = new EventEmitter(); + a.connected = true; + b.connected = true; + + /** + * @param {EventEmitter} from - Sender. + * @param {EventEmitter} to - Receiver. + * @returns {(message: object, cb?: (err: Error|null) => void) => boolean} A ChildProcess-like send. + */ + function makeSend(from, to) { + return (message, cb) => { + if (!from.connected) { + const err = new Error("channel closed"); + if (typeof cb === "function") setImmediate(() => cb(err)); + else throw err; + return false; + } + // structuredClone mirrors advanced (V8) serialization — a real Date/Map/Set survives the hop. + const cloned = structuredClone(message); + setImmediate(() => { + if (to.connected) to.emit("message", cloned); + }); + if (typeof cb === "function") setImmediate(() => cb(null)); + return true; + }; + } + + /** + * @returns {void} Flip both ends disconnected and notify both (idempotent). + */ + function disconnect() { + if (!a.connected && !b.connected) return; + a.connected = false; + b.connected = false; + setImmediate(() => { + a.emit("disconnect"); + b.emit("disconnect"); + }); + } + + a.send = makeSend(a, b); + b.send = makeSend(b, a); + a.disconnect = disconnect; + b.disconnect = disconnect; + return { a, b }; +} + +channelConformance( + "process (in-memory fake)", + () => { + const { a, b } = makeFakeChildPair(); + return [createChannel(a), createChannel(b)]; + }, + { describe, it, expect } +); + +/* ──────────────────────────────────────────────────────────────────────────────────────────────── + * Transport specifics — validation + the child-side endpoint against the fake. + * ──────────────────────────────────────────────────────────────────────────────────────────────── */ + +/** @returns {Promise} Let queued setImmediate deliveries run. */ +function tick() { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe("process transport specifics", () => { + it("declares its capabilities", () => { + const { a } = makeFakeChildPair(); + expect(createChannel(a).capabilities).toEqual({ structuredClone: true, codec: "none", buffersUntilHandler: false }); + }); + + it("rejects a non-ChildProcess to createChannel", () => { + expect(() => createChannel(null)).toThrow(TypeError); + expect(() => createChannel({})).toThrow(TypeError); + expect(() => createChannel({ send() {} })).toThrow(TypeError); + }); + + it("rejects a process without an IPC channel to createParentChannel", () => { + expect(() => createParentChannel({})).toThrow(TypeError); + expect(() => createParentChannel(null)).toThrow(TypeError); + }); + + it("child endpoint sends, receives, and detects the parent disconnecting", async () => { + const { a, b } = makeFakeChildPair(); + const parent = createChannel(a); + const child = createParentChannel(b); + + const got = []; + child.onMessage((m) => got.push(m)); + let closedInfo; + child.onClose((info) => { + closedInfo = info; + }); + + parent.send({ type: "surface", v: 1, leaves: ["x.y"] }); + await tick(); + expect(got).toHaveLength(1); + expect(got[0].leaves).toEqual(["x.y"]); + + // The parent tears down → the child learns of it via onClose. The child's own close() detaches + // without touching the channel and a send afterwards is a silent no-op. + parent.close(); + await tick(); + expect(closedInfo).toBeTruthy(); + expect(() => child.close()).not.toThrow(); + expect(() => child.close()).not.toThrow(); + expect(() => child.send({ type: "call", callId: "z", path: "x.y", args: [] })).not.toThrow(); + }); + + it("surfaces a send onto a dead channel through onClose rather than throwing", async () => { + const { a, b } = makeFakeChildPair(); + const parent = createChannel(a); + let closedInfo; + parent.onClose((info) => { + closedInfo = info; + }); + b.disconnect(); // both ends now disconnected + await tick(); + expect(() => parent.send({ type: "call", callId: "1", path: "p", args: [] })).not.toThrow(); + expect(closedInfo).toBeTruthy(); + }); + + it("treats the child's 'error' event as far-side death", () => { + const { a } = makeFakeChildPair(); + const parent = createChannel(a); + let closedInfo; + parent.onClose((info) => { + closedInfo = info; + }); + a.emit("error", new Error("spawn failed")); // a real ChildProcess error event + expect(closedInfo).toMatchObject({ reason: "error" }); + expect(closedInfo.error).toBeInstanceOf(Error); + }); + + it("RETHROWS a synchronous serialization refusal without firing onClose (per-call, not link death)", () => { + // child.send throws synchronously with NO dead-channel code when the V8 serializer rejects a + // value (a Symbol, a value hiding a function). That is a per-call fault: the transport must let + // it propagate so the core settles just that call VINE_BAD_FRAME — and must NOT declare the link + // dead. (The whole-link-death behavior this replaces was the final-review defect.) + const target = new EventEmitter(); + target.connected = true; + target.send = () => { + throw new TypeError("could not be cloned"); // no .code — a serializer refusal, not a dead channel + }; + const channel = createChannel(target); + let closedInfo; + channel.onClose((info) => { + closedInfo = info; + }); + expect(() => channel.send({ type: "call", callId: "1", path: "p", args: [] })).toThrow(/could not be cloned/); + expect(closedInfo).toBeUndefined(); // the link is NOT gone — only that one frame was refused + }); + + it("surfaces a DEAD-channel send throw through onClose, without rethrowing", () => { + // A send throw carrying a dead-channel code (ERR_IPC_CHANNEL_CLOSED / EPIPE) is real link death: + // the transport swallows it and reports it through onClose, so a caller that only ever sends + // still learns the link is gone. + const target = new EventEmitter(); + target.connected = true; + target.send = () => { + const err = new Error("channel closed"); + err.code = "ERR_IPC_CHANNEL_CLOSED"; + throw err; + }; + const channel = createChannel(target); + let closedInfo; + channel.onClose((info) => { + closedInfo = info; + }); + expect(() => channel.send({ type: "call", callId: "1", path: "p", args: [] })).not.toThrow(); + expect(closedInfo).toMatchObject({ reason: "error" }); + expect(closedInfo.error.code).toBe("ERR_IPC_CHANNEL_CLOSED"); + }); +}); + +/* ──────────────────────────────────────────────────────────────────────────────────────────────── + * The e2e bar over a REAL forked child. + * ──────────────────────────────────────────────────────────────────────────────────────────────── */ + +/** Instances, links and children to tear down after each test. @type {Array<() => Promise|void>} */ +let teardown = []; + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +/** + * Stand up a full vine over a real forked child: the serving instance boots inside the child + * (fixture `proc-serve-child.mjs`), the growing instance runs here, linked over the process IPC + * channel. The child is forked with advanced serialization, as a real consumer must. + * @param {object} [options] + * @param {object} [options.permissions] - Permission config for the GROW-side instance. + * @param {object} [options.growOptions] - Options forwarded to `grow()`. + * @returns {Promise<{growApi: object, link: object, child: import("node:child_process").ChildProcess}>} + */ +async function wire({ permissions, growOptions } = {}) { + const growApi = await slothlet({ base: GROW_DIR, silent: true, ...(permissions ? { permissions } : {}) }); + const child = fork(CHILD, [], { serialization: "advanced" }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + teardown.push(() => { + if (child.connected || child.exitCode === null) child.kill(); + }); + + const channel = createChannel(child); + const link = await grow(growApi, channel, { budgetMs: 5000, ...growOptions }); + teardown.push(async () => { + await link.close(); + }); + return { growApi, link, child }; +} + +describe("e2e over process — the served surface", () => { + it("mounts the far surface at identical dotted paths", async () => { + const { growApi, link } = await wire(); + expect(link.leaves).toEqual(["math.add", "tools.boom", "tools.echo", "tools.secret", "tools.secretCallCount", "tools.slow"]); + expect(link.leaves.some((leaf) => leaf.startsWith("slothlet"))).toBe(false); + expect(typeof growApi.math.add).toBe("function"); + expect(typeof growApi.tools.echo).toBe("function"); + }); +}); + +describe("e2e over process — point 1: sync + async round-trips", () => { + it("returns the right value for a sync far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.math.add(2, 3)).toBe(5); + }); + + it("returns the right value for an async far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.tools.echo("hi")).toBe("echo:hi"); + }); + + it("round-trips through a real MODULE caller, not just the host handle", async () => { + const { growApi } = await wire(); + expect(await growApi.caller.echo("via-self")).toBe("echo:via-self"); + }); + + it("keeps concurrent calls correlated across the boundary", async () => { + const { growApi } = await wire(); + const results = await Promise.all([growApi.math.add(1, 1), growApi.tools.echo("a"), growApi.math.add(10, 5), growApi.tools.echo("b")]); + expect(results).toEqual([2, "echo:a", 15, "echo:b"]); + }); +}); + +describe("e2e over process — point 2: remote errors re-throw as VineRemoteError", () => { + it("preserves the far error's name, message and code", async () => { + const { growApi } = await wire(); + let caught; + try { + await growApi.tools.boom(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.name).toBe("BoomError"); + expect(caught.message).toBe("kaboom from the far side"); + expect(caught.code).toBe("E_BOOM"); + expect(caught.remoteStack).toContain("kaboom from the far side"); + }); +}); + +describe("e2e over process — point 3: slothlet's permission gate covers mounted stubs", () => { + it("denies a module's call to a denied stub, and the call never reaches the far side", async () => { + const { growApi } = await wire({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "caller.**", target: "tools.secret", effect: "deny" }] } + }); + + let caught; + try { + await growApi.caller.secret(); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect(caught.code).toBe("PERMISSION_DENIED"); + expect(caught).not.toBeInstanceOf(VineError); + + // The gate fires before the stub body runs, so nothing crossed the boundary — the far side's own + // counter, read back over the same vine, is the proof. + expect(await growApi.tools.secretCallCount()).toBe(0); + + // A leaf the same caller IS permitted to reach still works — the deny is targeted. + expect(await growApi.caller.echo("ok")).toBe("echo:ok"); + expect(await growApi.tools.secretCallCount()).toBe(0); + }); + + it("lets the same call through when no rule denies it", async () => { + const { growApi } = await wire({ permissions: { defaultPolicy: "allow", rules: [] } }); + expect(await growApi.caller.secret()).toBe("top-secret"); + expect(await growApi.tools.secretCallCount()).toBe(1); + }); +}); + +describe("e2e over process — point 4: VINE_BUDGET", () => { + it("settles a slow call with VINE_BUDGET and ignores the late result", async () => { + // budgetMs is the per-CALL budget; handshakeMs is kept generous because a real fork + slothlet + // boot takes far longer than 50ms to publish its surface (unlike the in-process loopback). + const { growApi, link } = await wire({ growOptions: { budgetMs: 50, handshakeMs: 5000 } }); + let caught; + try { + await growApi.tools.slow(400); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineError); + expect(caught.code).toBe(CODES.BUDGET); + expect(caught.path).toBe("tools.slow"); + expect(caught.budgetMs).toBe(50); + + // The far side answers later; settle-once drops the frame and the link stays sane. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(await growApi.math.add(1, 1)).toBe(2); + expect(link.leaves).toContain("tools.slow"); + }); + + it("does not fire the budget for a call that answers in time", async () => { + const { growApi } = await wire({ growOptions: { budgetMs: 2000 } }); + expect(await growApi.tools.slow(20)).toBe("slow:20"); + }); +}); + +describe("e2e over process — point 5: killing the child settles in-flight calls with VINE_GONE", () => { + it("settles pending calls and resolves link.closed when the child is killed mid-call", async () => { + const { growApi, link, child } = await wire({ growOptions: { budgetMs: 10_000 } }); + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 50)); + + child.kill(); // real SIGTERM — the child dies with the call still in flight + + await expect(inFlight).rejects.toMatchObject({ code: CODES.GONE }); + await expect(link.closed).resolves.toMatchObject({ reason: "gone" }); + }); + + it("fails a call made after the child died, without waiting for a budget", async () => { + const { growApi, child } = await wire({ growOptions: { budgetMs: 10_000 } }); + child.kill(); + await new Promise((resolve) => setTimeout(resolve, 100)); + const started = Date.now(); + await expect(growApi.math.add(1, 1)).rejects.toMatchObject({ code: CODES.GONE }); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe("e2e over process — point 6: link.close() unmounts and settles VINE_CLOSED", () => { + it("removes the stubs from the api and settles in-flight calls", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 10_000 } }); + expect(typeof growApi.tools.echo).toBe("function"); + + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 50)); + await link.close(); + + await expect(inFlight).rejects.toMatchObject({ code: CODES.CLOSED }); + expect(growApi.tools).toBeUndefined(); + expect(growApi.math).toBeUndefined(); + await expect(link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("is idempotent, and the grow instance's OWN leaves survive", async () => { + const { growApi, link } = await wire(); + await link.close(); + await link.close(); + expect(typeof growApi.caller.echo).toBe("function"); + }); +}); diff --git a/tests/e2e-websocket.test.vitest.mjs b/tests/e2e-websocket.test.vitest.mjs new file mode 100644 index 0000000..6994068 --- /dev/null +++ b/tests/e2e-websocket.test.vitest.mjs @@ -0,0 +1,451 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/e2e-websocket.test.vitest.mjs + * + * The websocket transport against BOTH the shared Channel conformance suite and the full e2e bar from + * `docs/DESIGN.md`, over a REAL `ws` connection on an EPHEMERAL (OS-assigned, port 0) port. + * + * The boundary is a genuine network hop over localhost: a real `WebSocketServer`, a real client + * socket, the serve side running on the server-accepted socket and the grow side on the client + * socket. Every server + socket is torn down in `afterEach` / `afterAll` so the test process exits + * with no leaked handles or held ports, and every port is ephemeral — never a fixed one. + */ +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocketServer, WebSocket } from "ws"; +import slothlet from "@cldmv/slothlet"; + +import { grow, serve } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError } from "../src/lib/errors.mjs"; +import { createChannel, connect } from "../src/transport/websocket.mjs"; +import { channelConformance } from "../src/testing/conformance.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_DIR = path.join(here, "fixtures", "serve-api"); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); +const CODEC_DIR = path.join(here, "fixtures", "codec-api"); + +/** Every server we stand up, torn down in afterAll as a final backstop. @type {Set} */ +const servers = new Set(); + +/** + * Stand up a real server + client pair on an ephemeral port and return both connected sockets. + * @returns {Promise<{wss: import("ws").WebSocketServer, serverSocket: object, clientSocket: object}>} The live pair. + */ +async function standUpPair() { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + servers.add(wss); + await once(wss, "listening"); + const { port } = wss.address(); + const serverSocketReady = once(wss, "connection").then(([socket]) => socket); + const clientSocket = new WebSocket(`ws://127.0.0.1:${port}`); + const [serverSocket] = await Promise.all([serverSocketReady, once(clientSocket, "open")]); + return { wss, serverSocket, clientSocket }; +} + +/** + * Close a server and wait for it to actually release its port. + * @param {import("ws").WebSocketServer} wss - The server to close. + * @returns {Promise} Resolves once closed. + */ +function closeServer(wss) { + servers.delete(wss); + return new Promise((resolve) => wss.close(() => resolve())); +} + +/** + * Hard-tear a whole pair: terminate both sockets and release the port. Safe to call repeatedly. + * @param {{wss: import("ws").WebSocketServer, serverSocket: object, clientSocket: object}} pair - The pair. + * @returns {Promise} Resolves once fully torn down. + */ +async function tearDownPair(pair) { + try { + pair.clientSocket?.terminate?.(); + } catch { + // already gone + } + try { + pair.serverSocket?.terminate?.(); + } catch { + // already gone + } + await closeServer(pair.wss); +} + +/** Teardown callbacks to run (in reverse) after each e2e test. @type {Array<() => Promise | void>} */ +let teardown = []; + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +afterAll(async () => { + for (const wss of servers) { + try { + await new Promise((resolve) => wss.close(() => resolve())); + } catch { + // backstop only + } + } + servers.clear(); +}); + +/** + * Stand up a full vine over a real ws boundary: a serving instance on the server-accepted socket, a + * growing instance on the client socket, and a link between them. + * @param {object} [options] + * @param {object} [options.permissions] - Permission config for the GROW-side instance. + * @param {object} [options.growOptions] - Options forwarded to `grow()`. + * @param {object} [options.serveOptions] - Options forwarded to `serve()`. + * @param {string} [options.serveDir] - Which serve fixture directory to load (default: `serve-api`). + * @returns {Promise<{serveApi: object, growApi: object, link: object, serving: object, serverSocket: object, clientSocket: object, wss: object}>} The wired pair. + */ +async function wire({ permissions, growOptions, serveOptions, serveDir = SERVE_DIR } = {}) { + const serveApi = await slothlet({ base: serveDir, silent: true }); + const growApi = await slothlet({ base: GROW_DIR, silent: true, ...(permissions ? { permissions } : {}) }); + + const pair = await standUpPair(); + const far = createChannel(pair.serverSocket); // serve end — the server-accepted socket + const near = createChannel(pair.clientSocket); // grow end — the client socket + + const serving = await serve(serveApi, far, serveOptions); + const link = await grow(growApi, near, { budgetMs: 5000, ...growOptions }); + + teardown.push(async () => { + await serveApi.slothlet?.shutdown?.(); + }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + teardown.push(async () => { + await tearDownPair(pair); + }); + teardown.push(() => { + serving.close(); + }); + teardown.push(async () => { + await link.close(); + }); + + return { serveApi, growApi, link, serving, serverSocket: pair.serverSocket, clientSocket: pair.clientSocket, wss: pair.wss }; +} + +describe("e2e over websocket — the JSON codec degrades predictably (never corrupts or crashes)", () => { + it("returns a Date as an ISO string, and echoes a Date argument back as its ISO string", async () => { + const { growApi } = await wire({ serveDir: CODEC_DIR }); + // A Date crosses as the ISO string JSON produced — not a live Date, not a corrupted frame. + expect(await growApi.codec.when()).toBe("2020-01-02T03:04:05.000Z"); + // Same on the ARGUMENT path: the leaf echoes the value it received, and it received the ISO string. + expect(await growApi.codec.echo(new Date("2021-06-07T08:09:10.000Z"))).toBe("2021-06-07T08:09:10.000Z"); + }); + + it("returns a Map and a Set as empty objects — lossy but valid, not a crash", async () => { + const { growApi, link } = await wire({ serveDir: CODEC_DIR }); + expect(await growApi.codec.pairs()).toEqual({}); + expect(await growApi.codec.members()).toEqual({}); + // The link is unharmed by the lossy round-trips and keeps forwarding. + expect(await growApi.codec.when()).toBe("2020-01-02T03:04:05.000Z"); + expect(link.leaves).toContain("codec.echo"); + }); +}); + +// ── The shared Channel conformance suite, over a real ws pair ──────────────────────────────────── +channelConformance( + "websocket", + async () => { + const pair = await standUpPair(); + return { + a: createChannel(pair.serverSocket), + b: createChannel(pair.clientSocket), + cleanup: () => tearDownPair(pair) + }; + }, + { describe, it, expect } +); + +describe("websocket specifics", () => { + it("declares byte-transport capabilities (json codec, no structured clone, no pre-handler buffer)", async () => { + const pair = await standUpPair(); + try { + for (const socket of [pair.serverSocket, pair.clientSocket]) { + expect(createChannel(socket).capabilities).toEqual({ structuredClone: false, codec: "json", buffersUntilHandler: false }); + } + } finally { + await tearDownPair(pair); + } + }); + + it("rejects a non-ws socket with a TypeError", () => { + expect(() => createChannel(null)).toThrow(TypeError); + expect(() => createChannel({ send() {} })).toThrow(TypeError); + }); + + it("connect() builds a working client channel over a real server", async () => { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + servers.add(wss); + await once(wss, "listening"); + const accepted = once(wss, "connection").then(([socket]) => socket); + const { port } = wss.address(); + + const clientChannel = await connect(`ws://127.0.0.1:${port}`); + const serverSocket = await accepted; + const serverChannel = createChannel(serverSocket); + try { + const received = []; + serverChannel.onMessage((frame) => received.push(frame)); + clientChannel.send({ type: "call", callId: "c1", path: "x.y", args: [1] }); + await waitFor(() => received.length > 0); + expect(received[0]).toEqual({ type: "call", callId: "c1", path: "x.y", args: [1] }); + } finally { + clientChannel.close(); + try { + serverSocket.terminate(); + } catch { + // already gone + } + await closeServer(wss); + } + }); + + it("drops a malformed (non-JSON) payload instead of throwing into the socket", async () => { + const pair = await standUpPair(); + try { + const channel = createChannel(pair.clientSocket); + const received = []; + channel.onMessage((frame) => received.push(frame)); + // Send raw garbage straight down the wire, bypassing the channel's encoder. + pair.serverSocket.send("this is not json {"); + pair.serverSocket.send(JSON.stringify({ type: "result", callId: "ok", value: 1 })); + await waitFor(() => received.some((frame) => frame.callId === "ok")); + expect(received).toEqual([{ type: "result", callId: "ok", value: 1 }]); + } finally { + await tearDownPair(pair); + } + }); + + it("RETHROWS an un-encodable frame (BigInt) as a per-call refusal, leaving the socket usable", async () => { + const pair = await standUpPair(); + try { + const channel = createChannel(pair.clientSocket); + // JSON.stringify throws on a BigInt — the codec REFUSES the frame. send() re-raises it so the + // core settles just that call VINE_BAD_FRAME; it must NOT kill the socket. + expect(() => channel.send({ type: "call", callId: "big", path: "p", args: [1n] })).toThrow(TypeError); + // The socket is unharmed — a subsequent, encodable frame still sends without throwing. + expect(() => channel.send({ type: "call", callId: "ok", path: "p", args: [1] })).not.toThrow(); + } finally { + await tearDownPair(pair); + } + }); + + it("reports a real connection failure through onClose (the socket 'error' path)", async () => { + // Bind a server, learn its port, then fully release it — a client that then connects gets + // ECONNREFUSED, which surfaces as a socket 'error' the transport must report as a death. + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(wss, "listening"); + const { port } = wss.address(); + await new Promise((resolve) => wss.close(() => resolve())); + + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + const channel = createChannel(socket); + try { + let info; + channel.onClose((closeInfo) => { + info = closeInfo; + }); + await waitFor(() => info !== undefined); + expect(info.reason).toBe("error"); + } finally { + try { + socket.terminate(); + } catch { + // already gone + } + } + }); +}); + +describe("e2e over websocket — the served surface", () => { + it("mounts the far leaves at their identical dotted paths", async () => { + const { serving, link, growApi } = await wire(); + expect(serving.leaves).toEqual(["math.add", "tools.boom", "tools.echo", "tools.secret", "tools.secretCallCount", "tools.slow"]); + expect(link.leaves).toEqual(serving.leaves); + expect(link.skipped).toEqual([]); + expect(link.collisions).toEqual([]); + expect(typeof growApi.math.add).toBe("function"); + expect(typeof growApi.tools.echo).toBe("function"); + }); +}); + +describe("e2e over websocket — point 1: sync + async round-trips", () => { + it("returns the right value for a sync far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.math.add(2, 3)).toBe(5); + }); + + it("returns the right value for an async far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.tools.echo("hi")).toBe("echo:hi"); + }); + + it("round-trips through a real MODULE caller, not just the host handle", async () => { + const { growApi } = await wire(); + expect(await growApi.caller.echo("via-self")).toBe("echo:via-self"); + }); + + it("keeps concurrent calls correlated across the wire", async () => { + const { growApi } = await wire(); + const results = await Promise.all([growApi.math.add(1, 1), growApi.tools.echo("a"), growApi.math.add(10, 5), growApi.tools.echo("b")]); + expect(results).toEqual([2, "echo:a", 15, "echo:b"]); + }); + + it("refuses a function argument at the edge, before anything is sent (VINE_DATA_ONLY)", async () => { + const { growApi } = await wire(); + await expect(growApi.tools.echo({ onDone: () => {} })).rejects.toMatchObject({ + code: CODES.DATA_ONLY, + path: "tools.echo" + }); + }); +}); + +describe("e2e over websocket — point 2: remote errors re-throw as VineRemoteError", () => { + it("preserves the far error's name, message and code", async () => { + const { growApi } = await wire(); + let caught; + try { + await growApi.tools.boom(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.name).toBe("BoomError"); + expect(caught.message).toBe("kaboom from the far side"); + expect(caught.code).toBe("E_BOOM"); + expect(caught.remoteStack).toContain("kaboom from the far side"); + }); +}); + +describe("e2e over websocket — point 3: slothlet's permission gate covers mounted stubs", () => { + it("denies a module's call to a denied stub, and the call never reaches the far side", async () => { + const { growApi } = await wire({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "caller.**", target: "tools.secret", effect: "deny" }] } + }); + + let caught; + try { + await growApi.caller.secret(); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect(caught.code).toBe("PERMISSION_DENIED"); + expect(caught).not.toBeInstanceOf(VineError); + + // The gate fires before the stub body runs — nothing crossed the boundary. The far side's own + // counter, read back over the same vine, is the proof. + expect(await growApi.tools.secretCallCount()).toBe(0); + + // A leaf the same caller IS permitted to reach still works. + expect(await growApi.caller.echo("ok")).toBe("echo:ok"); + expect(await growApi.tools.secretCallCount()).toBe(0); + }); + + it("lets the same call through when no rule denies it", async () => { + const { growApi } = await wire({ permissions: { defaultPolicy: "allow", rules: [] } }); + expect(await growApi.caller.secret()).toBe("top-secret"); + expect(await growApi.tools.secretCallCount()).toBe(1); + }); +}); + +describe("e2e over websocket — point 4: VINE_BUDGET", () => { + it("settles a slow call with VINE_BUDGET and ignores the late result", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 50 } }); + let caught; + try { + await growApi.tools.slow(400); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineError); + expect(caught.code).toBe(CODES.BUDGET); + expect(caught.path).toBe("tools.slow"); + expect(caught.budgetMs).toBe(50); + + // The far side answers later; settle-once means the frame is dropped and the link stays sane. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(await growApi.math.add(1, 1)).toBe(2); + expect(link.leaves).toContain("tools.slow"); + }); + + it("does not fire the budget for a call that answers in time", async () => { + const { growApi } = await wire({ growOptions: { budgetMs: 2000 } }); + expect(await growApi.tools.slow(20)).toBe("slow:20"); + }); +}); + +describe("e2e over websocket — point 5: far-side death settles in-flight calls with VINE_GONE", () => { + it("settles pending calls and resolves link.closed when the far socket dies mid-call", async () => { + const { growApi, link, serverSocket } = await wire({ growOptions: { budgetMs: 10_000 } }); + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Hard-kill the far side: a real network death (RST), detected grow-side via the socket 'close'. + serverSocket.terminate(); + + await expect(inFlight).rejects.toMatchObject({ code: CODES.GONE }); + await expect(link.closed).resolves.toMatchObject({ reason: "gone" }); + }); + + it("fails a call made after the far side died, without waiting for a budget", async () => { + const { growApi, serverSocket } = await wire({ growOptions: { budgetMs: 10_000 } }); + serverSocket.terminate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const started = Date.now(); + await expect(growApi.math.add(1, 1)).rejects.toMatchObject({ code: CODES.GONE }); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe("e2e over websocket — point 6: link.close() unmounts and settles VINE_CLOSED", () => { + it("removes the stubs from the api and settles in-flight calls", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 10_000 } }); + expect(typeof growApi.tools.echo).toBe("function"); + + const inFlight = growApi.tools.slow(2000); + await new Promise((resolve) => setTimeout(resolve, 50)); + await link.close(); + + await expect(inFlight).rejects.toMatchObject({ code: CODES.CLOSED }); + expect(growApi.tools).toBeUndefined(); + expect(growApi.math).toBeUndefined(); + await expect(link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("is idempotent, and the grow instance's OWN leaves survive", async () => { + const { growApi, link } = await wire(); + await link.close(); + await link.close(); + expect(typeof growApi.caller.echo).toBe("function"); + }); +}); + +/** + * Poll until a predicate holds, failing loudly rather than hanging the suite. + * @param {() => boolean} predicate - The condition to wait for. + * @returns {Promise} Resolves once true. + */ +async function waitFor(predicate) { + const deadline = Date.now() + 3000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for the far side"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/tests/e2e-worker-threads.test.vitest.mjs b/tests/e2e-worker-threads.test.vitest.mjs new file mode 100644 index 0000000..ede539d --- /dev/null +++ b/tests/e2e-worker-threads.test.vitest.mjs @@ -0,0 +1,284 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/e2e-worker-threads.test.vitest.mjs + * + * The worker-threads transport against BOTH bars from `docs/DESIGN.md`: + * + * - the shared Channel conformance suite, run over two paired ports of a `worker_threads.MessageChannel` + * (a real structured-clone boundary in one process — no second thread needed to prove the Channel + * contract, and it exercises the child-side `createParentChannel` on the main thread so its code is + * measured); and + * - the full e2e bar over a REAL `worker_threads.Worker`: the serve side boots a genuine slothlet + * instance INSIDE the worker (`fixtures/wt-serve-worker.mjs`) and answers over `parentPort`; the + * grow side runs on the main thread and mounts forwarding stubs. Value round-trips, remote-error + * re-throw, permission gating on a mounted stub, budget expiry, real thread death, and teardown. + * + * Death detection is the transport's distinguishing property, so point 5 is done for real: + * `worker.terminate()` actually ends the thread, the parent channel observes the `"exit"` event, and + * every in-flight call settles `VINE_GONE` — no budget wait, no hang. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { MessageChannel, Worker } from "node:worker_threads"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import slothlet from "@cldmv/slothlet"; + +import { grow } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError } from "../src/lib/errors.mjs"; +import { createChannel, createParentChannel } from "../src/transport/worker-threads.mjs"; +import { channelConformance } from "../src/testing/conformance.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); +const WORKER_URL = new URL("./fixtures/wt-serve-worker.mjs", import.meta.url); + +// The conformance suite pairs two ports of a real MessageChannel: same process, real structured-clone +// boundary, and it drives the child-side endpoint on the main thread so its code is covered. +channelConformance( + "worker-threads", + () => { + const { port1, port2 } = new MessageChannel(); + return { + a: createParentChannel(port1), + b: createParentChannel(port2), + /** Close both ports so the paired MessageChannel never holds the event loop between cases. @returns {void} */ + cleanup() { + for (const port of [port1, port2]) { + try { + port.close(); + } catch { + // Already closed by a case exercising close(); teardown must not mask the assertion. + } + } + } + }; + }, + { describe, it, expect } +); + +/** Instances, workers and links to tear down after each test. @type {Array<() => Promise>} */ +let teardown = []; + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +/** + * Stand up a full vine over a REAL worker thread: spawn the serve worker, boot a grow instance on the + * main thread, and link them with the worker-threads transport. + * @param {object} [options] + * @param {object} [options.permissions] - Permission config for the GROW-side instance. + * @param {object} [options.growOptions] - Options forwarded to `grow()`. + * @param {object} [options.serveOptions] - Options forwarded to the worker's `serve()`. + * @param {string} [options.base] - Served api directory, relative to the fixtures dir (default: serve-api). + * @returns {Promise<{worker: import("node:worker_threads").Worker, growApi: object, link: object, channel: object}>} The wired pair. + */ +async function wire({ permissions, growOptions, serveOptions, base } = {}) { + const worker = new Worker(WORKER_URL, { workerData: { serveOptions, base } }); + teardown.push(async () => { + await worker.terminate(); + }); + const growApi = await slothlet({ base: GROW_DIR, silent: true, ...(permissions ? { permissions } : {}) }); + teardown.push(async () => { + await growApi.slothlet?.shutdown?.(); + }); + + const channel = createChannel(worker); + const link = await grow(growApi, channel, { budgetMs: 5000, ...growOptions }); + teardown.push(async () => { + await link.close(); + channel.close(); + }); + return { worker, growApi, link, channel }; +} + +describe("e2e over worker_threads — the served surface", () => { + it("mounts the far side's callable leaves at their identical dotted paths", async () => { + const { growApi, link } = await wire(); + expect(link.leaves).toEqual(["math.add", "tools.boom", "tools.echo", "tools.secret", "tools.secretCallCount", "tools.slow"]); + expect(link.collisions).toEqual([]); + expect(typeof growApi.math.add).toBe("function"); + expect(typeof growApi.tools.echo).toBe("function"); + }); + + it("honours a serve-side paths filter across the boundary", async () => { + const { link } = await wire({ serveOptions: { paths: ["tools"] } }); + expect(link.leaves.every((leaf) => leaf.startsWith("tools."))).toBe(true); + expect(link.leaves).not.toContain("math.add"); + }); +}); + +describe("e2e over worker_threads — point 1: sync + async round-trips", () => { + it("returns the right value for a sync far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.math.add(2, 3)).toBe(5); + }); + + it("returns the right value for an async far leaf", async () => { + const { growApi } = await wire(); + expect(await growApi.tools.echo("hi")).toBe("echo:hi"); + }); + + it("round-trips through a real MODULE caller, not just the host handle", async () => { + const { growApi } = await wire(); + expect(await growApi.caller.echo("via-self")).toBe("echo:via-self"); + }); + + it("keeps concurrent calls correlated across the thread boundary", async () => { + const { growApi } = await wire(); + const results = await Promise.all([growApi.math.add(1, 1), growApi.tools.echo("a"), growApi.math.add(10, 5), growApi.tools.echo("b")]); + expect(results).toEqual([2, "echo:a", 15, "echo:b"]); + }); +}); + +describe("e2e over worker_threads — point 2: remote errors re-throw as VineRemoteError", () => { + it("preserves the far error's name, message and code", async () => { + const { growApi } = await wire(); + let caught; + try { + await growApi.tools.boom(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.name).toBe("BoomError"); + expect(caught.message).toBe("kaboom from the far side"); + expect(caught.code).toBe("E_BOOM"); + expect(caught.remoteStack).toContain("kaboom from the far side"); + }); + + it("surfaces a data-only RETURN value (function) as VINE_REMOTE / remoteCode VINE_DATA_ONLY", async () => { + const { growApi } = await wire({ base: "wt-func-api" }); + let caught; + try { + await growApi.leaf.fn(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.code).toBe(CODES.REMOTE); + expect(caught.remoteCode).toBe(CODES.DATA_ONLY); + }); +}); + +describe("e2e over worker_threads — point 3: slothlet's permission gate covers mounted stubs", () => { + it("denies a module's call to a denied stub, and the call never reaches the worker", async () => { + const { growApi } = await wire({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "caller.**", target: "tools.secret", effect: "deny" }] } + }); + + let caught; + try { + await growApi.caller.secret(); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect(caught.code).toBe("PERMISSION_DENIED"); + expect(caught).not.toBeInstanceOf(VineError); + + // The gate fires BEFORE the stub body runs, so nothing crossed the boundary: the worker's own + // counter, read back over the same vine, is the proof. + expect(await growApi.tools.secretCallCount()).toBe(0); + + // A leaf the same caller IS permitted to reach still works. + expect(await growApi.caller.echo("ok")).toBe("echo:ok"); + expect(await growApi.tools.secretCallCount()).toBe(0); + }); + + it("lets the same call through when no rule denies it", async () => { + const { growApi } = await wire({ permissions: { defaultPolicy: "allow", rules: [] } }); + expect(await growApi.caller.secret()).toBe("top-secret"); + expect(await growApi.tools.secretCallCount()).toBe(1); + }); +}); + +describe("e2e over worker_threads — point 4: VINE_BUDGET", () => { + it("settles a slow call with VINE_BUDGET and ignores the late result", async () => { + // Small per-CALL budget, but a generous HANDSHAKE budget: a real worker's boot easily exceeds + // 50ms, and the point here is the call budget, not the surface deadline. + const { growApi, link } = await wire({ growOptions: { budgetMs: 50, handshakeMs: 5000 } }); + let caught; + try { + await growApi.tools.slow(400); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineError); + expect(caught.code).toBe(CODES.BUDGET); + expect(caught.path).toBe("tools.slow"); + expect(caught.budgetMs).toBe(50); + + // The worker answers later; settle-once means the frame is dropped and the link stays usable. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(await growApi.math.add(1, 1)).toBe(2); + expect(link.leaves).toContain("tools.slow"); + }); + + it("does not fire the budget for a call that answers in time", async () => { + const { growApi } = await wire({ growOptions: { budgetMs: 2000 } }); + expect(await growApi.tools.slow(20)).toBe("slow:20"); + }); +}); + +describe("e2e over worker_threads — point 5: worker.terminate() settles in-flight calls VINE_GONE", () => { + it("proves thread death: a mid-call terminate settles pending calls and resolves link.closed", async () => { + const { growApi, link, worker } = await wire({ growOptions: { budgetMs: 30_000 } }); + + let exited = false; + worker.once("exit", () => { + exited = true; + }); + + const inFlight = growApi.tools.slow(5000); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const started = Date.now(); + await worker.terminate(); // the thread is really gone — not a graceful close + + await expect(inFlight).rejects.toMatchObject({ code: CODES.GONE }); + await expect(link.closed).resolves.toMatchObject({ reason: "gone" }); + // The settle came from the observed "exit" event, well before the 30s budget could have fired. + expect(Date.now() - started).toBeLessThan(2000); + expect(exited).toBe(true); + }); + + it("fails a call made after the thread died, without waiting for a budget", async () => { + const { growApi, worker } = await wire({ growOptions: { budgetMs: 30_000 } }); + await worker.terminate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const started = Date.now(); + await expect(growApi.math.add(1, 1)).rejects.toMatchObject({ code: CODES.GONE }); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe("e2e over worker_threads — point 6: link.close() unmounts and settles VINE_CLOSED", () => { + it("removes the stubs from the api and settles in-flight calls", async () => { + const { growApi, link } = await wire({ growOptions: { budgetMs: 30_000 } }); + expect(typeof growApi.tools.echo).toBe("function"); + + const inFlight = growApi.tools.slow(5000); + await new Promise((resolve) => setTimeout(resolve, 50)); + await link.close(); + + await expect(inFlight).rejects.toMatchObject({ code: CODES.CLOSED }); + expect(growApi.tools).toBeUndefined(); + expect(growApi.math).toBeUndefined(); + await expect(link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("is idempotent, and the grow instance's OWN leaves survive", async () => { + const { growApi, link } = await wire(); + await link.close(); + await link.close(); + expect(typeof growApi.caller.echo).toBe("function"); + }); +}); diff --git a/tests/errors.test.vitest.mjs b/tests/errors.test.vitest.mjs new file mode 100644 index 0000000..3b2483e --- /dev/null +++ b/tests/errors.test.vitest.mjs @@ -0,0 +1,209 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/errors.test.vitest.mjs + * + * The error taxonomy and its wire projection: stable codes, remote-error impersonation, and a + * `toWire` that is total against whatever a leaf actually threw. + */ +import { describe, it, expect } from "vitest"; +import { CODES, VineError, VineRemoteError, fromWire, toWire } from "../src/lib/errors.mjs"; + +describe("CODES", () => { + it("carries every code the design names, frozen", () => { + expect(Object.isFrozen(CODES)).toBe(true); + expect(Object.values(CODES)).toEqual( + expect.arrayContaining(["VINE_GONE", "VINE_BUDGET", "VINE_CLOSED", "VINE_DATA_ONLY", "VINE_BAD_FRAME", "VINE_NO_LEAF"]) + ); + }); +}); + +describe("VineError", () => { + it("is an Error carrying a stable code", () => { + const err = new VineError(CODES.BUDGET, "too slow"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("VineError"); + expect(err.code).toBe("VINE_BUDGET"); + expect(err.message).toBe("too slow"); + }); + + it("copies details on as own properties", () => { + const err = new VineError(CODES.NO_LEAF, "nope", { path: "a.b", callId: "n#1" }); + expect(err.path).toBe("a.b"); + expect(err.callId).toBe("n#1"); + }); + + it("refuses to let details overwrite name/message/stack/code", () => { + const err = new VineError(CODES.GONE, "real", { name: "Fake", message: "fake", stack: "fake", code: "FAKE" }); + expect(err.name).toBe("VineError"); + expect(err.message).toBe("real"); + expect(err.code).toBe("VINE_GONE"); + expect(err.stack).not.toBe("fake"); + }); + + it("tolerates absent or non-object details", () => { + expect(new VineError(CODES.CLOSED, "x").code).toBe("VINE_CLOSED"); + expect(new VineError(CODES.CLOSED, "x", null).code).toBe("VINE_CLOSED"); + expect(new VineError(CODES.CLOSED, "x", "nope").code).toBe("VINE_CLOSED"); + }); +}); + +describe("VineRemoteError", () => { + it("impersonates the remote error and keeps its stack separate", () => { + const err = new VineRemoteError({ name: "BoomError", message: "kaboom", code: "E_BOOM", stack: "far stack" }); + expect(err).toBeInstanceOf(VineError); + expect(err.name).toBe("BoomError"); + expect(err.message).toBe("kaboom"); + expect(err.code).toBe("E_BOOM"); + expect(err.remoteStack).toBe("far stack"); + expect(err.stack).not.toBe("far stack"); + }); + + it("falls back to VINE_REMOTE when the remote error carried no code", () => { + const err = new VineRemoteError({ name: "TypeError", message: "bad" }); + expect(err.code).toBe(CODES.REMOTE); + expect(err.remoteStack).toBeUndefined(); + }); + + it("never adopts a reserved VINE_* code from the wire — that is finding 3's fix", () => { + const spoof = new VineRemoteError({ name: "VineError", message: "closed", code: CODES.CLOSED }); + expect(spoof.code).toBe(CODES.REMOTE); + expect(spoof.remoteCode).toBe(CODES.CLOSED); + }); + + it("reports .remoteCode for an ordinary code too, so the field is always the far side's own", () => { + expect(new VineRemoteError({ code: "E_BOOM" }).remoteCode).toBe("E_BOOM"); + expect(new VineRemoteError({}).remoteCode).toBeUndefined(); + }); + + it("reads a hostile wire object without letting a throwing getter escape", () => { + const hostile = { + get name() { + throw new Error("gotcha"); + }, + get message() { + throw new Error("gotcha"); + }, + get code() { + throw new Error("gotcha"); + }, + get stack() { + throw new Error("gotcha"); + } + }; + const err = new VineRemoteError(hostile); + expect(err.name).toBe("Error"); + expect(err.message).toBe(""); + expect(err.code).toBe(CODES.REMOTE); + expect(err.remoteCode).toBeUndefined(); + expect(err.remoteStack).toBeUndefined(); + }); + + it("survives a junk wire shape", () => { + for (const junk of [null, undefined, 7, "boom", { name: 1, message: [], code: 5 }]) { + const err = new VineRemoteError(junk); + expect(err).toBeInstanceOf(VineRemoteError); + expect(typeof err.name).toBe("string"); + expect(typeof err.message).toBe("string"); + } + }); +}); + +describe("toWire", () => { + it("projects an Error onto the schema shape", () => { + const err = new Error("nope"); + err.name = "BoomError"; + err.code = "E_BOOM"; + const wire = toWire(err); + expect(wire.name).toBe("BoomError"); + expect(wire.message).toBe("nope"); + expect(wire.code).toBe("E_BOOM"); + expect(typeof wire.stack).toBe("string"); + }); + + it("stringifies a numeric code", () => { + const err = new Error("x"); + err.code = 42; + expect(toWire(err).code).toBe("42"); + }); + + it("omits code when absent", () => { + expect("code" in toWire(new Error("x"))).toBe(false); + }); + + it("handles non-object throws", () => { + expect(toWire("just a string")).toEqual({ name: "Error", message: "just a string" }); + expect(toWire(null)).toEqual({ name: "Error", message: "null" }); + expect(toWire(undefined)).toEqual({ name: "Error", message: "undefined" }); + expect(toWire(7)).toEqual({ name: "Error", message: "7" }); + }); + + it("handles a null-prototype throw (no toString of its own)", () => { + const hostile = Object.create(null); + expect(toWire(hostile)).toEqual({ name: "Error", message: "" }); + }); + + it("survives a throwing accessor on the thrown object", () => { + const hostile = { + get name() { + throw new Error("gotcha"); + } + }; + expect(toWire(hostile)).toEqual({ name: "Error", message: "" }); + }); + + it("survives a name/message that are not strings", () => { + const wire = toWire({ name: 5, message: { toString: () => "coerced" } }); + expect(wire.name).toBe("5"); + expect(wire.message).toBe("coerced"); + }); + + it("falls back when a name/message coercion itself throws", () => { + const unstringifiable = { + toString() { + throw new Error("no string for you"); + } + }; + const wire = toWire({ name: unstringifiable, message: unstringifiable }); + expect(wire.name).toBe("Error"); + expect(wire.message).toBe(""); + }); + + it("projects a function throw (typeof 'function' is still an object-ish throw)", () => { + const wire = toWire(function named() {}); + expect(wire.name).toBe("named"); + }); +}); + +describe("fromWire", () => { + it("round-trips a thrown error's identity", () => { + const err = new Error("kaboom"); + err.name = "BoomError"; + err.code = "E_BOOM"; + const back = fromWire(toWire(err)); + expect(back).toBeInstanceOf(VineRemoteError); + expect(back.name).toBe("BoomError"); + expect(back.message).toBe("kaboom"); + expect(back.code).toBe("E_BOOM"); + expect(back.remoteStack).toContain("kaboom"); + }); + + it("tolerates a non-object wire value", () => { + const back = fromWire("not a wire error"); + expect(back).toBeInstanceOf(VineRemoteError); + expect(back.message).toBe("not a wire error"); + }); + + it("reads an absent wire value the way toWire writes one", () => { + expect(fromWire(null).message).toBe("null"); + expect(fromWire(undefined).message).toBe("undefined"); + expect(fromWire(7).message).toBe("7"); + }); + + it("survives an exotic primitive wire value", () => { + // `String(symbol)` is legal where `${symbol}` throws — the coercion goes through safeString for + // exactly this reason, so a junk rejection still settles the caller instead of throwing again. + const back = fromWire(Symbol("nope")); + expect(back).toBeInstanceOf(VineRemoteError); + expect(back.message).toBe("Symbol(nope)"); + }); +}); diff --git a/tests/fixtures/codec-api/codec.mjs b/tests/fixtures/codec-api/codec.mjs new file mode 100644 index 0000000..253e0fc --- /dev/null +++ b/tests/fixtures/codec-api/codec.mjs @@ -0,0 +1,39 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/codec-api/codec.mjs + * + * Serve-side probes for the websocket JSON codec's DOCUMENTED, lossy-but-VALID degradations. Each + * leaf returns (or echoes) a rich value so the grow side can assert exactly how `codec: "json"` + * reshapes it — a `Date` to an ISO string, a `Map`/`Set` to `{}` — proving the frame still crosses + * predictably rather than corrupting or crashing the socket. + */ + +/** + * @returns {Date} A fixed instant; over JSON it arrives grow-side as its ISO string. + */ +export function when() { + return new Date("2020-01-02T03:04:05.000Z"); +} + +/** + * @returns {Map} Entries that JSON cannot represent — arrives as `{}`. + */ +export function pairs() { + return new Map([["a", 1]]); +} + +/** + * @returns {Set} Members that JSON cannot represent — arrives as `{}`. + */ +export function members() { + return new Set([1, 2, 3]); +} + +/** + * @param {unknown} value - Anything data-shaped; returned unchanged so the grow side sees how the + * codec reshaped the ARGUMENT on the way in. + * @returns {Promise} The value as this side received it. + */ +export async function echo(value) { + return value; +} diff --git a/tests/fixtures/grow-api/caller.mjs b/tests/fixtures/grow-api/caller.mjs new file mode 100644 index 0000000..bab2311 --- /dev/null +++ b/tests/fixtures/grow-api/caller.mjs @@ -0,0 +1,27 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/grow-api/caller.mjs + * + * Grow-side fixture: a real MODULE that reaches the forwarded leaves through `self`. + * + * It has to be a module, not the test itself: slothlet's permission gate exempts the host's bound + * `api` handle by design (host standing), so a deny rule only bites when a module makes the call. + * That is exactly the shape the e2e permission assertion needs. + */ +import { self } from "@cldmv/slothlet/runtime"; + +/** + * @param {unknown} value - Payload to forward. + * @returns {Promise} Whatever the far side echoed. + */ +export async function echo(value) { + return self.tools.echo(value); +} + +/** + * The call a deny rule blocks — it must never reach the far side. + * @returns {Promise} Never resolves in a denied configuration. + */ +export async function secret() { + return self.tools.secret(); +} diff --git a/tests/fixtures/proc-serve-child.mjs b/tests/fixtures/proc-serve-child.mjs new file mode 100644 index 0000000..89f4161 --- /dev/null +++ b/tests/fixtures/proc-serve-child.mjs @@ -0,0 +1,32 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/proc-serve-child.mjs + * + * The serve side of the process-transport e2e, running in a REAL forked child. Boots a slothlet + * instance from the shared serve-api fixtures (sync `math.add`, async `tools.echo`/`tools.slow`, + * throwing `tools.boom`, instrumented `tools.secret`/`tools.secretCallCount`) and serves it over the + * child-side channel — `createParentChannel()` wrapping this process's IPC channel to its parent. + * + * The parent forks this file with `{ serialization: "advanced" }` and grows the far surface. After + * `serve()` the open IPC channel keeps the child alive; it exits when the parent kills or disconnects + * it (point 5 of the e2e bar kills it mid-call to prove real death detection). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import slothlet from "@cldmv/slothlet"; + +import { serve } from "../../src/index.mjs"; +import { createParentChannel } from "../../src/transport/process.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_DIR = path.join(here, "serve-api"); + +const api = await slothlet({ base: SERVE_DIR, silent: true }); +const channel = createParentChannel(); + +// Register a close handler so the child learns when the parent disconnects. There is nothing to do +// once the far side is gone — the process exits on its own when the IPC channel closes — but wiring it +// exercises the child endpoint's onClose registration under a real fork. +channel.onClose(() => {}); + +await serve(api, channel); diff --git a/tests/fixtures/regression-api/factory.mjs b/tests/fixtures/regression-api/factory.mjs new file mode 100644 index 0000000..5f5f5d5 --- /dev/null +++ b/tests/fixtures/regression-api/factory.mjs @@ -0,0 +1,28 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/regression-api/factory.mjs + * + * Leaves whose RETURN values probe the data-only rule from the serving side: a bare function, a + * function buried in an object graph, and the data-only control that must still cross untouched. + */ + +/** + * @returns {() => string} A live closure over this side's scope — never allowed onto the wire. + */ +export function make() { + return () => "escaped"; +} + +/** + * @returns {{ ok: number, deep: { onDone: () => void } }} A function hidden one level down. + */ +export function nested() { + return { ok: 1, deep: { onDone: () => {} } }; +} + +/** + * @returns {{ ok: number, list: number[] }} Ordinary data, which must still round-trip. + */ +export function plain() { + return { ok: 1, list: [1, 2, 3] }; +} diff --git a/tests/fixtures/regression-api/intl.mjs b/tests/fixtures/regression-api/intl.mjs new file mode 100644 index 0000000..59ff54d --- /dev/null +++ b/tests/fixtures/regression-api/intl.mjs @@ -0,0 +1,23 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/regression-api/intl.mjs + * + * A module whose EXPORT name is outside the ASCII identifier alphabet. slothlet sanitizes file and + * directory names, not export names, so `café` is a real, callable, `leaves()`-reported leaf — the + * case an ASCII-only path guard used to drop from a served surface without a word. + */ + +/** + * @returns {string} A drink. + */ +export function café() { + return "coffee"; +} + +/** + * The ASCII control: whatever happens to `café`, this one is never in doubt. + * @returns {string} A marker. + */ +export function ok() { + return "ok"; +} diff --git a/tests/fixtures/serve-api/math.mjs b/tests/fixtures/serve-api/math.mjs new file mode 100644 index 0000000..3abc171 --- /dev/null +++ b/tests/fixtures/serve-api/math.mjs @@ -0,0 +1,19 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/serve-api/math.mjs + * + * Serve-side fixture: a SYNCHRONOUS leaf plus a DATA export. The data export is load-bearing for the + * suite — a callable surface must not publish `math.answer`. + */ + +/** + * @param {number} a - Left operand. + * @param {number} b - Right operand. + * @returns {number} The sum. + */ +export function add(a, b) { + return a + b; +} + +/** A non-callable export — never part of a served surface. @type {number} */ +export const answer = 42; diff --git a/tests/fixtures/serve-api/tools.mjs b/tests/fixtures/serve-api/tools.mjs new file mode 100644 index 0000000..5b20068 --- /dev/null +++ b/tests/fixtures/serve-api/tools.mjs @@ -0,0 +1,53 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/serve-api/tools.mjs + * + * Serve-side fixture: the async, throwing, slow and instrumented leaves the e2e bar needs. + */ + +/** How many times {@link secret} actually executed — proves a denied call never crossed. @type {number} */ +let secretCalls = 0; + +/** + * @param {unknown} value - Anything data-shaped. + * @returns {Promise} The echoed value, tagged. + */ +export async function echo(value) { + return `echo:${value}`; +} + +/** + * @param {number} ms - How long to take. + * @returns {Promise} A late answer, for budget tests. + */ +export async function slow(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + return `slow:${ms}`; +} + +/** + * Throws an error carrying a name and a code, so the grow side can prove both survive the wire. + * @returns {never} Always throws. + */ +export function boom() { + const err = new Error("kaboom from the far side"); + err.name = "BoomError"; + err.code = "E_BOOM"; + throw err; +} + +/** + * The leaf a grow-side deny rule blocks. It counts its own executions. + * @returns {Promise} A value the denied caller must never see. + */ +export async function secret() { + secretCalls++; + return "top-secret"; +} + +/** + * @returns {Promise} How many times {@link secret} ran. + */ +export async function secretCallCount() { + return secretCalls; +} diff --git a/tests/fixtures/wt-func-api/leaf.mjs b/tests/fixtures/wt-func-api/leaf.mjs new file mode 100644 index 0000000..d47b702 --- /dev/null +++ b/tests/fixtures/wt-func-api/leaf.mjs @@ -0,0 +1,16 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/wt-func-api/leaf.mjs + * + * A serve-side leaf whose RETURN value contains a function. The vine is data-only in both directions, + * so serve rejects this answer with `VINE_DATA_ONLY`; the grow side sees it as a `VINE_REMOTE` error + * carrying `remoteCode: "VINE_DATA_ONLY"`. Isolated in its own fixture dir so the main serve fixtures + * stay a clean data-only surface. + */ + +/** + * @returns {() => number} A live function — exactly what the vine refuses to send back. + */ +export function fn() { + return () => 1; +} diff --git a/tests/fixtures/wt-serve-worker.mjs b/tests/fixtures/wt-serve-worker.mjs new file mode 100644 index 0000000..f2d68e3 --- /dev/null +++ b/tests/fixtures/wt-serve-worker.mjs @@ -0,0 +1,27 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/wt-serve-worker.mjs + * + * The SERVE side of the worker-threads e2e, running inside a real `worker_threads.Worker`. It boots a + * genuine slothlet instance from a fixture api (sync/async/throwing/slow leaves) and serves it over + * the child-side channel wrapping `parentPort`. The grow side lives on the main thread; together they + * exercise the transport across a real thread boundary. + * + * `workerData.base` picks the served api directory (default: the shared `serve-api` fixtures); + * `workerData.serveOptions` is forwarded to `serve()` so a test can filter the surface. `serve()` + * publishes the surface frame immediately — the readiness signal the grow side awaits — after which + * the worker stays alive answering calls until the parent terminates it or closes the link. + */ +import { workerData } from "node:worker_threads"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import slothlet from "@cldmv/slothlet"; + +import { serve } from "../../src/index.mjs"; +import { createParentChannel } from "../../src/transport/worker-threads.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const base = workerData?.base ? path.resolve(here, workerData.base) : path.join(here, "serve-api"); + +const api = await slothlet({ base, silent: true }); +await serve(api, createParentChannel(), workerData?.serveOptions); diff --git a/tests/frame.test.vitest.mjs b/tests/frame.test.vitest.mjs new file mode 100644 index 0000000..aae38e7 --- /dev/null +++ b/tests/frame.test.vitest.mjs @@ -0,0 +1,211 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/frame.test.vitest.mjs + * + * Frame construction, the TOTAL frame validator, and the two guards it exists for: prototype- + * polluting paths and function-valued arguments. + * + * The prototype-pollution case is not hypothetical. Probed against @cldmv/slothlet 3.14.0, + * `api.slothlet.api.add("__proto__.x", fn)` is accepted and lands the function on + * `Object.prototype` — so a `surface` frame is an untrusted input with a real exploit behind it. + */ +import { describe, it, expect } from "vitest"; +import { + FRAME_VERSION, + UNSAFE_SEGMENTS, + callFrame, + errorFrame, + findFunctionArg, + isSafePath, + isSafeSegment, + parseFrame, + resultFrame, + surfaceFrame +} from "../src/lib/frame.mjs"; + +describe("frame constructors", () => { + it("builds a surface frame with a copied leaf list", () => { + const leaves = ["a.b", "c"]; + const frame = surfaceFrame(leaves); + expect(frame).toEqual({ type: "surface", v: FRAME_VERSION, leaves: ["a.b", "c"] }); + leaves.push("mutated"); + expect(frame.leaves).toHaveLength(2); + }); + + it("builds call / result / error frames", () => { + expect(callFrame("n#1", "a.b", [1, 2])).toEqual({ type: "call", callId: "n#1", path: "a.b", args: [1, 2] }); + expect(resultFrame("n#1", "v")).toEqual({ type: "result", callId: "n#1", value: "v" }); + expect(resultFrame("n#1", undefined)).toEqual({ type: "result", callId: "n#1", value: undefined }); + const err = errorFrame("n#1", new Error("bad")); + expect(err.type).toBe("error"); + expect(err.error.message).toBe("bad"); + }); +}); + +describe("path guards", () => { + it("accepts ordinary dotted leaf paths", () => { + for (const path of ["a", "a.b", "exts.pdfViewer.open", "_private", "$dollar", "a1.b2"]) { + expect(isSafePath(path)).toBe(true); + } + }); + + it("rejects every prototype-walking segment", () => { + for (const path of ["__proto__", "__proto__.x", "a.__proto__.b", "constructor", "constructor.prototype.pwn", "a.prototype.b"]) { + expect(isSafePath(path)).toBe(false); + } + }); + + it("rejects the slothlet control plane and the instance teardown handles", () => { + for (const path of ["slothlet", "slothlet.api.remove", "shutdown", "destroy", "a.slothlet"]) { + expect(isSafePath(path)).toBe(false); + } + expect([...UNSAFE_SEGMENTS]).toEqual(expect.arrayContaining(["__proto__", "constructor", "prototype", "slothlet"])); + }); + + it("rejects malformed paths", () => { + for (const path of ["", ".", "a.", ".a", "a..b", "a b", "a-b", "a/b", "a[0]", 7, null, undefined, {}]) { + expect(isSafePath(path)).toBe(false); + } + }); + + it("isSafeSegment mirrors the per-segment rule", () => { + expect(isSafeSegment("ok")).toBe(true); + expect(isSafeSegment("__proto__")).toBe(false); + expect(isSafeSegment("a.b")).toBe(false); + expect(isSafeSegment(5)).toBe(false); + }); + + it("accepts any JavaScript identifier name, not just the ASCII ones", () => { + // A leaf's name is its EXPORT name, which slothlet does not sanitize: `export function café()` + // is a real callable leaf, and an ASCII-only guard used to drop it from the surface in silence. + for (const path of ["intl.café", "ünïcødé", "日本語.leaf", "Ω.α", "_ok.$ok"]) { + expect(isSafePath(path)).toBe(true); + } + }); + + it("still refuses a name that is not an identifier, however exotic", () => { + for (const segment of ["1leaf", "a b", "a-b", "emoji😀", "with space", ""]) { + expect(isSafeSegment(segment)).toBe(false); + } + }); +}); + +describe("findFunctionArg", () => { + it("passes a data-only argument graph", () => { + expect(findFunctionArg([])).toBeNull(); + expect(findFunctionArg([1, "two", null, undefined, { a: [1, { b: 2 }] }, new Map([["k", 1]]), new Set([1, 2])])).toBeNull(); + }); + + it("finds a top-level function", () => { + expect(findFunctionArg([() => {}])).toBe("arg[0]"); + }); + + it("finds a nested function and reports where", () => { + expect(findFunctionArg([{ onDone: () => {} }])).toBe("arg[0].onDone"); + expect(findFunctionArg([[1, [2, () => {}]]])).toBe("arg[0][1][1]"); + expect(findFunctionArg([new Map([["cb", () => {}]])])).toBe("arg[0].get(cb)"); + expect(findFunctionArg([new Set([1, () => {}])])).toBe("arg[0].item[1]"); + }); + + it("finds a function used as a Map KEY", () => { + expect(findFunctionArg([new Map([[() => {}, 1]])])).toBe("arg[0].key"); + }); + + it("finds a symbol-keyed function", () => { + const key = Symbol("cb"); + expect(findFunctionArg([{ [key]: () => {} }])).toContain("Symbol(cb)"); + }); + + it("is cycle-safe", () => { + const cyclic = { name: "loop" }; + cyclic.self = cyclic; + expect(findFunctionArg([cyclic])).toBeNull(); + cyclic.fn = () => {}; + expect(findFunctionArg([cyclic])).toBe("arg[0].fn"); + }); + + it("never invokes a getter (a getter-returned function is not detected, by design)", () => { + let invoked = false; + const obj = { + get sneaky() { + invoked = true; + return () => {}; + } + }; + expect(findFunctionArg([obj])).toBeNull(); + expect(invoked).toBe(false); + }); + + it("treats a non-array args value as suspect", () => { + expect(findFunctionArg("not an array")).toBe("arguments"); + expect(findFunctionArg(null)).toBe("arguments"); + }); +}); + +describe("parseFrame", () => { + it("parses a surface frame and filters unsafe leaves onto .unsafe", () => { + const frame = parseFrame({ type: "surface", v: 1, leaves: ["a.b", "__proto__.x", "slothlet.api.remove", 7] }); + expect(frame.type).toBe("surface"); + expect(frame.leaves).toEqual(["a.b"]); + expect(frame.unsafe).toEqual(["__proto__.x", "slothlet.api.remove", "7"]); + }); + + it("rejects a surface frame of the wrong version or shape", () => { + expect(parseFrame({ type: "surface", v: 2, leaves: [] })).toBeNull(); + expect(parseFrame({ type: "surface", leaves: [] })).toBeNull(); + expect(parseFrame({ type: "surface", v: 1, leaves: "nope" })).toBeNull(); + }); + + it("parses a call frame and copies its args", () => { + const args = [1, { a: 2 }]; + const frame = parseFrame({ type: "call", callId: "n#1", path: "a.b", args }); + expect(frame).toEqual({ type: "call", callId: "n#1", path: "a.b", args: [1, { a: 2 }] }); + args.push("mutated"); + expect(frame.args).toHaveLength(2); + }); + + it("REJECTS a call frame whose path is unsafe — there is no partial reading of 'invoke this'", () => { + expect(parseFrame({ type: "call", callId: "n#1", path: "__proto__.x", args: [] })).toBeNull(); + expect(parseFrame({ type: "call", callId: "n#1", path: "slothlet.api.remove", args: [] })).toBeNull(); + expect(parseFrame({ type: "call", callId: "n#1", path: "a.b", args: "nope" })).toBeNull(); + }); + + it("parses result and error frames", () => { + expect(parseFrame({ type: "result", callId: "n#1", value: 5 })).toEqual({ type: "result", callId: "n#1", value: 5 }); + expect(parseFrame({ type: "result", callId: "n#1" })).toEqual({ type: "result", callId: "n#1", value: undefined }); + const err = parseFrame({ type: "error", callId: "n#1", error: { name: "E", message: "m" } }); + expect(err.error.message).toBe("m"); + expect(parseFrame({ type: "error", callId: "n#1", error: "not an object" })).toBeNull(); + }); + + it("requires a non-empty string callId on every correlated frame", () => { + expect(parseFrame({ type: "call", callId: "", path: "a", args: [] })).toBeNull(); + expect(parseFrame({ type: "result", callId: 7, value: 1 })).toBeNull(); + expect(parseFrame({ type: "error", callId: null, error: {} })).toBeNull(); + }); + + it("returns null for unknown frame types (forward compatibility)", () => { + expect(parseFrame({ type: "stream", callId: "n#1" })).toBeNull(); + expect(parseFrame({ type: "surface2", v: 1, leaves: [] })).toBeNull(); + }); + + it("never throws on junk", () => { + const junk = [null, undefined, 0, 1, "", "frame", true, [], [1, 2], Symbol("s"), () => {}, new Date(), { type: 7 }, {}]; + for (const value of junk) expect(parseFrame(value)).toBeNull(); + }); + + it("never throws on a hostile object with a throwing accessor", () => { + const hostile = { + get type() { + throw new Error("gotcha"); + } + }; + expect(parseFrame(hostile)).toBeNull(); + }); + + it("does not let a __proto__ key in the frame itself pollute anything", () => { + const polluted = JSON.parse('{"type":"result","callId":"n#1","value":1,"__proto__":{"pwned":true}}'); + expect(parseFrame(polluted)).toEqual({ type: "result", callId: "n#1", value: 1 }); + expect({}.pwned).toBeUndefined(); + }); +}); diff --git a/tests/grow-serve-units.test.vitest.mjs b/tests/grow-serve-units.test.vitest.mjs new file mode 100644 index 0000000..d2d73a2 --- /dev/null +++ b/tests/grow-serve-units.test.vitest.mjs @@ -0,0 +1,508 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/grow-serve-units.test.vitest.mjs + * + * Unit-level coverage of the failure paths `grow` and `serve` are built around but which a healthy + * loopback e2e never reaches: a transport whose `send` throws, an `add()` slothlet refuses, a + * `remove()` that silently unmounts nothing, a `leaves()` that throws for a stale moduleID, a leaf + * that vanished between publication and invocation, and a synchronous transport that delivers the + * surface frame during registration. + * + * These use FAKE api and channel objects on purpose — the point is to drive states a real slothlet + * instance and a healthy loopback pair cannot be made to produce on demand. + */ +import { describe, it, expect } from "vitest"; +import { grow } from "../src/grow.mjs"; +import { serve } from "../src/serve.mjs"; +import { CODES } from "../src/lib/errors.mjs"; + +/** + * A Channel whose behaviour each test tailors. + * @param {object} [behaviour] + * @param {(frame: object) => void} [behaviour.onSend] - Called for every outbound frame; may throw. + * @param {boolean} [behaviour.syncSurface] - Deliver a surface frame synchronously during `onMessage`. + * @param {string[]} [behaviour.leaves] - Leaves for the synchronous surface. + * @param {boolean} [behaviour.noOnClose] - Omit `onClose` entirely. + * @param {boolean} [behaviour.syncClose] - Fire the close handler synchronously during registration. + * @returns {object} The fake channel plus test controls. + */ +function fakeChannel(behaviour = {}) { + const sent = []; + let handler = null; + let closeHandler = null; + const channel = { + sent, + send(frame) { + sent.push(frame); + behaviour.onSend?.(frame); + }, + onMessage(fn) { + handler = fn; + if (behaviour.syncSurface) fn({ type: "surface", v: 1, leaves: behaviour.leaves ?? ["far.leaf"] }); + }, + /** + * Push a frame at the registered handler, as the transport would. + * @param {unknown} frame - The frame. + * @returns {void} + */ + deliver(frame) { + handler?.(frame); + }, + /** + * Fire the registered close handler. + * @param {object} [info] - Close info. + * @returns {void} + */ + fireClose(info) { + closeHandler?.(info); + } + }; + if (!behaviour.noOnClose) { + channel.onClose = (fn) => { + closeHandler = fn; + if (behaviour.syncClose) fn({ reason: "already-dead" }); + }; + } + return channel; +} + +/** + * A minimal stand-in for a slothlet instance. `leaves()` models the real one closely enough for the + * teardown path: a moduleID answers the paths that module currently OWNS, an id nobody mounted + * throws `API_LEAVES_UNKNOWN_MODULE`, and a `forceOverwrite` takeover reassigns ownership of the + * path to the taking module. + * @param {object} [behaviour] + * @param {Record>} [behaviour.records] - `leaves()` answers by key. + * @param {(key: string) => void} [behaviour.onLeaves] - Called before answering; may throw. + * @param {(path: string) => void} [behaviour.onAdd] - Called on `add`; may throw. + * @param {(key: string) => void} [behaviour.onRemove] - Called on `remove`. + * @param {boolean} [behaviour.removeIsNoop] - Model the colon-moduleID bug: `remove(id)` unmounts nothing. + * @returns {object} The fake api. + */ +function fakeApi(behaviour = {}) { + const tree = {}; + /** @type {Map>} moduleID → the paths it owns. */ + const owners = new Map(); + const api = { + tree, + owners, + removed: [], + slothlet: { + api: { + async leaves(key) { + behaviour.onLeaves?.(key); + if (behaviour.records && key in behaviour.records) return behaviour.records[key]; + if (owners.has(key)) return [...owners.get(key)].map((path) => ({ path, kind: "data" })); + if (key === ".") return behaviour.records?.["."] ?? []; + throw new Error(`[API_LEAVES_UNKNOWN_MODULE] No module found for '${key}'.`); + }, + async add(path, fn, options = {}) { + behaviour.onAdd?.(path); + const segments = path.split("."); + const last = segments.pop(); + let node = api; + for (const segment of segments) { + if (node[segment] === undefined) node[segment] = {}; + node = node[segment]; + } + const free = node[last] === undefined; + if (free || options.forceOverwrite) node[last] = fn; + if (free || options.forceOverwrite) { + const id = options.moduleID ?? "module-id"; + for (const paths of owners.values()) paths.delete(path); + if (!owners.has(id)) owners.set(id, new Set()); + owners.get(id).add(path); + } + return options.moduleID ?? "module-id"; + }, + async remove(key) { + api.removed.push(key); + behaviour.onRemove?.(key); + if (behaviour.removeIsNoop && !key.includes(".")) return; + if (owners.has(key)) owners.delete(key); + else for (const paths of owners.values()) paths.delete(key); + if (key.includes(".") || behaviour.records) { + const segments = key.split("."); + const last = segments.pop(); + let node = api; + for (const segment of segments) node = node?.[segment]; + if (node) delete node[last]; + } + } + } + } + }; + return api; +} + +describe("serve — collection edge cases", () => { + it("skips a module key whose leaves() throws, and still serves the rest", async () => { + const api = fakeApi({ + records: { ".": [{ path: "math.add", kind: "function" }] }, + onLeaves(key) { + if (key === "stale") throw new Error("API_LEAVES_UNKNOWN_MODULE"); + } + }); + const channel = fakeChannel(); + const serving = await serve(api, channel, { modules: ["stale"] }); + expect(serving.leaves).toEqual(["math.add"]); + }); + + it("unions a named module's leaves into the surface", async () => { + const api = fakeApi({ + records: { ".": [{ path: "math.add", kind: "function" }], "ext-1": [{ path: "exts.one.go", kind: "function" }] } + }); + const serving = await serve(api, fakeChannel(), { modules: ["ext-1"] }); + expect(serving.leaves).toEqual(["exts.one.go", "math.add"]); + }); + + it("tolerates a leaves() answer that is not an array", async () => { + const api = fakeApi(); + api.slothlet.api.leaves = async () => "not an array"; + const serving = await serve(api, fakeChannel()); + expect(serving.leaves).toEqual([]); + }); + + it("drops non-function records and unsafe paths from the surface", async () => { + const api = fakeApi({ + records: { + ".": [ + { path: "math.add", kind: "function" }, + { path: "math.answer", kind: "data" }, + { path: "math", kind: "namespace" }, + { path: "slothlet.api.remove", kind: "function" }, + { path: "__proto__.pwn", kind: "function" }, + { kind: "function" }, + null + ] + } + }); + const serving = await serve(api, fakeChannel()); + expect(serving.leaves).toEqual(["math.add"]); + }); + + it("ignores a NON-ARRAY paths option, but reads an unsatisfiable array as fail-closed", async () => { + const api = fakeApi({ records: { ".": [{ path: "math.add", kind: "function" }] } }); + expect((await serve(api, fakeChannel(), { paths: "tools" })).leaves).toEqual(["math.add"]); + expect((await serve(api, fakeChannel(), { paths: [] })).leaves).toEqual([]); + expect((await serve(api, fakeChannel(), { paths: ["", 7] })).leaves).toEqual([]); + }); +}); + +describe("serve — answering edge cases", () => { + /** + * @param {object} [behaviour] - Channel behaviour. + * @param {object} [apiBehaviour] - Api behaviour. + * @returns {Promise<{api: object, channel: object, serving: object}>} A serving fake. + */ + async function serving(behaviour, apiBehaviour) { + const api = fakeApi({ records: { ".": [{ path: "math.add", kind: "function" }] }, ...apiBehaviour }); + api.math = { add: (a, b) => a + b }; + const channel = fakeChannel(behaviour); + return { api, channel, serving: await serve(api, channel, undefined) }; + } + + it("publishes the surface as its first frame", async () => { + const { channel } = await serving(); + expect(channel.sent[0]).toEqual({ type: "surface", v: 1, leaves: ["math.add"] }); + }); + + it("answers VINE_NO_LEAF when the published path no longer resolves", async () => { + const { api, channel } = await serving(); + delete api.math; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [1, 2] }); + await tick(); + expect(channel.sent.at(-1).error.code).toBe(CODES.NO_LEAF); + }); + + it("answers VINE_NO_LEAF when the path resolves to a non-function", async () => { + const { api, channel } = await serving(); + api.math.add = 42; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [] }); + await tick(); + expect(channel.sent.at(-1).error.code).toBe(CODES.NO_LEAF); + }); + + it("answers VINE_NO_LEAF when an intermediate segment is a primitive", async () => { + const { api, channel } = await serving(); + api.math = 7; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [] }); + await tick(); + expect(channel.sent.at(-1).error.code).toBe(CODES.NO_LEAF); + }); + + it("substitutes an error frame when the result cannot be sent", async () => { + let failNext = false; + const { channel } = await serving({ + onSend(frame) { + if (frame.type === "result" && failNext) throw new Error("could not be cloned"); + } + }); + failNext = true; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [1, 2] }); + await tick(); + const last = channel.sent.at(-1); + expect(last.type).toBe("error"); + expect(last.error.code).toBe(CODES.BAD_FRAME); + }); + + it("gives up quietly when the substitute error frame ALSO cannot be sent", async () => { + const { channel } = await serving({ + onSend(frame) { + if (frame.type !== "surface") throw new Error("channel is dead"); + } + }); + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [1, 2] }); + await expect(tick()).resolves.toBeUndefined(); + }); + + it("swallows a send failure for the surface frame itself (no callId to answer on)", async () => { + const api = fakeApi({ records: { ".": [{ path: "math.add", kind: "function" }] } }); + const channel = fakeChannel({ + onSend(frame) { + if (frame.type === "surface") throw new Error("dead on arrival"); + } + }); + await expect(serve(api, channel)).resolves.toBeTruthy(); + }); + + it("does not answer a call that lands after close()", async () => { + const { channel, serving: handle } = await serving(); + handle.close(); + const before = channel.sent.length; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [1, 2] }); + await tick(); + expect(channel.sent.length).toBe(before); + }); + + it("does not answer an ERROR for a call that failed only after close()", async () => { + const api = fakeApi({ records: { ".": [{ path: "slow.go", kind: "function" }] } }); + let fail; + api.slow = { go: () => new Promise((_, reject) => (fail = reject)) }; + const channel = fakeChannel(); + const handle = await serve(api, channel); + const before = channel.sent.length; + channel.deliver({ type: "call", callId: "c1", path: "slow.go", args: [] }); + await tick(); + handle.close(); + fail(new Error("late failure")); + await tick(); + expect(channel.sent.length).toBe(before); + }); + + it("describes a non-Error send failure without inventing a message", async () => { + const api = fakeApi({ records: { ".": [{ path: "math.add", kind: "function" }] } }); + api.math = { add: (a, b) => a + b }; + let armed = false; + const channel = fakeChannel({ + onSend(frame) { + if (frame.type === "result" && armed) throw "a bare string, not an Error"; + } + }); + await serve(api, channel); + armed = true; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [1, 2] }); + await tick(); + expect(channel.sent.at(-1).error.message).toContain("a bare string, not an Error"); + }); + + it("does not answer a call whose leaf resolves only after close() (the in-flight race)", async () => { + const api = fakeApi({ records: { ".": [{ path: "slow.go", kind: "function" }] } }); + let release; + api.slow = { go: () => new Promise((resolve) => (release = resolve)) }; + const channel = fakeChannel(); + const handle = await serve(api, channel); + const before = channel.sent.length; + channel.deliver({ type: "call", callId: "c1", path: "slow.go", args: [] }); + await tick(); + handle.close(); + release("late"); + await tick(); + expect(channel.sent.length).toBe(before); + }); +}); + +describe("grow — mount and teardown edge cases", () => { + it("accepts a surface delivered SYNCHRONOUSLY during onMessage registration", async () => { + const api = fakeApi(); + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["far.leaf"] })); + expect(link.leaves).toEqual(["far.leaf"]); + }); + + it("ignores junk frames and a second surface publication", async () => { + const api = fakeApi(); + const channel = fakeChannel({ syncSurface: true, leaves: ["far.leaf"] }); + const link = await grow(api, channel); + channel.deliver(null); + channel.deliver({ type: "surface", v: 1, leaves: ["other.leaf"] }); + channel.deliver({ type: "result", callId: "never-opened", value: 1 }); + channel.deliver({ type: "error", callId: "never-opened", error: { name: "E", message: "m" } }); + expect(link.leaves).toEqual(["far.leaf"]); + expect(api.other).toBeUndefined(); + }); + + it("skips a leaf whose add() slothlet refuses", async () => { + const api = fakeApi({ + onAdd(path) { + if (path === "bad.leaf") throw new Error("INVALID_CONFIG_API_PATH_INVALID"); + } + }); + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["ok.leaf", "bad.leaf"] })); + expect(link.leaves).toEqual(["ok.leaf"]); + expect(link.skipped).toEqual(["bad.leaf"]); + }); + + it("falls back to per-path removal when remove(moduleID) unmounts nothing", async () => { + const api = fakeApi({ removeIsNoop: true }); + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["far.leaf"] })); + expect(typeof api.far.leaf).toBe("function"); + await link.close(); + expect(api.far.leaf).toBeUndefined(); + expect(api.removed).toEqual([link.id, "far.leaf"]); + }); + + it("never mounts a collided path, and leaves it alone during teardown", async () => { + const api = fakeApi({ removeIsNoop: true }); + api.far = { leaf: () => "local" }; + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["far.leaf"] })); + expect(link.collisions).toEqual(["far.leaf"]); + expect(link.leaves).toEqual([]); + await link.close(); + expect(api.far.leaf()).toBe("local"); + expect(api.removed).toEqual([link.id]); + }); + + it("treats every mounted path as owned when leaves() cannot answer", async () => { + // Ownership is unknowable here, so the fallback keeps its original job: a remove(moduleID) that + // unmounts NOTHING must still leave no callable stub behind. + const api = fakeApi({ removeIsNoop: true }); + api.slothlet.api.leaves = async () => "not a record list"; + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["far.leaf"] })); + await link.close(); + expect(api.far.leaf).toBeUndefined(); + expect(api.removed).toEqual([link.id, "far.leaf"]); + }); + + it("does not remove a path the link no longer owns", async () => { + // The unit-level twin of the e2e takeover case: the records say another module owns the path + // now, so the per-path fallback must not touch it even though the stub is still resolvable. + const api = fakeApi({ removeIsNoop: true }); + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["far.leaf"] })); + await api.slothlet.api.add("far.leaf", () => "local", { moduleID: "someone-else", forceOverwrite: true }); + await link.close(); + expect(api.far.leaf()).toBe("local"); + expect(api.removed).toEqual([link.id]); + }); + + it("swallows a per-path removal that throws", async () => { + const api = fakeApi({ + removeIsNoop: true, + onRemove(key) { + if (key.includes(".")) throw new Error("nope"); + } + }); + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["far.leaf"] })); + await expect(link.close()).resolves.toBeUndefined(); + await expect(link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("treats a path that throws while being probed as un-occupied", async () => { + const api = fakeApi(); + Object.defineProperty(api, "hostile", { + enumerable: true, + get() { + throw new Error("no reading me"); + } + }); + const link = await grow(api, fakeChannel({ syncSurface: true, leaves: ["hostile.leaf"] })); + expect(link.collisions).toEqual([]); + }); + + it("fails the handshake when the transport reports the peer dead DURING onClose registration", async () => { + const api = fakeApi(); + await expect(grow(api, fakeChannel({ syncClose: true }))).rejects.toMatchObject({ code: CODES.GONE }); + }); + + it("works over a transport with no onClose at all", async () => { + const api = fakeApi(); + const link = await grow(api, fakeChannel({ syncSurface: true, noOnClose: true })); + expect(link.leaves).toEqual(["far.leaf"]); + await link.close(); + }); +}); + +describe("grow — stub dispatch edge cases", () => { + /** + * @param {object} [behaviour] - Channel behaviour. + * @returns {Promise<{api: object, channel: object, link: object}>} A grown fake. + */ + async function grown(behaviour = {}) { + const api = fakeApi(); + const channel = fakeChannel({ syncSurface: true, leaves: ["far.leaf"], ...behaviour }); + const link = await grow(api, channel, { budgetMs: 500 }); + return { api, channel, link }; + } + + it("settles VINE_BAD_FRAME when the call frame cannot be sent", async () => { + const { api, link } = await grown({ + onSend(frame) { + if (frame.type === "call") throw new Error("DataCloneError"); + } + }); + await expect(api.far.leaf(1)).rejects.toMatchObject({ code: CODES.BAD_FRAME, path: "far.leaf" }); + await link.close(); + }); + + it("describes a non-Error send failure without inventing a message", async () => { + const { api, link } = await grown({ + onSend(frame) { + if (frame.type === "call") throw "a bare string, not an Error"; + } + }); + await expect(api.far.leaf(1)).rejects.toMatchObject({ code: CODES.BAD_FRAME }); + await expect(api.far.leaf(1)).rejects.toThrow(/a bare string, not an Error/); + await link.close(); + }); + + it("refuses a call made after close() with VINE_CLOSED", async () => { + const { api, link } = await grown(); + const stub = api.far.leaf; + await link.close(); + await expect(stub(1)).rejects.toMatchObject({ code: CODES.CLOSED, path: "far.leaf" }); + }); + + it("refuses a call made after the far side died with VINE_GONE", async () => { + const { api, channel, link } = await grown(); + channel.fireClose({ reason: "peer-closed" }); + await expect(api.far.leaf(1)).rejects.toMatchObject({ code: CODES.GONE, path: "far.leaf" }); + await expect(link.closed).resolves.toMatchObject({ reason: "gone", info: { reason: "peer-closed" } }); + }); + + it("ignores a second close notification, and one after a local close", async () => { + const { channel, link } = await grown(); + channel.fireClose({ reason: "first" }); + channel.fireClose({ reason: "second" }); + await expect(link.closed).resolves.toMatchObject({ info: { reason: "first" } }); + + const other = await grown(); + await other.link.close(); + other.channel.fireClose({ reason: "after-local-close" }); + await expect(other.link.closed).resolves.toMatchObject({ reason: "closed" }); + }); + + it("falls back to the default budget for a nonsense budgetMs", async () => { + const api = fakeApi(); + const channel = fakeChannel({ syncSurface: true }); + const link = await grow(api, channel, { budgetMs: -1, handshakeMs: Infinity }); + expect(link.leaves).toEqual(["far.leaf"]); + await link.close(); + }); +}); + +/** + * Let queued microtasks and one macrotask run. + * @returns {Promise} Resolves on the next macrotask. + */ +function tick() { + return new Promise((resolve) => setTimeout(resolve, 5)); +} diff --git a/tests/link.test.vitest.mjs b/tests/link.test.vitest.mjs new file mode 100644 index 0000000..468f147 --- /dev/null +++ b/tests/link.test.vitest.mjs @@ -0,0 +1,226 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/link.test.vitest.mjs + * + * The correlation machinery in isolation: settle-once, budget timers, bulk settle, and the two + * misuse guards (`assertChannel` / `assertApi`). + */ +import { describe, it, expect } from "vitest"; +import { PendingTable, assertApi, assertChannel, makeNonce, onCloseSafe } from "../src/lib/link.mjs"; +import { CODES, VineError } from "../src/lib/errors.mjs"; + +/** + * @returns {PendingTable} A table with a fixed nonce, for predictable ids. + */ +function table() { + return new PendingTable("nonce"); +} + +describe("makeNonce", () => { + it("produces distinct non-empty strings", () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(makeNonce()); + expect(seen.size).toBe(200); + expect([...seen].every((value) => typeof value === "string" && value.length > 0)).toBe(true); + }); + + it("stays collision-free on the no-crypto fallback (older browsers, insecure contexts)", () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + try { + Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true, writable: true }); + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(makeNonce()); + // The process-local counter alone guarantees this, without leaning on Math.random. + expect(seen.size).toBe(200); + } finally { + if (original) Object.defineProperty(globalThis, "crypto", original); + else delete globalThis.crypto; + } + }); +}); + +describe("PendingTable ids", () => { + it("uses a monotonic counter behind the link nonce", () => { + const pending = table(); + expect(pending.nextCallId()).toBe("nonce#1"); + expect(pending.nextCallId()).toBe("nonce#2"); + }); +}); + +describe("PendingTable settling", () => { + it("resolves a pending call", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.b", budgetMs: 1000 }); + expect(pending.size).toBe(1); + expect(pending.has(id)).toBe(true); + expect(pending.resolve(id, 42)).toBe(true); + await expect(promise).resolves.toBe(42); + expect(pending.size).toBe(0); + }); + + it("rejects a pending call", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.b", budgetMs: 1000 }); + pending.reject(id, new VineError(CODES.NO_LEAF, "gone")); + await expect(promise).rejects.toMatchObject({ code: CODES.NO_LEAF }); + }); + + it("settles ONCE — every later terminal is dropped", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.b", budgetMs: 1000 }); + expect(pending.resolve(id, "first")).toBe(true); + expect(pending.resolve(id, "second")).toBe(false); + expect(pending.reject(id, new Error("late"))).toBe(false); + await expect(promise).resolves.toBe("first"); + }); + + it("ignores terminals for an unknown callId", () => { + const pending = table(); + expect(pending.resolve("never-opened", 1)).toBe(false); + expect(pending.reject("never-opened", new Error("x"))).toBe(false); + expect(pending.has("never-opened")).toBe(false); + }); + + it("fires the budget timer with a VINE_BUDGET error", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.slow", budgetMs: 20 }); + await expect(promise).rejects.toMatchObject({ code: CODES.BUDGET, path: "a.slow", callId: id, budgetMs: 20 }); + expect(pending.size).toBe(0); + }); + + it("drops a result that arrives after the budget expired", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.slow", budgetMs: 10 }); + await expect(promise).rejects.toMatchObject({ code: CODES.BUDGET }); + expect(pending.resolve(id, "too late")).toBe(false); + }); + + it("clears the budget timer when a call settles normally", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.b", budgetMs: 20 }); + pending.resolve(id, "quick"); + await expect(promise).resolves.toBe("quick"); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(pending.size).toBe(0); + }); + + it("arms no timer for a non-positive or non-finite budget", async () => { + const pending = table(); + for (const budgetMs of [0, -5, Infinity, NaN, undefined]) { + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.b", budgetMs }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(pending.has(id)).toBe(true); + pending.resolve(id, "ok"); + await expect(promise).resolves.toBe("ok"); + } + }); +}); + +describe("PendingTable.settleAll", () => { + it("rejects every pending call with the given code and drains the table", async () => { + const pending = table(); + const ids = [pending.nextCallId(), pending.nextCallId(), pending.nextCallId()]; + const promises = ids.map((id) => pending.open(id, { path: `p.${id}`, budgetMs: 5000 })); + expect(pending.settleAll(CODES.GONE, "far side gone")).toBe(3); + expect(pending.size).toBe(0); + for (const [index, promise] of promises.entries()) { + await expect(promise).rejects.toMatchObject({ code: CODES.GONE, message: "far side gone", callId: ids[index] }); + } + }); + + it("is a no-op on an empty table", () => { + expect(table().settleAll(CODES.CLOSED, "x")).toBe(0); + }); + + it("settles an entry that has no timer (a budget-less call)", async () => { + const pending = table(); + const id = pending.nextCallId(); + const promise = pending.open(id, { path: "a.b", budgetMs: 0 }); + expect(pending.settleAll(CODES.GONE, "gone")).toBe(1); + await expect(promise).rejects.toMatchObject({ code: CODES.GONE }); + }); + + it("drains BEFORE rejecting, so a re-entrant handler sees an empty table", async () => { + const pending = table(); + const id = pending.nextCallId(); + let sizeDuringRejection = -1; + const promise = pending.open(id, { path: "a.b", budgetMs: 5000 }).catch(() => { + sizeDuringRejection = pending.size; + }); + pending.settleAll(CODES.CLOSED, "closed"); + await promise; + expect(sizeDuringRejection).toBe(0); + }); +}); + +describe("assertChannel", () => { + it("accepts a minimal Channel", () => { + expect(() => assertChannel({ send() {}, onMessage() {} }, "grow")).not.toThrow(); + }); + + it("rejects anything missing send or onMessage", () => { + for (const bad of [null, undefined, 7, "channel", {}, { send() {} }, { onMessage() {} }, { send: 1, onMessage() {} }]) { + expect(() => assertChannel(bad, "grow")).toThrow(TypeError); + } + expect(() => assertChannel({}, "grow")).toThrow(/grow\(\) needs a Channel/); + }); +}); + +describe("assertApi", () => { + it("accepts an object exposing the required api.slothlet.api methods", () => { + const api = { slothlet: { api: { add() {}, remove() {}, leaves() {} } } }; + expect(() => assertApi(api, "grow", ["add", "remove"])).not.toThrow(); + }); + + it("names the missing methods", () => { + const api = { slothlet: { api: { add() {} } } }; + expect(() => assertApi(api, "grow", ["add", "remove"])).toThrow(/remove/); + expect(() => assertApi({}, "serve", ["leaves"])).toThrow(/leaves/); + expect(() => assertApi(null, "serve", ["leaves"])).toThrow(TypeError); + }); +}); + +describe("onCloseSafe", () => { + it("reports false when the transport has no onClose", () => { + expect(onCloseSafe({ send() {}, onMessage() {} }, () => {})).toBe(false); + }); + + it("registers the handler and swallows anything it throws", () => { + let registered = null; + const channel = { + onClose(handler) { + registered = handler; + } + }; + expect( + onCloseSafe(channel, () => { + throw new Error("handler blew up"); + }) + ).toBe(true); + expect(() => registered({ reason: "peer-closed" })).not.toThrow(); + }); + + it("forwards the close info", () => { + let registered = null; + let seen = null; + onCloseSafe( + { + onClose(handler) { + registered = handler; + } + }, + (info) => { + seen = info; + } + ); + registered({ reason: "peer-closed" }); + expect(seen).toEqual({ reason: "peer-closed" }); + }); +}); diff --git a/tests/package-surface.test.vitest.mjs b/tests/package-surface.test.vitest.mjs new file mode 100644 index 0000000..a87550f --- /dev/null +++ b/tests/package-surface.test.vitest.mjs @@ -0,0 +1,136 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/package-surface.test.vitest.mjs + * + * The package's published surface: every documented entry point resolves, the frame schema matches + * the protocol the code implements, and the transports that are still scaffolds fail LOUDLY — never + * a silent no-op a consumer could mistake for working forwarding. + * + * `transport/loopback` is implemented and is asserted the other way: it must NOT throw. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { grow, serve, CODES, VineError, VineRemoteError, DEFAULT_BUDGET_MS } from "../src/index.mjs"; + +/** Transports still awaiting their own implementation pass. @type {string[]} */ +const SCAFFOLD_TRANSPORTS = []; +/** Transports whose Channel is implemented (createChannel does real work, never a scaffold throw). @type {string[]} */ +const IMPLEMENTED_TRANSPORTS = ["loopback", "post-message", "worker-threads", "process", "websocket"]; +/** Every transport subpath the package publishes. @type {string[]} */ +const ALL_TRANSPORTS = [...IMPLEMENTED_TRANSPORTS, ...SCAFFOLD_TRANSPORTS]; + +describe("package surface", () => { + it("root export exposes grow + serve as functions", () => { + expect(typeof grow).toBe("function"); + expect(typeof serve).toBe("function"); + }); + + it("root export exposes the error taxonomy consumers branch on", () => { + expect(typeof VineError).toBe("function"); + expect(typeof VineRemoteError).toBe("function"); + expect(CODES.GONE).toBe("VINE_GONE"); + expect(DEFAULT_BUDGET_MS).toBe(30_000); + }); + + it.each(ALL_TRANSPORTS)("transport subpath '%s' resolves and exposes createChannel", async (name) => { + const mod = await import(`../src/transport/${name}.mjs`); + expect(typeof mod.createChannel).toBe("function"); + }); + + it("the testing subpath exposes the conformance harness", async () => { + const mod = await import("../src/testing/conformance.mjs"); + expect(typeof mod.channelConformance).toBe("function"); + }); + + it("package.json exports map lists every published subpath", () => { + const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")); + for (const name of ALL_TRANSPORTS) expect(pkg.exports[`./transport/${name}`]).toBe(`./src/transport/${name}.mjs`); + expect(pkg.exports["."]).toBe("./src/index.mjs"); + expect(pkg.exports["./testing"]).toBe("./src/testing/conformance.mjs"); + expect(pkg.peerDependencies["@cldmv/slothlet"]).toBeTruthy(); + }); +}); + +describe("unimplemented transports still fail loudly (pre-release contract)", () => { + it.each(SCAFFOLD_TRANSPORTS)("transport '%s' createChannel throws a not-implemented error naming its transport", async (name) => { + const { createChannel } = await import(`../src/transport/${name}.mjs`); + expect(() => createChannel()).toThrowError(new RegExp(`transport/${name}`)); + expect(() => createChannel()).toThrowError(/not implemented/i); + }); + + it("loopback is implemented and does NOT throw", async () => { + const { createChannel, createPair } = await import("../src/transport/loopback.mjs"); + expect(() => createChannel()).not.toThrow(); + expect(createPair()).toHaveLength(2); + }); + + it("post-message is implemented — it validates its port instead of throwing a scaffold error", async () => { + const { createChannel } = await import("../src/transport/post-message.mjs"); + const { MessageChannel } = await import("node:worker_threads"); + // No scaffold "not implemented" throw: the no-arg call fails on the MISSING PORT (a TypeError), + // and a real port yields a working Channel. + expect(() => createChannel()).toThrowError(TypeError); + expect(() => createChannel()).not.toThrowError(/not implemented/i); + const { port1 } = new MessageChannel(); + const channel = createChannel(port1); + expect(typeof channel.send).toBe("function"); + expect(channel.capabilities.structuredClone).toBe(true); + channel.close(); + }); + + it("worker-threads is implemented — it validates its worker/port instead of throwing a scaffold error", async () => { + const { createChannel, createParentChannel } = await import("../src/transport/worker-threads.mjs"); + const { MessageChannel } = await import("node:worker_threads"); + // No scaffold "not implemented" throw: createChannel with no Worker fails on the MISSING ARG (a + // TypeError), and createParentChannel around a real MessagePort yields a working Channel. + expect(() => createChannel()).toThrowError(TypeError); + expect(() => createChannel()).not.toThrowError(/not implemented/i); + const { port1 } = new MessageChannel(); + const channel = createParentChannel(port1); + expect(typeof channel.send).toBe("function"); + expect(channel.capabilities).toEqual({ structuredClone: true, codec: "none", buffersUntilHandler: false }); + channel.close(); + }); + + it("websocket is implemented — it validates its socket instead of throwing a scaffold error", async () => { + const { createChannel } = await import("../src/transport/websocket.mjs"); + // No scaffold "not implemented" throw: createChannel with no socket fails on the MISSING ARG (a + // TypeError), and a socket exposing the ws instance surface yields a working json-codec Channel. + expect(() => createChannel()).toThrowError(TypeError); + expect(() => createChannel()).not.toThrowError(/not implemented/i); + const socket = { + send() {}, + on() {}, + close() {}, + readyState: 1 + }; + const channel = createChannel(socket); + expect(typeof channel.send).toBe("function"); + expect(channel.capabilities).toEqual({ structuredClone: false, codec: "json", buffersUntilHandler: false }); + channel.close(); + }); +}); + +describe("frame schema", () => { + const schema = JSON.parse(readFileSync(fileURLToPath(new URL("../schemas/frame.schema.json", import.meta.url)), "utf8")); + + it("is a 2020-12 JSON Schema with the four v1 frame shapes", () => { + expect(schema.$schema).toContain("2020-12"); + expect(Array.isArray(schema.oneOf)).toBe(true); + expect(schema.oneOf.map((entry) => entry.properties.type.const)).toEqual(["surface", "call", "result", "error"]); + }); + + it("matches the frames the implementation actually builds", async () => { + const { surfaceFrame, callFrame, resultFrame, errorFrame, FRAME_VERSION } = await import("../src/lib/frame.mjs"); + const [surface, call, result, error] = schema.oneOf; + + expect(Object.keys(surfaceFrame(["a.b"]))).toEqual(expect.arrayContaining(surface.required)); + expect(surface.properties.v.const).toBe(FRAME_VERSION); + expect(Object.keys(callFrame("n#1", "a.b", []))).toEqual(expect.arrayContaining(call.required)); + expect(Object.keys(resultFrame("n#1", 1))).toEqual(expect.arrayContaining(result.required)); + expect(Object.keys(errorFrame("n#1", new Error("x")))).toEqual(expect.arrayContaining(error.required)); + expect(Object.keys(errorFrame("n#1", new Error("x")).error)).toEqual(expect.arrayContaining(error.properties.error.required)); + }); +}); diff --git a/tests/regression-send-failure.test.vitest.mjs b/tests/regression-send-failure.test.vitest.mjs new file mode 100644 index 0000000..6c5c57d --- /dev/null +++ b/tests/regression-send-failure.test.vitest.mjs @@ -0,0 +1,193 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/regression-send-failure.test.vitest.mjs + * + * REGRESSION LOCK for the send-failure policy (final-review findings 1 + 2). + * + * A `channel.send(frame)` failure has two distinct causes and every transport now keeps them apart: + * + * - **The medium REFUSES this frame** — an un-serializable argument the data-only scan cannot see (a + * `Symbol` / a value hiding a function → `DataCloneError` on the structured-clone family and a + * synchronous serializer throw from `child.send`; a `BigInt` → a `JSON.stringify` throw on the + * websocket JSON codec). This is a PER-CALL fault: `send()` rethrows, the core settles JUST that + * call `VINE_BAD_FRAME`, and the link — plus every other in-flight call — stays alive. + * - **The channel is DEAD** — that fires `onClose` and settles everything `VINE_GONE`; it is covered + * by each transport's own e2e (point 5) and is deliberately NOT re-tested here. + * + * The historical defect this locks out: `src/transport/process.mjs` treated a synchronous serializer + * throw as far-side DEATH, so one un-cloneable argument on one call killed the WHOLE link (every + * in-flight call `VINE_GONE`, `link.closed` → `gone`) while the child was still alive. The + * post-message / worker-threads / websocket transports had the mirror bug in the other direction — + * swallowing the refusal so the call hung to its full budget instead of failing fast. + * + * Each transport is exercised over its REAL boundary (an in-process structured-clone MessageChannel + * hop for the port transports, a real forked child for `process`, a real `ws` socket for + * `websocket`), so the uniform policy is proven end to end through `grow()`. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { MessageChannel } from "node:worker_threads"; +import { fork } from "node:child_process"; +import { WebSocketServer, WebSocket } from "ws"; +import slothlet from "@cldmv/slothlet"; + +import { grow, serve } from "../src/index.mjs"; +import { CODES } from "../src/lib/errors.mjs"; +import { createChannel as createPostMessageChannel } from "../src/transport/post-message.mjs"; +import { createParentChannel as createWorkerParentChannel } from "../src/transport/worker-threads.mjs"; +import { createChannel as createProcessChannel } from "../src/transport/process.mjs"; +import { createChannel as createWebSocketChannel } from "../src/transport/websocket.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_DIR = path.join(here, "fixtures", "serve-api"); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); +const PROC_CHILD = path.join(here, "fixtures", "proc-serve-child.mjs"); + +/** @type {Array<() => Promise|void>} */ +let teardown = []; + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +/** + * Assert the fixed behaviour holds for an already-wired link: the bad call rejects `VINE_BAD_FRAME`, + * an unrelated in-flight call still completes, and the link is NOT gone. + * @param {object} growApi - The grow-side slothlet instance. + * @param {object} link - The live link. + * @param {() => Promise} makeBadCall - Issues the call whose argument the medium refuses. + * @returns {Promise} + */ +async function assertRefusalIsPerCall(growApi, link, makeBadCall) { + let linkClosed = false; + link.closed.then(() => { + linkClosed = true; + }); + + // A healthy, unrelated call is in flight when the bad frame is refused… + const inFlight = growApi.tools.slow(200); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // …the bad call fails fast with BAD_FRAME — not GONE, not a budget timeout. + await expect(makeBadCall()).rejects.toMatchObject({ code: CODES.BAD_FRAME }); + + // The unrelated in-flight call still completes, and the link keeps forwarding afterwards. + expect(await inFlight).toBe("slow:200"); + expect(await growApi.math.add(2, 3)).toBe(5); + expect(linkClosed).toBe(false); + expect(link.leaves).toContain("tools.echo"); +} + +describe("regression: an un-serializable arg fails ONLY that call (VINE_BAD_FRAME), link stays alive", () => { + // ── post-message (real worker_threads MessageChannel structured-clone hop) ───────────────────── + it("post-message — a Symbol arg (DataCloneError) settles that call BAD_FRAME", async () => { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => await serveApi.slothlet?.shutdown?.()); + teardown.push(async () => await growApi.slothlet?.shutdown?.()); + + const { port1, port2 } = new MessageChannel(); + const near = createPostMessageChannel(port1); + const far = createPostMessageChannel(port2); + const growing = grow(growApi, near, { budgetMs: 2000 }); + const serving = await serve(serveApi, far); + const link = await growing; + teardown.push(async () => { + await link.close(); + serving.close(); + near.close(); + far.close(); + }); + + await assertRefusalIsPerCall(growApi, link, () => growApi.tools.echo(Symbol("not-cloneable"))); + }); + + // ── worker-threads (two paired MessageChannel ports, real structured-clone hop) ──────────────── + it("worker-threads — a Symbol arg (DataCloneError) settles that call BAD_FRAME", async () => { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => await serveApi.slothlet?.shutdown?.()); + teardown.push(async () => await growApi.slothlet?.shutdown?.()); + + const { port1, port2 } = new MessageChannel(); + const near = createWorkerParentChannel(port1); + const far = createWorkerParentChannel(port2); + const growing = grow(growApi, near, { budgetMs: 2000 }); + const serving = await serve(serveApi, far); + const link = await growing; + teardown.push(async () => { + await link.close(); + serving.close(); + near.close(); + far.close(); + }); + + await assertRefusalIsPerCall(growApi, link, () => growApi.tools.echo(Symbol("not-cloneable"))); + }); + + // ── process (a REAL forked child over IPC, advanced serialization) ───────────────────────────── + it("process — a Symbol arg (synchronous serializer throw) settles that call BAD_FRAME, child stays alive", async () => { + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => await growApi.slothlet?.shutdown?.()); + const child = fork(PROC_CHILD, [], { serialization: "advanced" }); + teardown.push(() => { + if (child.connected || child.exitCode === null) child.kill(); + }); + + const link = await grow(growApi, createProcessChannel(child), { budgetMs: 10_000, handshakeMs: 10_000 }); + teardown.push(async () => await link.close()); + + await assertRefusalIsPerCall(growApi, link, () => growApi.tools.echo(Symbol("not-cloneable"))); + + // The whole point of the regression: the child is demonstrably still alive and connected. + expect(child.connected).toBe(true); + expect(child.exitCode).toBe(null); + }, 20_000); + + // ── websocket (a REAL ws connection on an ephemeral port; a BigInt is un-encodable JSON) ─────── + it("websocket — a BigInt arg (JSON.stringify throw) settles that call BAD_FRAME", async () => { + const serveApi = await slothlet({ base: SERVE_DIR, silent: true }); + const growApi = await slothlet({ base: GROW_DIR, silent: true }); + teardown.push(async () => await serveApi.slothlet?.shutdown?.()); + teardown.push(async () => await growApi.slothlet?.shutdown?.()); + + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(wss, "listening"); + const { port } = wss.address(); + const serverSocketReady = once(wss, "connection").then(([socket]) => socket); + const clientSocket = new WebSocket(`ws://127.0.0.1:${port}`); + const [serverSocket] = await Promise.all([serverSocketReady, once(clientSocket, "open")]); + + const far = createWebSocketChannel(serverSocket); + const near = createWebSocketChannel(clientSocket); + const serving = await serve(serveApi, far); + const link = await grow(growApi, near, { budgetMs: 2000 }); + teardown.push(async () => { + await link.close(); + serving.close(); + try { + clientSocket.terminate(); + } catch { + // already gone + } + try { + serverSocket.terminate(); + } catch { + // already gone + } + await new Promise((resolve) => wss.close(() => resolve())); + }); + + // A Symbol would merely degrade (JSON drops it); a BigInt is what JSON.stringify actually refuses. + await assertRefusalIsPerCall(growApi, link, () => growApi.tools.echo(10n)); + }, 20_000); +}); diff --git a/tests/regressions.test.vitest.mjs b/tests/regressions.test.vitest.mjs new file mode 100644 index 0000000..5d042b2 --- /dev/null +++ b/tests/regressions.test.vitest.mjs @@ -0,0 +1,482 @@ +/** + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/regressions.test.vitest.mjs + * + * REGRESSION FILE. Every test here started life in an adversarial review as its own inverse: a + * green assertion that a DEFECT was present. Each one is now written the right way round and pins + * the FIXED behaviour, so a re-introduction of the original bug fails here rather than shipping. + * + * The numbering is the review's, kept deliberately so a finding can be traced from report to test: + * + * 1. `link.close()`'s per-path fallback is OWNERSHIP-scoped — a local module that legitimately took + * a vine path over (`forceOverwrite`, its own moduleID) survives the teardown intact. + * 2. `link.leaves` lists only paths actually mounted: a collided path is reported on `collisions`, + * never added, and the local incumbent keeps answering there — during the link and after it. + * 3. A far side cannot impersonate a vine link-state error: a remote `code` in the reserved `VINE_*` + * namespace is remapped to `VINE_REMOTE`, with the far side's spelling kept on `.remoteCode`. + * 4. A leaf whose EXPORT name is outside the ASCII alphabet (`export function café()`) is served, + * mounted and callable end to end; leaves a serve declines are reported on `serving.excluded`. + * 5. Data-only is enforced on RETURN values too: a leaf returning a function is refused serve-side + * with `VINE_DATA_ONLY` instead of handing the caller a live closure over a by-reference + * transport (and failing as an opaque clone error over a cloning one). + * 6. Answering a call does not corrupt the served instance's own leaf records (the `Reflect.apply` + * dispatch) — found while verifying 4 and 5. + * 8. The minors: mounting stops when the far side dies mid-mount; `handshakeMs` has explicit + * semantics for nonsense values; a hostile error object settles the call it belongs to; and + * `close()` releases the receive closure. + */ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import slothlet from "@cldmv/slothlet"; + +import { grow, serve } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError, fromWire } from "../src/lib/errors.mjs"; +import { createPair } from "../src/transport/loopback.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const GROW_DIR = path.join(here, "fixtures", "grow-api"); +const REGRESSION_DIR = path.join(here, "fixtures", "regression-api"); + +/** @type {Array<() => Promise>} */ +let teardown = []; +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // teardown must not mask the assertion + } + } + teardown = []; +}); + +/** + * Compose a real slothlet instance and register its shutdown. + * @param {string} base - Fixture directory. + * @param {object} [options] - Extra slothlet options. + * @returns {Promise} The live instance. + */ +async function instance(base, options) { + const api = await slothlet({ base, silent: true, ...options }); + teardown.push(async () => api.slothlet?.shutdown?.()); + return api; +} + +/** + * A far side that publishes a fixed surface and answers every call from a table. + * @param {object} channel - The far end of a loopback pair. + * @param {string[]} leaves - The surface to publish. + * @param {(frame: object) => unknown} answer - Produces the result value for a call frame. + * @returns {void} + */ +function fakeFarSide(channel, leaves, answer) { + channel.send({ type: "surface", v: 1, leaves }); + channel.onMessage((frame) => { + if (frame?.type === "call") channel.send({ type: "result", callId: frame.callId, value: answer(frame) }); + }); +} + +describe("finding 1 — close() removes what the link still OWNS, never local reality", () => { + it("leaves a local forceOverwrite takeover intact, and still unmounts the paths it kept", async () => { + const api = await instance(GROW_DIR); + const [near, far] = createPair(); + fakeFarSide(far, ["fresh.leaf", "other.leaf"], (frame) => `REMOTE(${frame.path})`); + + const link = await grow(api, near, { handshakeMs: 5000, budgetMs: 2000 }); + expect(link.leaves).toEqual(["fresh.leaf", "other.leaf"]); + expect(await api.fresh.leaf()).toBe("REMOTE(fresh.leaf)"); + + // Local reality legitimately takes one path over, with its OWN moduleID. + await api.slothlet.api.add("fresh.leaf", () => "LOCAL", { moduleID: "my-local-module", forceOverwrite: true }); + expect(await api.fresh.leaf()).toBe("LOCAL"); + + await link.close(); + + // The takeover survives: the vine no longer owns that path, so it is not the vine's to remove. + expect(typeof api.fresh.leaf).toBe("function"); + expect(await api.fresh.leaf()).toBe("LOCAL"); + // The path the link DID still own is gone — teardown is still a real teardown. + expect(api.other?.leaf).toBeUndefined(); + }); + + it("still unmounts every stub when nothing took a path over", async () => { + const api = await instance(GROW_DIR); + const [near, far] = createPair(); + fakeFarSide(far, ["fresh.leaf"], () => "REMOTE"); + + const link = await grow(api, near, { handshakeMs: 5000, budgetMs: 2000 }); + expect(typeof api.fresh.leaf).toBe("function"); + await link.close(); + expect(api.fresh?.leaf).toBeUndefined(); + }); +}); + +describe("finding 2 — link.leaves lists only what is actually mounted", () => { + it("reports a collided path on collisions ONLY, and never mounts it", async () => { + const api = await instance(GROW_DIR); + const [near, far] = createPair(); + fakeFarSide(far, ["caller.secret", "fresh.leaf"], (frame) => `REMOTE(${frame.path})`); + + const link = await grow(api, near, { handshakeMs: 5000, budgetMs: 2000 }); + teardown.push(async () => link.close()); + + expect(link.collisions).toEqual(["caller.secret"]); + expect(link.leaves).toEqual(["fresh.leaf"]); + expect(link.leaves).not.toContain("caller.secret"); + // The three lists stay disjoint — a path is mounted, skipped or collided, never two of them. + expect(link.skipped).toEqual([]); + + // The local incumbent still answers there; the far leaf was never reachable at that path. + await expect(api.caller.secret()).rejects.toThrow(); + expect(await api.fresh.leaf()).toBe("REMOTE(fresh.leaf)"); + }); + + it("leaves the collided local incumbent answering after close()", async () => { + const api = await instance(GROW_DIR); + const [near, far] = createPair(); + fakeFarSide(far, ["caller.echo"], () => "REMOTE"); + + const link = await grow(api, near, { handshakeMs: 5000, budgetMs: 2000 }); + expect(link.collisions).toEqual(["caller.echo"]); + await link.close(); + + // The vine borrowed nothing at that path, so it gives nothing back: the local module is intact. + expect(typeof api.caller.echo).toBe("function"); + }); +}); + +describe("finding 3 — a far side cannot impersonate a vine link-state error", () => { + it("remaps a reserved VINE_* code to VINE_REMOTE and keeps the original on .remoteCode", () => { + const spoof = fromWire({ name: "VineError", message: "the link was closed", code: "VINE_CLOSED", stack: "remote" }); + + // The consumer's documented branch — `err instanceof VineError && err.code === CODES.CLOSED` — + // no longer fires for something that arrived over the wire. + expect(spoof).toBeInstanceOf(VineError); + expect(spoof.code).toBe(CODES.REMOTE); + expect(spoof.code).not.toBe(CODES.CLOSED); + // Nothing is lost: what the far side actually said is still readable. + expect(spoof.remoteCode).toBe("VINE_CLOSED"); + expect(spoof.remoteStack).toBe("remote"); + expect(spoof).toBeInstanceOf(VineRemoteError); + }); + + it("remaps every reserved code, not just the link-state ones", () => { + for (const code of Object.values(CODES)) { + const spoof = fromWire({ name: "VineError", message: "spoof", code }); + expect(spoof.code).toBe(CODES.REMOTE); + expect(spoof.remoteCode).toBe(code); + } + }); + + it("still adopts an ordinary application code verbatim", () => { + const real = fromWire({ name: "BoomError", message: "kaboom", code: "E_BOOM" }); + expect(real.code).toBe("E_BOOM"); + expect(real.remoteCode).toBe("E_BOOM"); + }); + + it("cannot drive a caller's teardown branch from across a live link", async () => { + const api = await instance(GROW_DIR); + const [near, far] = createPair(); + far.send({ type: "surface", v: 1, leaves: ["fresh.leaf"] }); + far.onMessage((frame) => { + if (frame?.type === "call") { + far.send({ type: "error", callId: frame.callId, error: { name: "VineError", message: "closed", code: CODES.CLOSED } }); + } + }); + + const link = await grow(api, near, { handshakeMs: 5000, budgetMs: 2000 }); + teardown.push(async () => link.close()); + + let caught; + try { + await api.fresh.leaf(); + } catch (err) { + caught = err; + } + expect(caught.code).toBe(CODES.REMOTE); + expect(caught.remoteCode).toBe(CODES.CLOSED); + // And the link really is still open — the spoof did not describe reality. + expect(typeof api.fresh.leaf).toBe("function"); + }); +}); + +describe("finding 4 — a unicode-named leaf crosses the vine, and declined leaves are reported", () => { + it("serves, mounts and calls a leaf whose export name is non-ASCII", async () => { + const serveApi = await instance(REGRESSION_DIR); + const growApi = await instance(GROW_DIR); + + const records = await serveApi.slothlet.api.leaves(".", { details: true }); + expect(records.some((record) => record.path === "intl.café" && record.kind === "function")).toBe(true); + + const [near, far] = createPair(); + const serving = await serve(serveApi, far, { paths: ["intl"] }); + teardown.push(async () => serving.close()); + expect(serving.leaves).toEqual(["intl.café", "intl.ok"]); + + const link = await grow(growApi, near, { handshakeMs: 5000, budgetMs: 2000 }); + teardown.push(async () => link.close()); + expect(link.leaves).toEqual(["intl.café", "intl.ok"]); + expect(await growApi.intl["café"]()).toBe("coffee"); + }); + + it("reports leaves the paths filter declined on serving.excluded", async () => { + const serveApi = await instance(REGRESSION_DIR); + const [, far] = createPair(); + const serving = await serve(serveApi, far, { paths: ["intl"] }); + teardown.push(async () => serving.close()); + + expect(serving.excluded).toEqual(["factory.make", "factory.nested", "factory.plain"]); + expect(serving.excluded.some((leaf) => serving.leaves.includes(leaf))).toBe(false); + }); + + it("reports leaves the safety guard declined on serving.excluded", async () => { + const api = { + slothlet: { + api: { + async leaves() { + return [ + { path: "math.add", kind: "function" }, + { path: "slothlet.api.remove", kind: "function" }, + { path: "__proto__.pwn", kind: "function" }, + { path: "math.answer", kind: "data" } + ]; + } + } + } + }; + const [, far] = createPair(); + const serving = await serve(api, far); + expect(serving.leaves).toEqual(["math.add"]); + // Refused callable leaves are visible; a data record was never a candidate and is not "excluded". + expect(serving.excluded).toEqual(["__proto__.pwn", "slothlet.api.remove"]); + expect(serving.excluded).not.toContain("math.answer"); + }); +}); + +describe("finding 5 — data-only is enforced on RETURN values, not only arguments", () => { + /** + * Wire a real serve of the regression fixtures to a real grow. + * @returns {Promise} The growing instance. + */ + async function wired() { + const serveApi = await instance(REGRESSION_DIR); + const growApi = await instance(GROW_DIR); + const [near, far] = createPair(); + const serving = await serve(serveApi, far); + teardown.push(async () => serving.close()); + const link = await grow(growApi, near, { handshakeMs: 5000, budgetMs: 2000 }); + teardown.push(async () => link.close()); + return growApi; + } + + it("refuses a leaf that returns a bare function instead of delivering the closure", async () => { + const growApi = await wired(); + let caught; + try { + await growApi.factory.make(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + // Serve-side vine errors cross as remote errors like any other, so the vine code lands on + // `.remoteCode` (finding 3's remapping) — `.code` says only "this came from over there". + expect(caught.remoteCode).toBe(CODES.DATA_ONLY); + expect(caught.code).toBe(CODES.REMOTE); + expect(caught.message).toContain("data-only"); + }); + + it("finds a function buried in the returned graph and names where it was", async () => { + const growApi = await wired(); + await expect(growApi.factory.nested()).rejects.toThrow(/value\.deep\.onDone/); + }); + + it("still returns ordinary data untouched", async () => { + const growApi = await wired(); + expect(await growApi.factory.plain()).toEqual({ ok: 1, list: [1, 2, 3] }); + }); +}); + +describe("finding 6 — answering a call does not corrupt the served instance's records", () => { + it("keeps a called leaf reported as a callable leaf, with no Function.prototype members", async () => { + const serveApi = await instance(REGRESSION_DIR); + const growApi = await instance(GROW_DIR); + const [near, far] = createPair(); + const first = await serve(serveApi, far, { paths: ["intl"] }); + teardown.push(async () => first.close()); + const link = await grow(growApi, near, { handshakeMs: 5000, budgetMs: 2000 }); + teardown.push(async () => link.close()); + + expect(await growApi.intl["café"]()).toBe("coffee"); + + // A `leaf.apply(parent, args)` dispatch would have turned `intl.café` into a NAMESPACE owning + // `intl.café.apply`, and this second serve would publish that instead of the leaf itself. + const [, other] = createPair(); + const second = await serve(serveApi, other, { paths: ["intl"] }); + teardown.push(async () => second.close()); + expect(second.leaves).toEqual(["intl.café", "intl.ok"]); + expect(second.leaves.some((leaf) => /\.(apply|call|bind)$/.test(leaf))).toBe(false); + }); +}); + +describe("finding 8a — mounting stops when the far side dies mid-mount", () => { + it("mounts nothing further and reports the rest as skipped", async () => { + let fireClose = null; + let added = 0; + const channel = { + send() {}, + onMessage(handler) { + handler({ type: "surface", v: 1, leaves: ["a.one", "b.two", "c.three"] }); + }, + onClose(handler) { + fireClose = handler; + } + }; + const api = { + slothlet: { + api: { + async add() { + // The peer dies while the mount loop is still walking the manifest. + if (++added === 1) fireClose({ reason: "peer-died" }); + }, + async remove() {}, + async leaves() { + return []; + } + } + } + }; + + const link = await grow(api, channel, { handshakeMs: 1000, budgetMs: 1000 }); + expect(added).toBe(1); + expect(link.leaves).toEqual(["a.one"]); + expect(link.skipped).toEqual(["b.two", "c.three"]); + await expect(link.closed).resolves.toMatchObject({ reason: "gone" }); + }); +}); + +describe("finding 8b — handshakeMs has explicit semantics, never a silent forever-wait", () => { + /** + * A channel that never publishes a surface. + * @returns {object} The silent channel. + */ + function silent() { + return { send() {}, onMessage() {} }; + } + + /** + * A slothlet stand-in with nothing mounted. + * @returns {object} The fake api. + */ + function bareApi() { + return { + slothlet: { + api: { + async add() {}, + async remove() {}, + async leaves() { + return []; + } + } + } + }; + } + + it.each([ + ["null", null], + ["zero", 0], + ["negative", -1], + ["NaN", Number.NaN], + ["a string", "50"] + ])("falls back to the default budget for %s rather than waiting forever", async (_label, handshakeMs) => { + const started = Date.now(); + await expect(grow(bareApi(), silent(), { budgetMs: 40, handshakeMs })).rejects.toMatchObject({ code: CODES.BUDGET, budgetMs: 40 }); + expect(Date.now() - started).toBeLessThan(3000); + }); + + it("treats Infinity as the documented opt-out and keeps waiting", async () => { + const growing = grow(bareApi(), silent(), { budgetMs: 30, handshakeMs: Number.POSITIVE_INFINITY }); + const raced = await Promise.race([growing.then(() => "settled"), new Promise((resolve) => setTimeout(() => resolve("pending"), 200))]); + expect(raced).toBe("pending"); + }); +}); + +describe("finding 8c — a hostile error frame still settles the call it belongs to", () => { + it("rejects immediately instead of degrading to a budget wait", async () => { + const api = await instance(GROW_DIR); + const [near, far] = createPair(); + far.send({ type: "surface", v: 1, leaves: ["fresh.leaf"] }); + far.onMessage((frame) => { + if (frame?.type !== "call") return; + far.send({ + type: "error", + callId: frame.callId, + error: { + get name() { + throw new Error("gotcha"); + }, + get message() { + throw new Error("gotcha"); + }, + get code() { + throw new Error("gotcha"); + }, + get stack() { + throw new Error("gotcha"); + } + } + }); + }); + + const link = await grow(api, near, { handshakeMs: 5000, budgetMs: 5000 }); + teardown.push(async () => link.close()); + + const started = Date.now(); + let caught; + try { + await api.fresh.leaf(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(VineRemoteError); + expect(caught.code).toBe(CODES.REMOTE); + expect(caught.name).toBe("Error"); + expect(caught.message).toBe(""); + // The point of the fix: this settled on the frame, not on the 5s budget. + expect(Date.now() - started).toBeLessThan(2000); + }); +}); + +describe("finding 8d — close() releases the receive closure", () => { + it("re-registers a handler that is no longer the link's, and ignores later frames", async () => { + /** @type {Function[]} */ + const handlers = []; + const channel = { + send() {}, + onMessage(handler) { + handlers.push(handler); + if (handlers.length === 1) handler({ type: "surface", v: 1, leaves: ["far.leaf"] }); + } + }; + const api = { + slothlet: { + api: { + async add() {}, + async remove() {}, + async leaves() { + return []; + } + } + } + }; + + const link = await grow(api, channel, { handshakeMs: 1000, budgetMs: 1000 }); + expect(handlers).toHaveLength(1); + + await link.close(); + expect(handlers).toHaveLength(2); + expect(handlers[1]).not.toBe(handlers[0]); + // The replacement is inert: a late frame is neither answered nor thrown into the transport. + expect(() => handlers[1]({ type: "result", callId: "whatever", value: 1 })).not.toThrow(); + }); +}); diff --git a/tests/scaffold.test.vitest.mjs b/tests/scaffold.test.vitest.mjs deleted file mode 100644 index 71bd7f0..0000000 --- a/tests/scaffold.test.vitest.mjs +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @Project: @cldmv/slothlet-vine - * @Filename: /tests/scaffold.test.vitest.mjs - * - * Characterization tests for the pre-implementation scaffold: the public surface - * exists at the documented paths, and every stub fails LOUDLY with a message that - * names itself and says it is not implemented — never a silent no-op a consumer - * could mistake for working forwarding. These tests are replaced/extended as the - * real implementation lands with the browser spike. - */ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -import { grow, serve } from "../src/index.mjs"; - -const TRANSPORTS = ["loopback", "post-message", "worker-threads", "process", "websocket"]; - -describe("package surface", () => { - it("root export exposes grow + serve as functions", () => { - expect(typeof grow).toBe("function"); - expect(typeof serve).toBe("function"); - }); - - it.each(TRANSPORTS)("transport subpath '%s' resolves and exposes createChannel", async (name) => { - const mod = await import(`../src/transport/${name}.mjs`); - expect(typeof mod.createChannel).toBe("function"); - }); - - it("package.json exports map lists every transport subpath", () => { - const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")); - for (const name of TRANSPORTS) expect(pkg.exports[`./transport/${name}`]).toBe(`./src/transport/${name}.mjs`); - expect(pkg.exports["."]).toBe("./src/index.mjs"); - expect(pkg.peerDependencies["@cldmv/slothlet"]).toBeTruthy(); - }); -}); - -describe("stubs fail loudly (pre-release contract)", () => { - it("grow throws a not-implemented error naming itself", () => { - expect(() => grow({}, { send() {}, onMessage() {} })).toThrowError(/grow/); - expect(() => grow()).toThrowError(/not implemented/i); - }); - - it("serve throws a not-implemented error naming itself", () => { - expect(() => serve({}, { send() {}, onMessage() {} })).toThrowError(/serve/); - expect(() => serve()).toThrowError(/not implemented/i); - }); - - it.each(TRANSPORTS)("transport '%s' createChannel throws a not-implemented error naming its transport", async (name) => { - const { createChannel } = await import(`../src/transport/${name}.mjs`); - expect(() => createChannel()).toThrowError(new RegExp(`transport/${name}`)); - expect(() => createChannel()).toThrowError(/not implemented/i); - }); -}); - -describe("frame schema (draft)", () => { - const schema = JSON.parse(readFileSync(fileURLToPath(new URL("../schemas/frame.schema.json", import.meta.url)), "utf8")); - - it("is a 2020-12 JSON Schema with the two frame shapes", () => { - expect(schema.$schema).toContain("2020-12"); - expect(Array.isArray(schema.oneOf)).toBe(true); - expect(schema.oneOf).toHaveLength(2); - }); - - it("call frames require type/callId/path/args; result|error frames require type/callId", () => { - const [call, settle] = schema.oneOf; - expect(call.required).toEqual(["type", "callId", "path", "args"]); - expect(call.properties.type.const).toBe("call"); - expect(settle.required).toEqual(["type", "callId"]); - expect(settle.properties.type.enum).toEqual(["result", "error"]); - }); -}); From 9318248e61a0cb7424c87751f8ead70f48e5a784 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 04:49:56 -0700 Subject: [PATCH 2/3] build: add files allowlist so the published tarball is runtime-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a files field npm would ship all 64 repo files (tests, fixtures, .github, .configs, docs — 417kB) as the 1.0.0 debut. Restrict to src + schemas + README + LICENSE: 16 files, 150kB unpacked. --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index 4a3930c..fba8982 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,12 @@ "access": "public" }, "sideEffects": false, + "files": [ + "src", + "schemas", + "README.md", + "LICENSE" + ], "repository": { "type": "git", "url": "git+https://github.com/CLDMV/slothlet-vine.git" From e131e93862f263a453b0fb5c47358742bbdf21ca Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 07:09:51 -0700 Subject: [PATCH 3/3] docs: reference the upstream slothlet fixes; clarify these guards are keepers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three slothlet bugs found while building vine now have fix PRs (CLDMV/slothlet#305/#306/#307 for issues #302/#303/#304). Update the code comments to reference them — but the guards STAY, and the comments now say why: - frame.mjs UNSAFE_SEGMENTS is security (validating untrusted REMOTE surface paths at vine's boundary), not a bug workaround — independent of the slothlet version. - grow.mjs hyphen moduleID is zero-cost and works on patched + unpatched slothlet. - serve.mjs Reflect.apply is idiomatic and shadow-proof — better than leaf.apply even once #307 lands. No behavior change; 330 tests green. --- src/grow.mjs | 2 ++ src/lib/frame.mjs | 5 +++++ src/serve.mjs | 3 +++ 3 files changed, 10 insertions(+) diff --git a/src/grow.mjs b/src/grow.mjs index aec9a24..45c2853 100644 --- a/src/grow.mjs +++ b/src/grow.mjs @@ -93,6 +93,8 @@ export async function grow(api, channel, options = {}) { // moduleID containing a COLON (`vine:`) is accepted by `add()` but is then silently unknown // to `remove()` — the call resolves, reports nothing, and every stub stays mounted AND callable. // A hyphenated id removes cleanly. `close()` verifies the outcome regardless (see below). + // Reported as CLDMV/slothlet#303 and fixed by CLDMV/slothlet#306; the hyphen is kept anyway — it is + // zero-cost, works on both patched and unpatched slothlet, and nothing benefits from a colon. const moduleID = `vine-${nonce}`; const pending = new PendingTable(nonce); diff --git a/src/lib/frame.mjs b/src/lib/frame.mjs index c57a9c2..5f487f5 100644 --- a/src/lib/frame.mjs +++ b/src/lib/frame.mjs @@ -17,6 +17,11 @@ * own reserved roots (`slothlet` / `shutdown` / `destroy` are refused with * `INVALID_CONFIG_API_PATH_INVALID`), so the gap is exactly the prototype chain — which is what * {@link UNSAFE_SEGMENTS} closes. + * + * Reported upstream as CLDMV/slothlet#302 and being hardened in slothlet by CLDMV/slothlet#305, but + * this guard STAYS regardless of the slothlet version: it validates UNTRUSTED remote surface paths + * at vine's own boundary, which is vine's responsibility to enforce independent of what any + * downstream `add()` does (and vine's peer floor spans slothlet versions that predate the fix). */ import { toWire } from "./errors.mjs"; diff --git a/src/serve.mjs b/src/serve.mjs index 50377de..c7976e8 100644 --- a/src/serve.mjs +++ b/src/serve.mjs @@ -238,6 +238,9 @@ async function collectLeaves(api, options) { * publish `intl.café.apply` in place of `intl.café`, handing the far side `Function.prototype.apply` * bound to a real leaf. `Reflect.apply` reads no property and leaves the records untouched (also * verified) — and, incidentally, cannot be hijacked by a leaf that shadows `apply` with its own. + * Reported as CLDMV/slothlet#304 and fixed by CLDMV/slothlet#307; `Reflect.apply` is kept regardless, + * because it is the idiomatic this-arg + args-array dispatch AND shadow-proof — strictly better than + * `leaf.apply` on a fixed slothlet too. * @param {object} api - The slothlet instance. * @param {string} path - Validated dotted path. * @param {unknown[]} args - Call arguments.