diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f59cb..f57c70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,4 @@ jobs: - run: npm test - run: node scripts/package-smoke.mjs - run: node scripts/interop.mjs + - run: node scripts/hydrated-interop.mjs diff --git a/.github/workflows/hydration-spike.yml b/.github/workflows/hydration-spike.yml deleted file mode 100644 index 359aafb..0000000 --- a/.github/workflows/hydration-spike.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: hydration-spike -on: - pull_request: - push: - branches: [main] - workflow_dispatch: -permissions: - contents: read -jobs: - experiment: - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - defaults: - run: - working-directory: spikes/hydration/ts - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - - run: npm run fetch - - run: npm ci --ignore-scripts - - run: npm run check - - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f539ef1..998feae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: - run: npm ci --ignore-scripts && npm run check && npm run build && npm test - run: node scripts/package-smoke.mjs - run: node scripts/interop.mjs + - run: node scripts/hydrated-interop.mjs - run: node scripts/go-smoke.mjs "$GITHUB_REF_NAME" - name: Pack release run: | diff --git a/AGENTS.md b/AGENTS.md index 5499ead..3c3132f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # Working here as an agent -Read [CHARTER.md](CHARTER.md), [the current realization](docs/WIRE-RUNTIME.md), and +Read [CHARTER.md](CHARTER.md), [the baseline realization](docs/WIRE-RUNTIME.md), [the hydrated layer](hydrated/README.md), and bitwire decision 0015 with its wire/carrier contracts at the pinned dependency. Read [the composition plan](docs/COMPOSITION.md) before adding routing across -instances, multiplexing, wire export or stream carriers. Its pending protocol +instances, multiplexing or stream carriers; wire export is the hydrated layer +(bitwire decision 0019). Its pending protocol decisions are prerequisites, not implemented APIs or permission to infer laws. - Implement addressless Endpoints in carriers and one reusable addressed layer above them. Wire grants sending; Endpoint adds receive/close ownership; WireNode adds complete structure. Change missing or incorrect laws in bitwire first. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4141d3f..39a980b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.7.0 + +- Require bitwire 0.6.0, with its public hydrated declarations and shared pure + hydrated codec. +- Add `hydrated/{go,ts}` (TypeScript subpath `/hydrated`), the realization of + bitwire decision 0019, the hydrated wire protocol's first edition. + - Messages may carry live Wires. References are `(owner path, scope, id)`, + with 16-octet unpredictable ids, valid end to end in one namespace. + - Exports are bound to a local Endpoint's lifetime. An endpoint travels only + as its sending face, on every path. Liveness is judged on send. + - Received context is delivered beside the value. + - Endpoint termination is bitwire's `Termination`. A receiver failure ends + the endpoint and its export. + - The shared codec owns the grammar and bounds. Conversion counts each + occurrence and refuses before allocating, so a compact value with shared + subtrees, a wide tuple or a repeated reference path cannot grow past the + bounds. +- CI and release run the Go/TypeScript hydrated interoperability. The package + and Go smokes exercise the hydrated layer from installed artifacts. +- Add the composition conformance suite (Cell, Text, Counter). +- Remove the hydration spike. + ## 0.6.0 - Restore addressless Wire sending and Endpoint ownership under bitwire 0.5.0. diff --git a/LAYOUT.md b/LAYOUT.md index 634b20f..f7668e2 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -1,7 +1,7 @@ # Repository layout -Use component first, language second: `//`, with two-letter codes such as `go` and `ts`. The current implementation is `core/{go,ts}` and `websocket/{go,ts}`. TypeScript source and tests use `src/` and `test/` within each component; Go tests sit beside their packages. +Use component first, language second: `//`, with two-letter codes such as `go` and `ts`. The current implementation is `core/{go,ts}`, `websocket/{go,ts}` and `hydrated/{go,ts}`. TypeScript source and tests use `src/` and `test/` within each component; Go tests sit beside their packages. -The root `go.mod` and `package.json` describe one release unit. Shared documentation stays under `docs/`. The generic interoperability peer is test-only at `conformance/interop/go`; its runner is `scripts/interop.mjs`. Test fixtures live with the tests that use them. Do not add empty packages for planned components. +The root `go.mod` and `package.json` describe one release unit. Shared documentation stays under `docs/`. The generic interoperability peer is test-only at `conformance/interop/go`; its runner is `scripts/interop.mjs`. The hydrated peer is `conformance/interop/hydrated`, run by `scripts/hydrated-interop.mjs`. Test fixtures live with the tests that use them. Do not add empty packages for planned components. Move imports, tests, manifests, build/release tooling and documentation together whenever a component moves. See [RELEASING.md](RELEASING.md) for the exported coordinates. diff --git a/README.md b/README.md index 073560c..6e08cb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bitruntime -Go and TypeScript implementations of [bitwire 0.5.0](https://github.com/Bitspark/bitwire/tree/v0.5.0): addressless endpoints, addressed access, complete byte-keyed trees, local pairs and WebSocket carriers. +Go and TypeScript implementations of [bitwire 0.6.0](https://github.com/Bitspark/bitwire/tree/v0.6.0): addressless endpoints, addressed access, complete byte-keyed trees, local pairs and WebSocket carriers, and the hydrated layer of [decision 0019](https://github.com/Bitspark/bitwire/blob/v0.6.0/docs/decisions/0019-hydrated-wire-protocol.md) ([hydrated/README.md](hydrated/README.md)). Wire grants sending ground ontos values. Endpoint adds one detachable receive owner, termination and closure. AddressedWire adds a separate path parameter; an AddressedEndpoint presents that layer over an existing endpoint. WireNode is a complete deixis tree whose own values are send capabilities. @@ -9,7 +9,7 @@ Wire grants sending ground ontos values. Endpoint adds one detachable receive ow Install the release package and shared contract: ```sh -npm install --allow-remote=root --@bitspark:registry=https://registry.npmjs.org @bitspark/bitwire@0.5.0 https://github.com/Bitspark/bitruntime/releases/download/v0.6.0/bitspark-bitruntime-0.6.0.tgz +npm install --allow-remote=root --@bitspark:registry=https://registry.npmjs.org @bitspark/bitwire@0.6.0 https://github.com/Bitspark/bitruntime/releases/download/v0.7.0/bitspark-bitruntime-0.7.0.tgz ``` ```ts @@ -27,15 +27,17 @@ A raw endpoint can send an atom directly. The addressed facade works on the same `/core` exports `pair`, `addressed`, `bind`, `under`, `asAddressed`, `compose`, `select` and `route`. Binding captures a path and returns send authority only; `under` captures a prefix. `asAddressed` selects a WireNode node and invokes its own sender once. MissingPathError differs from refusal by that sender. `route(tree,path,message)` does the analogous explicit selection for receiver trees. Structural construction and selection invoke no own capabilities. +`/hydrated` realizes bitwire's hydrated layer (decision 0019): `Scope`, `Endpoint`, `Namespace`, `hydratedTuple` and `items`. A message may carry live Wires. A participant's dispatcher hands its `Scope` each value addressed to its path, with the received context. An `Endpoint`'s `wire` is the only exportable local Wire, and closing the endpoint ends its export. + `/websocket` exports `connectWebSocket`, `listenWebSocket` and `bindWebSocketServer`. A binding owns accepted endpoints and leaves a supplied HTTP/HTTPS server open. A convenience listener owns its HTTP server too. WebSocket negotiates `bitwire.ontos.v2` and carries whole ontos-codec-v1 values. The optional addressed layer encodes `bitwire/addressed/1` as an ordinary value above any carrier. ## Go ```sh -go get github.com/Bitspark/bitruntime@v0.6.0 +go get github.com/Bitspark/bitruntime@v0.7.0 ``` -Use `core/go.NewPair` and `websocket/go.Dial`, `Accept`, `NewServer` or `Listen`. They return bitwire Endpoints. `core/go.Addressed`, `Bind`, `Under` and `AsAddressed` implement the same layering as TypeScript; absent tree paths return ErrMissingPath. A receiver runs on its endpoint's dispatcher. Close does not wait for or cancel dispatched application work. +Use `core/go.NewPair` and `websocket/go.Dial`, `Accept`, `NewServer` or `Listen`. They return bitwire Endpoints. `core/go.Addressed`, `Bind`, `Under` and `AsAddressed` implement the same layering as TypeScript; absent tree paths return ErrMissingPath. `hydrated/go` provides `NewScope`, `NewEndpoint`, `NewNamespace` and `NewTuple` over bitwire's hydrated declarations. A receiver runs on its endpoint's dispatcher. Close does not wait for or cancel dispatched application work. ## Limits and ownership @@ -60,7 +62,8 @@ npm ci --ignore-scripts npm run check npm test node scripts/interop.mjs +node scripts/hydrated-interop.mjs node scripts/package-smoke.mjs ``` -Tests use bitwire-owned observations, independent bytes, native carrier failures, TLS, resource release and cross-language peers. Packed-consumer tests install into a fresh project. [RELEASING.md](RELEASING.md) defines the public Go tag and GitHub package asset process. [The realization record](docs/WIRE-RUNTIME.md) states the contract and evidence. +Tests use bitwire-owned observations, independent bytes, native carrier failures, TLS, resource release and cross-language peers. Packed-consumer tests install into a fresh project. [RELEASING.md](RELEASING.md) defines the public Go tag and GitHub package asset process. [The realization record](docs/WIRE-RUNTIME.md) states the 0.6.0 baseline's contract and evidence; [hydrated/README.md](hydrated/README.md) states the hydrated layer's. diff --git a/RELEASING.md b/RELEASING.md index 277a5d5..e7c18cf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,12 @@ # Releasing -One root `vX.Y.Z` tag versions the Go module and TypeScript package together. bitruntime 0.6.0 implements bitwire 0.5.0: raw ontos-codec-v1 messages, the optional `bitwire/addressed/1` layer and WebSocket `bitwire.ontos.v2`. +One root `vX.Y.Z` tag versions the Go module and TypeScript package together. bitruntime 0.7.0 implements bitwire 0.6.0: raw ontos-codec-v1 messages, the optional `bitwire/addressed/1` layer, WebSocket `bitwire.ontos.v2` and the hydrated layer `bitwire/hydrated/1` (decision 0019). -- Go module: `github.com/Bitspark/bitruntime`, packages `core/go` and `websocket/go`. -- TypeScript package: `@bitspark/bitruntime`, subpaths `/core` and `/websocket`. +- Go module: `github.com/Bitspark/bitruntime`, packages `core/go`, `websocket/go` and `hydrated/go`. +- TypeScript package: `@bitspark/bitruntime`, subpaths `/core`, `/websocket` and `/hydrated`. - TypeScript distribution: GitHub release asset `bitspark-bitruntime-.tgz`, covered by `SHA256SUMS`. This is an npm-compatible tarball, not a registry publication. URL installs with npm 12 require `--allow-remote=root`. -Before tagging, land a reviewed green PR on main. CI requires Go formatting/vet/race tests, TypeScript checking/build/tests, bitwire conformance observations, a fresh installed package consumer and Go/TypeScript WebSocket interoperability. Run the same checks locally where supported. Confirm bitwire's exact dependency release is publicly installable, with no file dependencies, module replacements or private registries. Run `node scripts/go-smoke.mjs ` before tagging. +Before tagging, land a reviewed green PR on main. CI requires Go formatting/vet/race tests, TypeScript checking/build/tests, bitwire conformance observations, a fresh installed package consumer, and Go/TypeScript WebSocket and hydrated interoperability. Run the same checks locally where supported. Confirm bitwire's exact dependency release is publicly installable, with no file dependencies, module replacements or private registries. Run `node scripts/go-smoke.mjs ` before tagging. Keep package.json, package-lock.json and docs/RELEASE.md consistent. Create and push an annotated version tag on the verified main commit. The tag workflow repeats checks, verifies a fresh public Go consumer, packs the TypeScript artifact and publishes the GitHub release. Verify the workflow, remote commit/tag, downloaded artifact checksum and fresh installed consumers before reporting completion. diff --git a/conformance/interop/hydrated/main.go b/conformance/interop/hydrated/main.go new file mode 100644 index 0000000..4e0940a --- /dev/null +++ b/conformance/interop/hydrated/main.go @@ -0,0 +1,171 @@ +// Command hydrated is the Go peer of the hydrated interoperability check +// (scripts/hydrated-interop.mjs), evidence for bitwire decision 0019. Test-only. +// +// hydrated serve listen at participant [s]; on connection, expose an echo +// endpoint and send its reference payload at ["bootstrap"]; +// print the URL; exit when stdin closes +// hydrated client participant [c]: call "continue" with a reply HydratedWire, send a +// third HydratedWire through the returned continuation, print "ok" +// when the third HydratedWire receives the value +// +// The echo: (op, arg, reply) replies (arg, continuation); the continuation sends +// the value it receives to the HydratedWire it carries, then closes. +package main + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + hydrated "github.com/Bitspark/bitruntime/hydrated/go" + websocket "github.com/Bitspark/bitruntime/websocket/go" + ontos "github.com/Bitspark/bitwire/ontos/go/core" + wire "github.com/Bitspark/bitwire/wire/go" +) + +var ( + ns = hydrated.NewNamespace("interop") + bootstrap = wire.Path{ontos.NewAtom([]byte("bootstrap"))} +) + +func text(s string) ontos.Atom { return ontos.NewAtom([]byte(s)) } + +func fail(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +// participant composes a scope at path over an addressed link; bootstrap values +// go to onBootstrap, everything else to the scope with the link as context. +func participant(link wire.AddressedEndpoint, path wire.Path, onBootstrap func(ontos.Value)) *hydrated.Scope { + scope, err := hydrated.NewScope(ns, path, link, hydrated.DefaultLimits) + if err != nil { + fail(err) + } + if _, err := link.Receive(func(p wire.Path, v ontos.Value) { + if wire.PathEqual(p, bootstrap) { + onBootstrap(v) + return + } + if err := scope.Deliver(p, v, "link"); err != nil { + fmt.Fprintln(os.Stderr, "diagnostic:", err) + } + }); err != nil { + fail(err) + } + return scope +} + +func echo(v wire.HydratedValue, _ wire.ReceivedContext) { + xs, _ := hydrated.Items(v) + cont := hydrated.NewEndpoint(1) + _, _ = cont.Receive(func(next wire.HydratedValue, _ wire.ReceivedContext) { + ys, _ := hydrated.Items(next) + _ = ys[1].(wire.HydratedWire).Send(ys[0]) + _ = cont.Close() + }) + r, _ := hydrated.NewTuple(xs[1], cont) + _ = xs[2].(wire.HydratedWire).Send(r) +} + +func serve() { + server, err := websocket.Listen("127.0.0.1:0", websocket.Options{}, func(e *websocket.Endpoint, _ *http.Request) { + link := core.Addressed(e) + scope := participant(link, wire.Path{text("s")}, func(ontos.Value) {}) + service := hydrated.NewEndpoint(16) + _, _ = service.Receive(echo) + ref, err := scope.Expose(service) + if err != nil { + fail(err) + } + payload, err := ref.Payload() + if err != nil { + fail(err) + } + if err := link.Send(bootstrap, payload); err != nil { + fail(err) + } + }) + if err != nil { + fail(err) + } + fmt.Println(server.URL()) + _, _ = bufio.NewReader(os.Stdin).ReadString(0) + _ = server.Close() +} + +func client(url string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + e, err := websocket.Dial(ctx, url, websocket.Options{}) + if err != nil { + fail(err) + } + refs := make(chan ontos.Value, 1) + scope := participant(core.Addressed(e), wire.Path{text("c")}, func(v ontos.Value) { refs <- v }) + var payload ontos.Value + select { + case payload = <-refs: + case <-time.After(10 * time.Second): + fail(fmt.Errorf("no bootstrap reference")) + } + ref, err := hydrated.ReadReference(payload) + if err != nil { + fail(err) + } + reply := hydrated.NewEndpoint(1) + replies := make(chan wire.HydratedValue, 1) + _, _ = reply.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { replies <- v }) + req, _ := hydrated.NewTuple(text("continue"), text("hello"), reply) + if err := scope.Connect(ref).Send(req); err != nil { + fail(err) + } + var r wire.HydratedValue + select { + case r = <-replies: + case <-time.After(10 * time.Second): + fail(fmt.Errorf("no reply")) + } + xs, _ := hydrated.Items(r) + if !xs[0].(ontos.Atom).Equal(text("hello")) { + fail(fmt.Errorf("wrong reply")) + } + third := hydrated.NewEndpoint(1) + thirds := make(chan wire.HydratedValue, 1) + _, _ = third.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { thirds <- v }) + next, _ := hydrated.NewTuple(text("third HydratedWire"), third) + if err := xs[1].(wire.HydratedWire).Send(next); err != nil { + fail(err) + } + select { + case v := <-thirds: + if !v.(ontos.Atom).Equal(text("third HydratedWire")) { + fail(fmt.Errorf("wrong third value")) + } + case <-time.After(10 * time.Second): + fail(fmt.Errorf("third HydratedWire not reached")) + } + fmt.Println("ok") + _ = e.Close() +} + +func main() { + if len(os.Args) < 2 { + fail(fmt.Errorf("usage: hydrated serve | client ")) + } + switch os.Args[1] { + case "serve": + serve() + case "client": + if len(os.Args) < 3 { + fail(fmt.Errorf("client needs a url")) + } + client(os.Args[2]) + default: + fail(fmt.Errorf("unknown mode %q", os.Args[1])) + } +} diff --git a/docs/COMPOSITION.md b/docs/COMPOSITION.md index bcdcc2e..9df7da2 100644 --- a/docs/COMPOSITION.md +++ b/docs/COMPOSITION.md @@ -1,8 +1,12 @@ # Runtime composition plan -**Status, 7 October 2026:** implementation plan, with no new runtime code or API. +**Status, 7 October 2026:** implementation plan. Its exported-Wire facility is +implemented in bitruntime 0.7.0 as the hydrated layer; routing, multiplexing and +the stream carrier remain planned, with no runtime code or API. The released baseline is bitruntime 0.6.0 with bitwire 0.5.0. The current -[realization record](WIRE-RUNTIME.md) describes that implementation. The +[realization record](WIRE-RUNTIME.md) describes that implementation. bitruntime +0.7.0, with bitwire 0.6.0, adds the hydrated layer (`hydrated/{go,ts}`), which +realizes the exported-Wire facility as bitwire decision 0019. The [bitwire composition architecture](https://github.com/Bitspark/bitwire/blob/main/docs/wire/composition.md) owns the target semantics and acceptance obligations; this page owns how bitruntime should realize them. Future protocol records must settle the pending @@ -30,9 +34,9 @@ at the peer. These are cooperating facilities, not aliases for one operation. | Proposed stream component | Read/write framing, decode bounds, shutdown and concrete stream bindings. | A new bitwire stream-framing contract. | | Proposed routing component | Explicit mount registration, dispatch/forwarding, binding generations, return-route bindings and bounded route state. | A specified routing/mount profile; no implicit changes to DeixisNode selection. | | Proposed multiplexing component | Open/accept/refuse logical connections, own the carrier receive attachment, manage channel state/queues and release. | A specified bitwire multiplexing protocol. | -| Proposed reference component | Export/import Wire references, forward sends, reject stale references and release registry entries. | A specified bitwire wire-export protocol. | +| Reference component, implemented in 0.7.0 as `hydrated/{go,ts}` | Export/import Wire references inside hydrated values, forward sends, refuse stale and unknown references, and end exports with their endpoints. | bitwire decision 0019 (`bitwire/hydrated/1`) and bitwire 0.6.0's public declarations and pure codec. | -Names and public signatures for proposed components remain to be selected. +Names and public signatures for the remaining proposed components remain to be selected. Follow component-first paths and the two runtime languages when code is added. Reuse endpoint machinery where semantics match; do not copy an old RPC engine, envelope interface or second generic Wire. bitwire supplies meaning, canonical @@ -89,6 +93,13 @@ A proxy grants Wire.send, not Endpoint ownership. Duplicate exports, re-export through another instance and release/send races require stated outcomes. A channel per exported Wire is optional; examine addressed reference routing first. +bitruntime 0.7.0 realizes export as decision 0019 states it. References are +end-to-end `(owner path, scope, id)` with unpredictable ids, valid within one +namespace, so transit participants keep no export state and forwarding copies a +reference. An export lives exactly as long as its local Endpoint. No channel per +exported Wire and no multiplexed logical Endpoint is involved. Gateways between +namespaces are not defined. + ### Both composition orders | Composition | Mechanism | Additional requirement | @@ -108,7 +119,7 @@ equivalent to one concatenated path. | --- | --- | | 1. Protocol records | bitwire records routing/mounts, multiplexing, wire export and composition choices, expected outcomes and independent vectors. Each implementation follows its own settled record. | | 2. Routing realization | Implement explicit bindings and routing in Go and TypeScript; demonstrate local then multi-process traversal, cross-branch replies under a declared service convention, refusal and stale-binding isolation. | -| 3. Multiplexing and exports | Implement logical endpoints and export/import against the protocols. Exercise endpoint observations plus allocation, authority, aliasing, release and aggregate resource cases. | +| 3. Multiplexing and exports | Implement logical endpoints and export/import against the protocols. Exercise endpoint observations plus allocation, authority, aliasing, release and aggregate resource cases. Export is delivered in 0.7.0 (the hydrated layer); multiplexing remains. | | 4. Additional carrier | Define stream framing in bitwire and implement a TCP/TLS binding. Verify arbitrary chunks, EOF/truncation, limits and release. This can proceed alongside stages 2 and 3 after its own contract exists. | | 5. Combined consumer | Run the same machinery over local, WebSocket and the new stream carrier, in both language roles and fresh packaged consumers. Then a host or `Pages` consumer can adopt it explicitly. | @@ -141,8 +152,12 @@ Add these separately reported families: - Native Go/TypeScript observations, Go race checks, cross-language and multi-process runs, and independently installed public package consumers. -These new families are unrun. Existing 0.6.0 endpoint checks do not establish -multi-hop safety, multiplexing or capability transfer. A documentation CI pass +The export family runs in 0.7.0: decision 0019's observations in Go and +TypeScript, its independent vectors, the composition conformance suite and +Go/TypeScript hydrated interoperability ([hydrated/README.md](../hydrated/README.md)). +Its multi-hop cases run around an opaque test router, not a deployed host. The +routing, multiplexing and stream families remain unrun. Existing endpoint checks +do not establish multi-hop routing safety or multiplexing. A documentation CI pass does not change that status. Each delivered stage must name its exact source, protocol version, environments, observations and remaining limits here or in its linked realization record before a release claims it. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 77a1d58..2f9c8bd 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,11 +1,27 @@ -# bitruntime 0.6.0 +# bitruntime 0.7.0 -This breaking release implements bitwire 0.5.0's addressless Wire and Endpoint in Go and TypeScript. Local pairs and WebSocket carry opaque ground ontos values without mandatory IDs, paths or request-response fields. +This release adds the hydrated wire layer, realizing bitwire decision 0019 over +bitwire 0.6.0's public hydrated declarations and shared pure codec. A message may +carry live Wires inside its structure. A Wire travels as a reference that its +receiver turns back into a usable send-only Wire. A reference returning to its +owner restores the original Wire. Routers between participants see only ground +values. -One addressed layer works over either carrier. bind captures a path into a send-only Wire; under composes prefixes; asAddressed derives access from a complete WireNode. Selection and reconstruction preserve own capabilities without invoking them. Missing paths remain distinct from refused operations. +`hydrated/go` and the TypeScript subpath `/hydrated` provide: +- **Scopes.** One per participant incarnation, with a random token. Export ids + are 16 unpredictable octets, so one reference never reveals another. +- **Endpoints.** Their sending faces are the only exportable local Wires. An + export ends when its endpoint closes, a receiver failure terminates the + endpoint, and termination is reported as bitwire's `Termination`. +- **Proxies.** Recognized throughout the namespace, so forwarding never wraps a + proxy in another. +- **Liveness and context.** Liveness is judged on send. The composition's + received context is delivered beside each value, never inside it. +- **Bounds.** Nodes, depth and bytes are counted one way in both directions. + Conversion refuses before allocating, and incoming frames count whole. -The generic envelope API and decoder are removed. WebSocket negotiates bitwire.ontos.v2 and carries ontos-codec-v1 values. Addressed access uses bitwire/addressed/1 above that raw boundary. Consumers own their service exchange fields. - -Existing local admission, ordering, bounded queues, exclusive detachable receiving and resource-release guarantees remain. Facades retain the underlying endpoint's ownership and lifetime. - -Validation covers contract-owned raw and addressed observations, exact byte paths, independent bytes, Go race tests, TLS/origin checks, both Go/TypeScript connection roles and fresh installed consumers. TypeScript ships as the release tarball with SHA256SUMS; Go ships through the root tag. +Validation covers decision 0019's observations in Go and TypeScript, the +independent vectors, the composition conformance suite, Go race tests, +Go/TypeScript hydrated interoperability in both roles and fresh installed +consumers. The core and WebSocket guarantees of 0.6.0 are unchanged. TypeScript +ships as the release tarball with SHA256SUMS; Go ships through the root tag. diff --git a/docs/WIRE-RUNTIME.md b/docs/WIRE-RUNTIME.md index ac47ac5..50fb844 100644 --- a/docs/WIRE-RUNTIME.md +++ b/docs/WIRE-RUNTIME.md @@ -1,5 +1,12 @@ # Layered wire realization +**Scope:** this record is the bitruntime 0.6.0 baseline: addressless endpoints, +the addressed facade, local pairs and WebSocket over bitwire 0.5.0. bitruntime +0.7.0 keeps that baseline unchanged and adds the hydrated layer, which carries +exported live Wires as bitwire decision 0019 specifies; its record is +[hydrated/README.md](../hydrated/README.md). Statements below about what is +absent describe the 0.6.0 baseline. + **Target decided 2026-10-05; implemented.** This realizes bitwire decision 0015 and deixis's structural identity under the owner's cross-repository correction. It replaces the generic envelope realization without retaining its @@ -91,7 +98,8 @@ These are the two implemented runtime languages. The other bitwire presentations declare interfaces and check package consumption; no concrete tree implementation or cross-package runtime conformance is claimed for them. Their first concrete implementations must run the same structural observations. Multi-hop relays, -mount-crossing exchanges, cancellation and exported live wires remain absent: +mount-crossing exchanges, cancellation and exported live wires remain absent +(exported live Wires arrive in 0.7.0 as the hydrated layer; see the scope above): their R25b lifecycle/relay families are required when their separately specified protocols are introduced, under decision 0015's triggers. Existing endpoint queue saturation, close and disconnect checks are not evidence for a relay chain. diff --git a/go.mod b/go.mod index 073911d..995a648 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Bitspark/bitruntime go 1.26.0 require ( - github.com/Bitspark/bitwire v0.5.0 + github.com/Bitspark/bitwire v0.6.0 github.com/bitspark/deixis v0.6.0 github.com/coder/websocket v1.8.15 ) diff --git a/go.sum b/go.sum index e603cae..a417cb1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Bitspark/bitwire v0.5.0 h1:qNDUMFTmMI2fVThPgR4UkOp/fTO2J6h1Q7pjhUrL9DY= -github.com/Bitspark/bitwire v0.5.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= +github.com/Bitspark/bitwire v0.6.0 h1:Da4R/tRW+hw615DrsaPJ2KwFz0s/kagKdgN9nXfOf1s= +github.com/Bitspark/bitwire v0.6.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= github.com/bitspark/deixis v0.6.0 h1:YAbZiEB+wlQtZoTwLd2+3xGagsfVqmOEH0Fh+fzzaOA= github.com/bitspark/deixis v0.6.0/go.mod h1:jw08wxGevbjoJPBnUaDX549ayvjIOOlLk5GXhIkxu5A= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= diff --git a/hydrated/README.md b/hydrated/README.md new file mode 100644 index 0000000..3c1f225 --- /dev/null +++ b/hydrated/README.md @@ -0,0 +1,36 @@ +# Hydrated wires + +**Status, 7 October 2026:** the realization of bitwire decision 0019, the hydrated +wire protocol's first edition, accepted at bitwire `5b1de82`, for bitruntime +0.7.0. It implements bitwire 0.6.0's public hydrated declarations +(`HydratedValue`, `HydratedWire`, `HydratedEndpoint`, `ReceivedContext`) and +encodes and decodes through bitwire's shared pure hydrated codec, which owns the +grammar and its bounds. No private copy of either remains here. + +[The Go package](go/hydrated.go) and [the TypeScript module](ts/src/index.ts) +hold the stateful half of the protocol: +- scopes with random tokens and 16-octet unpredictable export ids; +- frames sent to owner paths; +- proxies recognized throughout the namespace; +- exports bound to a local Endpoint's lifetime; +- liveness judged on send; +- received context delivered beside the value; +- one counting domain for bounds, in both directions; +- Endpoint termination, reported as bitwire's `Termination`. + +[The Go tests](go/hydrated_test.go) run the record's observations 1-15 and 17 +around an opaque middle router, over local pairs and WebSocket where the carrier +matters. The [TypeScript tests](ts/test/hydrated.test.mjs) repeat observations +1-4, 6-10, 15 and 17. [scripts/hydrated-interop.mjs](../scripts/hydrated-interop.mjs) +runs observation 16 over a real WebSocket in both roles: Go serving TypeScript, +and TypeScript serving Go. The peers build from this repository; published +packages are qualified at release. + +[The composition suite](ts/test/COMPOSITION.md) is Fiber Composition's +consumer conformance, accepted at `4217f1b`. It covers Cell, Text and Counter +adapters, nested cells, lazy reads, both write directions, failures and bounded +reply lifetimes, over both carriers. + +The record's vectors are copied from bitwire `5b1de82` into +[testdata](go/testdata/hydrated-vectors.json), pinned by sha256; both languages +replay them. diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go new file mode 100644 index 0000000..5cc861a --- /dev/null +++ b/hydrated/go/hydrated.go @@ -0,0 +1,795 @@ +// Package hydrated realizes bitwire decision 0019, the hydrated wire protocol's +// first edition, over bitwire's public hydrated declarations and its shared pure +// codec. The codec owns the grammar, validation and bounds; this package owns +// export ids and staging, reference recognition, liveness and endpoints. +// +// A Scope is one participant's hydration state for one incarnation. Its +// dispatcher owns the participant's addressed receive slot and hands the Scope +// every value addressed to the participant's own path, with the received context +// the composition establishes. Domain code sees only Values, Wires and Endpoints. +package hydrated + +import ( + "crypto/rand" + "errors" + "fmt" + "strconv" + "sync" + + ontos "github.com/Bitspark/bitwire/ontos/go/core" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// The runtime realizes bitwire's public hydrated declarations. +var ( + _ wire.HydratedEndpoint = (*Endpoint)(nil) + _ wire.HydratedWire = (*face)(nil) + _ wire.HydratedWire = (*proxy)(nil) + _ wire.HydratedTuple = Tuple{} +) + +// Local is the context of a send that never left the process. +type Local struct{} + +// Refusal names a refusal. At the owner it is a host diagnostic; at the sender +// it refuses the send before admission. It is never an outcome (D8). +type Refusal string + +func (r Refusal) Error() string { return string(r) } + +const ( + MalformedFrame Refusal = "malformed-frame" + StaleScope Refusal = "stale-scope" + UnknownExport Refusal = "unknown-export" + Limit Refusal = "limit" + TargetRefused Refusal = "target-refused" + Unexportable Refusal = "unexportable" + ForeignNamespace Refusal = "foreign-namespace" + ScopeEnded Refusal = "scope-ended" +) + +// Tuple is a hydrated tuple with at least one wire.HydratedWire beneath it. NewTuple returns +// the ground tuple when there is none (D1). +type Tuple struct{ items []wire.HydratedValue } + +// NewTuple captures items. Each must be an ontos value, a Tuple or a wire.HydratedWire. An +// Endpoint is captured as its sending face: a value never carries receive or +// close authority, on any path (D7). +func NewTuple(items ...wire.HydratedValue) (wire.HydratedValue, error) { + captured := make([]wire.HydratedValue, len(items)) + for i, item := range items { + captured[i] = sendOnly(item) + } + ground := make([]ontos.Value, 0, len(captured)) + live := false + for _, item := range captured { + switch x := item.(type) { + case ontos.Atom: + ground = append(ground, x) + case ontos.Tuple: + ground = append(ground, x) + case Tuple, *face, *proxy: + live = true + case wire.HydratedWire: + return nil, Unexportable // only the runtime's own Wires are leaves (D1) + default: + return nil, errors.New("hydrated: not a value") + } + } + if live { + return Tuple{captured}, nil + } + return ontos.NewTuple(ground...), nil +} + +// sendOnly projects an Endpoint to its sending face, and the zero Tuple, the +// only one NewTuple does not make, to the ground empty tuple (D1, D7). +func sendOnly(v wire.HydratedValue) wire.HydratedValue { + switch x := v.(type) { + case *Endpoint: + return x.face + case Tuple: + if x.items == nil { + return ontos.NewTuple() + } + } + return v +} + +// Items returns the tuple's items. +func (t Tuple) Items() []wire.HydratedValue { return append([]wire.HydratedValue(nil), t.items...) } + +// Items returns any tuple's items, ground or hydrated. +func Items(v wire.HydratedValue) ([]wire.HydratedValue, bool) { + switch x := v.(type) { + case Tuple: + return x.Items(), true + case ontos.Tuple: + out := make([]wire.HydratedValue, x.Len()) + for i, item := range x.Items() { + out[i] = item + } + return out, true + } + return nil, false +} + +// Namespace identifies one set of participants addressed by absolute paths. +// Proxies remember theirs, so recognition is runtime-wide within it (D6). +type Namespace struct{ name string } + +// NewNamespace creates a namespace. +func NewNamespace(name string) *Namespace { return &Namespace{name} } + +// Reference is the ground form of a live wire.HydratedWire (D2, D3). +type Reference struct { + Path wire.Path + Scope, ID ontos.Atom +} + +func (r Reference) data() (wire.HydratedReference, error) { + return wire.NewHydratedReference(r.Path, r.Scope, r.ID) +} + +// Payload is the reference's ground payload ((path...), scope, id), for +// composition bootstrap over ground data. +func (r Reference) Payload() (ontos.Value, error) { + d, err := r.data() + if err != nil { + return nil, refusal(err) + } + v, err := wire.PackHydratedReference(d, DefaultLimits.codec()) + return v, refusal(err) +} + +// ReadReference reads a reference payload. +func ReadReference(v ontos.Value) (Reference, error) { + d, err := wire.UnpackHydratedReference(v, DefaultLimits.codec()) + if err != nil { + return Reference{}, refusal(err) + } + return Reference{d.Path(), d.Scope(), d.ID()}, nil +} + +func (r Reference) key() string { + k := strconv.Itoa(len(r.Path)) + for _, a := range r.Path { + k += "/" + strconv.Itoa(a.Len()) + ":" + string(a.Bytes()) + } + return k + "|" + string(r.Scope.Bytes()) + "|" + string(r.ID.Bytes()) +} + +// Endpoint is a local HydratedEndpoint: one receiver, ordered non-inline +// dispatch, a bounded queue and an owner lifetime. Its sending face is what a +// value carries; closing it ends its export in every scope (D7). +type Endpoint struct { + mu sync.Mutex + queue []delivery + limit int + handler func(wire.HydratedValue, wire.ReceivedContext) + gen uint64 // receiver generation: a detach removes only its own receiver + end wire.Termination // the first termination, final once closed is set + closed bool + done chan struct{} + wake chan struct{} + face *face + exports map[*Scope]ontos.Atom +} + +type delivery struct { + value wire.HydratedValue + context wire.ReceivedContext +} + +// face is an Endpoint's sending face. It grants sending only. +type face struct{ e *Endpoint } + +func (f *face) Send(v wire.HydratedValue) error { + projected := sendOnly(v) + if err := checkLocal(projected); err != nil { + return err + } + return f.e.admit(projected, Local{}) +} + +// checkLocal validates a value for local delivery as dehydration would for a +// remote one (D1, D7). A Tuple recognized its leaves when NewTuple built it and +// is immutable, so only the top level needs checking; nothing is traversed. +func checkLocal(v wire.HydratedValue) error { + switch v.(type) { + case ontos.Atom, ontos.Tuple, Tuple, *face, *proxy: + return nil + case wire.HydratedWire: + return Unexportable + } + return errors.New("hydrated: not a value") +} + +// NewEndpoint creates an endpoint whose queue admits at most limit values. A +// bound below one is a programming error. +func NewEndpoint(limit int) *Endpoint { + if limit < 1 { + panic("hydrated: an endpoint bound must be at least one") + } + e := &Endpoint{limit: limit, done: make(chan struct{}), wake: make(chan struct{}, 1), exports: map[*Scope]ontos.Atom{}} + e.face = &face{e} + go e.dispatch() + return e +} + +// wire.HydratedWire is the endpoint's sending face. +func (e *Endpoint) Wire() wire.HydratedWire { return e.face } + +// Send admits a value locally, as its sending face does. +func (e *Endpoint) Send(v wire.HydratedValue) error { return e.face.Send(v) } + +// Receive attaches the one receiver. Values are dispatched in admission order, +// never inline with the admitting send. +func (e *Endpoint) Receive(handler func(wire.HydratedValue, wire.ReceivedContext)) (detach func(), err error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.handler != nil { + return nil, errors.New("hydrated: receiver already attached") + } + e.gen++ + gen := e.gen + e.handler = handler + e.signal() + return func() { + e.mu.Lock() + if e.gen == gen { + e.handler = nil + } + e.mu.Unlock() + }, nil +} + +// Termination reports how the endpoint ended, as bitwire's Endpoint contract +// does: "failed" with the receiver's failure, or "closed". It is the zero value +// while the endpoint is open. The first termination is final: it is set before +// Closed is closed, and a receiver failing after Close leaves it "closed". +func (e *Endpoint) Termination() wire.Termination { + e.mu.Lock() + defer e.mu.Unlock() + return e.end +} + +// Closed is closed when the endpoint closes. +func (e *Endpoint) Closed() <-chan struct{} { return e.done } + +// Close ends the endpoint and withdraws its export from every scope. +func (e *Endpoint) Close() error { + e.terminate(wire.Termination{Kind: "closed"}) + return nil +} + +// terminate ends the endpoint once: the first termination, by Close or by a +// receiver failure, is the one reported, and later ones change nothing. +func (e *Endpoint) terminate(end wire.Termination) { + e.mu.Lock() + if e.closed { + e.mu.Unlock() + return + } + e.closed = true + e.end = end + exports := e.exports + e.exports = map[*Scope]ontos.Atom{} + e.queue = nil + close(e.done) + e.mu.Unlock() + for s, id := range exports { + s.withdraw(e, id) + } +} + +func (e *Endpoint) admit(v wire.HydratedValue, ctx wire.ReceivedContext) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed || len(e.queue) >= e.limit { + return TargetRefused + } + e.queue = append(e.queue, delivery{v, ctx}) + e.signal() + return nil +} + +func (e *Endpoint) signal() { + select { + case e.wake <- struct{}{}: + default: + } +} + +func (e *Endpoint) dispatch() { + for { + select { + case <-e.done: + return + case <-e.wake: + } + for { + e.mu.Lock() + if e.closed || e.handler == nil || len(e.queue) == 0 { + e.mu.Unlock() + break + } + d, h := e.queue[0], e.handler + e.queue = e.queue[1:] + e.mu.Unlock() + if err := deliverTo(h, d); err != nil { + // A receiver failure terminates the endpoint, which ends its + // export, unless the endpoint has already ended. + e.terminate(wire.Termination{Kind: "failed", Message: err.Error()}) + return + } + } + } +} + +func deliverTo(h func(wire.HydratedValue, wire.ReceivedContext), d delivery) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("hydrated: receiver failed: %v", r) + } + }() + h(d.value, d.context) + return nil +} + +// register records an export; it refuses a closed endpoint. +func (e *Endpoint) register(s *Scope, id ontos.Atom) bool { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return false + } + e.exports[s] = id + return true +} + +func (e *Endpoint) unregister(s *Scope) { + e.mu.Lock() + delete(e.exports, s) + e.mu.Unlock() +} + +func (e *Endpoint) isClosed() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.closed +} + +// proxy is an imported wire.HydratedWire. It carries its reference and namespace, so any +// participant in the namespace forwards it unchanged (D6). A proxy with a dead +// reason is this participant's own stale or withdrawn reference: its sends are +// refused locally (D7, returning home). +type proxy struct { + ref Reference + ns *Namespace + via *Scope + dead Refusal +} + +func (p *proxy) Send(v wire.HydratedValue) error { + if p.dead != "" { + return p.dead + } + return p.via.send(p.ref, v) +} + +// Limits bounds a scope (D8). +type Limits struct{ Exports, Nodes, Depth, Bytes int } + +// DefaultLimits are finite defaults; a composition configures its own. +var DefaultLimits = Limits{Exports: 64, Nodes: 4096, Depth: 64, Bytes: 1 << 20} + +// codec is the shared codec's share of the bounds; Exports stays the runtime's. +func (l Limits) codec() wire.HydratedCodecLimits { + return wire.HydratedCodecLimits{Nodes: l.Nodes, Depth: l.Depth, Bytes: l.Bytes} +} + +// refusal names a codec failure as the protocol's refusal (D8). +func refusal(err error) error { + switch { + case errors.Is(err, wire.ErrHydratedLimit): + return Limit + case errors.Is(err, wire.ErrHydratedMalformed): + return MalformedFrame + } + return err +} + +// Scope is one participant's hydration state for one incarnation (Terms). +type Scope struct { + encodeMu sync.Mutex // encoding and registration are one step per scope (D5) + mu sync.Mutex + path wire.Path + token ontos.Atom + ns *Namespace + sender wire.AddressedWire + limits Limits + byID map[string]*Endpoint + byEndpoint map[*Endpoint]ontos.Atom + closed bool +} + +// NewScope creates a scope at path in ns with a fresh random token. sender +// carries frames to owner paths; the composition supplies its routing. +func NewScope(ns *Namespace, path wire.Path, sender wire.AddressedWire, limits Limits) (*Scope, error) { + if limits.Exports <= 0 || limits.Nodes <= 0 || limits.Depth <= 0 || limits.Bytes <= 0 { + return nil, errors.New("hydrated: limits must be positive") + } + token := make([]byte, 16) + if _, err := rand.Read(token); err != nil { + return nil, err + } + return &Scope{ + path: append(wire.Path(nil), path...), token: ontos.NewAtom(token), ns: ns, sender: sender, + limits: limits, byID: map[string]*Endpoint{}, byEndpoint: map[*Endpoint]ontos.Atom{}, + }, nil +} + +// Token is the scope token. +func (s *Scope) Token() ontos.Atom { return s.token } + +// Live reports the number of live exports. +func (s *Scope) Live() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.byEndpoint) +} + +// Expose exports an endpoint's face for composition bootstrap, such as a +// service's well-known reference. Domain adapters never call it. +func (s *Scope) Expose(e *Endpoint) (Reference, error) { + s.encodeMu.Lock() + defer s.encodeMu.Unlock() + staged := map[*Endpoint]ontos.Atom{} + id, err := s.exportID(e, staged) + if err != nil { + return Reference{}, err + } + if err := s.commit(staged); err != nil { + return Reference{}, err + } + return Reference{s.path, s.token, id}, nil +} + +// Connect returns a proxy for a reference, for composition bootstrap. +func (s *Scope) Connect(r Reference) wire.HydratedWire { + return s.importRef(r, map[string]wire.HydratedWire{}) +} + +// Close ends the scope: its exports end and its references go stale. It closes +// no domain endpoint. +func (s *Scope) Close() { + s.mu.Lock() + s.closed = true + endpoints := s.byEndpoint + s.byEndpoint, s.byID = map[*Endpoint]ontos.Atom{}, map[string]*Endpoint{} + s.mu.Unlock() + for e := range endpoints { + e.unregister(s) + } +} + +func (s *Scope) withdraw(e *Endpoint, id ontos.Atom) { + s.mu.Lock() + if s.byID[string(id.Bytes())] == e { + delete(s.byID, string(id.Bytes())) + delete(s.byEndpoint, e) + } + s.mu.Unlock() +} + +// exportID returns e's export id in this scope, staging a new one if needed. A +// new id is 16 octets from a cryptographically secure source, redrawn if it +// equals a live or staged id, so no reference reveals another's (D3). +func (s *Scope) exportID(e *Endpoint, staged map[*Endpoint]ontos.Atom) (ontos.Atom, error) { + if e.isClosed() { + return ontos.Atom{}, Unexportable + } + if id, ok := staged[e]; ok { + return id, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ontos.Atom{}, ScopeEnded + } + if id, ok := s.byEndpoint[e]; ok { + return id, nil + } + if len(s.byEndpoint)+len(staged) >= s.limits.Exports { + return ontos.Atom{}, Limit + } + for { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return ontos.Atom{}, err + } + id := ontos.NewAtom(b) + if s.byID[string(b)] == nil && !stagedID(staged, id) { + staged[e] = id + return id, nil + } + } +} + +func stagedID(staged map[*Endpoint]ontos.Atom, id ontos.Atom) bool { + for _, other := range staged { + if other.Equal(id) { + return true + } + } + return false +} + +// commit registers staged exports before the frame is admitted (D5). An +// endpoint closed meanwhile is not registered; its reference is simply dead. +func (s *Scope) commit(staged map[*Endpoint]ontos.Atom) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ScopeEnded + } + for e, id := range staged { + if _, ok := s.byEndpoint[e]; ok { + continue + } + if e.register(s, id) { + s.byEndpoint[e] = id + s.byID[string(id.Bytes())] = e + } + } + return nil +} + +// send stages v and registers its new exports in one step per scope, then +// admits one frame to the owner path (D5). +func (s *Scope) send(dest Reference, v wire.HydratedValue) error { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return ScopeEnded + } + frame, err := s.stage(dest, v) + if err != nil { + return err + } + return s.sender.Send(dest.Path, frame) +} + +func (s *Scope) stage(dest Reference, v wire.HydratedValue) (ontos.Value, error) { + s.encodeMu.Lock() + defer s.encodeMu.Unlock() + staged := map[*Endpoint]ontos.Atom{} + body, err := s.toData(v, 0, staged, &budget{}) + if err != nil { + return nil, err + } + frame, err := wire.PackHydratedFrame(wire.HydratedFrame{Scope: dest.Scope, ID: dest.ID, Body: body}, s.limits.codec()) + if err != nil { + return nil, refusal(err) + } + return frame, s.commit(staged) +} + +// budget counts a value as D8 does, while converting it. refs holds one +// descriptor per distinct face or proxy, shared by all its occurrences, and +// paths the least encoded size of the paths those descriptors copied. +type budget struct { + nodes, bytes, paths int + refs map[wire.HydratedWire]wire.HydratedReference +} + +// leastPathBytes is a lower bound on a path's encoded size: a tag and a length +// octet per segment, plus its bytes. A frame holding the path is at least this +// large, so refusing beyond the byte bound never refuses a valid value. +func leastPathBytes(path wire.Path) int { + n := 0 + for _, a := range path { + n += 2 + a.Len() + } + return n +} + +// reference returns w's descriptor, copying its path once per conversion. +func (s *Scope) reference(b *budget, w wire.HydratedWire, path wire.Path, scope ontos.Atom, id func() (ontos.Atom, error)) (wire.HydratedData, error) { + if r, ok := b.refs[w]; ok { + return r, nil + } + if b.paths += leastPathBytes(path); b.paths > s.limits.Bytes { + return nil, Limit + } + i, err := id() + if err != nil { + return nil, err + } + r, err := wire.NewHydratedReference(path, scope, i) + if err != nil { + return nil, refusal(err) + } + if b.refs == nil { + b.refs = map[wire.HydratedWire]wire.HydratedReference{} + } + b.refs[w] = r + return r, nil +} + +// refBytes is a Wire leaf's value bytes under D8: its path bytes and 32. +func refBytes(path wire.Path) int { + n := 32 + for _, a := range path { + n += a.Len() + } + return n +} + +func (s *Scope) spend(b *budget, depth, n int) error { + b.nodes++ + b.bytes += n + if b.nodes > s.limits.Nodes || depth > s.limits.Depth || b.bytes > s.limits.Bytes { + return Limit + } + return nil +} + +// toData maps a live value onto the codec's structural data, staging an export +// for each new face. It counts each occurrence as D8 does and refuses before +// allocating, so a compact value with shared subtrees cannot expand past the +// bounds. The codec stays authoritative for every bound. +func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (wire.HydratedData, error) { + switch x := v.(type) { + case ontos.Atom: + if err := s.spend(b, depth, x.Len()); err != nil { + return nil, err + } + return wire.NewHydratedDataAtom(x), nil + case ontos.Tuple, Tuple: + if err := s.spend(b, depth, 0); err != nil { + return nil, err + } + // Index the tuple and grow the output as children are counted: nothing + // is copied or reserved at the tuple's size before the budget allows it. + n, at := tupleIndex(x) + var out []wire.HydratedData + for i := 0; i < n; i++ { + d, err := s.toData(at(i), depth+1, staged, b) + if err != nil { + return nil, err + } + out = append(out, d) + } + t, err := wire.NewHydratedDataTuple(out...) + return t, refusal(err) + case *Endpoint: + return s.toData(x.face, depth, staged, b) + case *face: + if err := s.spend(b, depth, refBytes(s.path)); err != nil { + return nil, err + } + return s.reference(b, x, s.path, s.token, func() (ontos.Atom, error) { return s.exportID(x.e, staged) }) + case *proxy: + if x.ns != s.ns { + return nil, ForeignNamespace + } + if err := s.spend(b, depth, refBytes(x.ref.Path)); err != nil { + return nil, err + } + return s.reference(b, x, x.ref.Path, x.ref.Scope, func() (ontos.Atom, error) { return x.ref.ID, nil }) + case wire.HydratedWire: + return nil, Unexportable + } + return nil, errors.New("hydrated: not a value") +} + +// tupleIndex gives a tuple's length and indexed access without copying it. +func tupleIndex(v wire.HydratedValue) (int, func(int) wire.HydratedValue) { + switch x := v.(type) { + case ontos.Tuple: + return x.Len(), func(i int) wire.HydratedValue { return x.At(i) } + case Tuple: + return len(x.items), func(i int) wire.HydratedValue { return x.items[i] } + } + return 0, nil +} + +// fromData maps decoded structural data onto live values. Occurrences of one +// reference within the value share one proxy; nothing is retained across +// messages (D7). +func (s *Scope) fromData(d wire.HydratedData, interned map[string]wire.HydratedWire) (wire.HydratedValue, error) { + switch x := d.(type) { + case wire.HydratedDataAtom: + return x.Value(), nil + case wire.HydratedDataTuple: + items := make([]wire.HydratedValue, x.Len()) + for i, c := range x.Items() { + var err error + if items[i], err = s.fromData(c, interned); err != nil { + return nil, err + } + } + return NewTuple(items...) + case wire.HydratedReference: + return s.importRef(Reference{x.Path(), x.Scope(), x.ID()}, interned), nil + } + return nil, MalformedFrame +} + +// encodeBody and decodeBody are the body halves of a frame. +func (s *Scope) encodeBody(v wire.HydratedValue, staged map[*Endpoint]ontos.Atom) (ontos.Value, error) { + d, err := s.toData(v, 0, staged, &budget{}) + if err != nil { + return nil, err + } + body, err := wire.PackHydratedBody(d, s.limits.codec()) + return body, refusal(err) +} + +func (s *Scope) decodeBody(body ontos.Value, interned map[string]wire.HydratedWire) (wire.HydratedValue, error) { + d, err := wire.UnpackHydratedBody(body, s.limits.codec()) + if err != nil { + return nil, refusal(err) + } + return s.fromData(d, interned) +} + +// Deliver decides one value addressed to this participant's own path (D4). The +// returned refusal is a host diagnostic; nothing is sent back. +func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx wire.ReceivedContext) error { + if !wire.PathEqual(path, s.path) { + return MalformedFrame + } + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return ScopeEnded + } + frame, err := wire.UnpackHydratedFrame(message, s.limits.codec()) + if err != nil { + return refusal(err) + } + if !frame.Scope.Equal(s.token) { + return StaleScope + } + s.mu.Lock() + target := s.byID[string(frame.ID.Bytes())] + s.mu.Unlock() + if target == nil { + return UnknownExport + } + value, err := s.fromData(frame.Body, map[string]wire.HydratedWire{}) + if err != nil { + return err + } + return target.admit(value, ctx) +} + +// importRef turns a reference into a wire.HydratedWire. A reference to this participant in +// its current scope returns home to the original face; a stale or withdrawn one +// becomes a wire.HydratedWire whose sends are refused. Liveness is judged on send (D7). +func (s *Scope) importRef(r Reference, interned map[string]wire.HydratedWire) wire.HydratedWire { + if wire.PathEqual(r.Path, s.path) { + if !r.Scope.Equal(s.token) { + return &proxy{ref: r, ns: s.ns, via: s, dead: StaleScope} + } + s.mu.Lock() + e := s.byID[string(r.ID.Bytes())] + s.mu.Unlock() + if e == nil { + return &proxy{ref: r, ns: s.ns, via: s, dead: UnknownExport} + } + return e.face + } + k := r.key() + if w, ok := interned[k]; ok { + return w + } + p := &proxy{ref: r, ns: s.ns, via: s} + interned[k] = p + return p +} diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go new file mode 100644 index 0000000..0e416ef --- /dev/null +++ b/hydrated/go/hydrated_test.go @@ -0,0 +1,1170 @@ +package hydrated + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "runtime" + "strings" + "sync" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + websocket "github.com/Bitspark/bitruntime/websocket/go" + ontos "github.com/Bitspark/bitwire/ontos/go/core" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func text(s string) ontos.Atom { return ontos.NewAtom([]byte(s)) } + +// The raw grammar, for crafting frames by hand; the runtime itself uses the codec. +var ( + header = ontos.NewAtom([]byte("bitwire/hydrated/1")) + tupleTag = ontos.NewAtom([]byte{1}) + wireTag = ontos.NewAtom([]byte{2}) +) + +// value is a reference's tagged body node, (02, ((path...), scope, id)). +func (r Reference) value() ontos.Value { + segments := make([]ontos.Value, len(r.Path)) + for i, a := range r.Path { + segments[i] = a + } + return ontos.NewTuple(wireTag, ontos.NewTuple(ontos.NewTuple(segments...), r.Scope, r.ID)) +} + +// pending reads an endpoint's queue length under its lock. +func (e *Endpoint) pending() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.queue) +} + +func tuple(t *testing.T, items ...wire.HydratedValue) wire.HydratedValue { + t.Helper() + v, err := NewTuple(items...) + if err != nil { + t.Fatal(err) + } + return v +} + +func items(t *testing.T, v wire.HydratedValue) []wire.HydratedValue { + t.Helper() + xs, ok := Items(v) + if !ok { + t.Fatalf("not a tuple: %#v", v) + } + return xs +} + +func eventually(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal(what) + } + time.Sleep(2 * time.Millisecond) + } +} + +// --- vectors (bitwire#83 accepted at 6e33fb3, pinned by hash) --- + +type jsonValue map[string]json.RawMessage + +func ground(v jsonValue) ontos.Value { + if raw, ok := v["atom"]; ok { + var h string + _ = json.Unmarshal(raw, &h) + b, _ := hex.DecodeString(h) + return ontos.NewAtom(b) + } + var xs []jsonValue + _ = json.Unmarshal(v["tuple"], &xs) + out := make([]ontos.Value, len(xs)) + for i, x := range xs { + out[i] = ground(x) + } + return ontos.NewTuple(out...) +} + +func wires(v wire.HydratedValue) int { + switch x := v.(type) { + case wire.HydratedWire: + return 1 + case Tuple: + n := 0 + for _, item := range x.items { + n += wires(item) + } + return n + } + return 0 +} + +func TestObservation1VectorsRoundTrip(t *testing.T) { + raw, err := os.ReadFile("testdata/hydrated-vectors.json") + if err != nil { + t.Fatal(err) + } + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != "9d5f684871d1b56719b33bdff3114a101d2a7a1a8702d1f4474303df0e109312" { + t.Fatal("vectors changed; repin to the reviewed bitwire record") + } + var v struct { + Encode []struct { + Name, Live, Hex string + Body jsonValue + } + RejectBody, RejectFrame []struct { + Name string + Value jsonValue + } + } + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + // A scope at a path no vector names: every reference imports as a proxy. + s, _ := NewScope(NewNamespace("vectors"), wire.Path{text("vectors")}, nil, DefaultLimits) + for _, c := range v.Encode { + body := ground(c.Body) + live, err := s.decodeBody(body, map[string]wire.HydratedWire{}) + if err != nil { + t.Fatalf("%s: %v", c.Name, err) + } + if got, want := wires(live), strings.Count(c.Live, "wire{"); got != want { + t.Fatalf("%s: %d wire leaves, want %d", c.Name, got, want) + } + again, err := s.encodeBody(live, map[*Endpoint]ontos.Atom{}) + if err != nil || !again.Equal(body) { + t.Fatalf("%s: re-encoding changed the body: %v", c.Name, err) + } + b, _ := wire.EncodeMessage(again, wire.DefaultMaxMessageBytes) + if hex.EncodeToString(b) != c.Hex { + t.Fatalf("%s: bytes", c.Name) + } + } + for _, c := range v.RejectBody { + if _, err := s.decodeBody(ground(c.Value), map[string]wire.HydratedWire{}); !errors.Is(err, MalformedFrame) { + t.Fatalf("%s: %v", c.Name, err) + } + } + // Frame rejection with a live target at the vectors' scope S and id I1. + s.token = ontos.NewAtom([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) + i1, _ := hex.DecodeString("101112131415161718191a1b1c1d1e1f") + target := NewEndpoint(8) + s.byID[string(i1)], s.byEndpoint[target] = target, ontos.NewAtom(i1) + for _, c := range v.RejectFrame { + if err := s.Deliver(s.path, ground(c.Value), nil); !errors.Is(err, MalformedFrame) { + t.Fatalf("%s: %v", c.Name, err) + } + } + if target.pending() != 0 { + t.Fatal("a rejected frame was delivered") + } +} + +// --- a namespace of participants around an opaque middle router --- + +var paths = map[string]wire.Path{ + "client": {text("client42")}, + "provider": {text("a"), text("b"), text("service2")}, + "third": {text("c")}, +} + +type diagnostic struct { + who string + err error +} + +type participant struct { + name string + ep wire.AddressedEndpoint + mu sync.Mutex + sc *Scope +} + +func (p *participant) scope() *Scope { + p.mu.Lock() + defer p.mu.Unlock() + return p.sc +} + +type tree struct { + ns *Namespace + mu sync.Mutex + routes map[string]wire.AddressedWire + trace []ontos.Value + diags []diagnostic + parts map[string]*participant +} + +func key(p wire.Path) string { return Reference{Path: p}.key() } + +func (tr *tree) diagnostics() []diagnostic { + tr.mu.Lock() + defer tr.mu.Unlock() + return append([]diagnostic(nil), tr.diags...) +} + +func (tr *tree) frames() int { + tr.mu.Lock() + defer tr.mu.Unlock() + return len(tr.trace) +} + +// links returns n pairs of (participant side, middle side) endpoints. +func links(t *testing.T, carrier string, n int) [][2]wire.Endpoint { + out := make([][2]wire.Endpoint, n) + if carrier == "pair" { + for i := range out { + a, b, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + out[i] = [2]wire.Endpoint{a, b} + t.Cleanup(func() { _ = a.Close() }) + } + return out + } + accepted := make(chan *websocket.Endpoint, n) + server, err := websocket.Listen("127.0.0.1:0", websocket.Options{}, func(e *websocket.Endpoint, _ *http.Request) { accepted <- e }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + for i := range out { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + c, err := websocket.Dial(ctx, server.URL(), websocket.Options{}) + cancel() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + out[i] = [2]wire.Endpoint{c, <-accepted} + } + return out +} + +func newTree(t *testing.T, carrier string, names ...string) *tree { + tr := &tree{ns: NewNamespace("test"), routes: map[string]wire.AddressedWire{}, parts: map[string]*participant{}} + ls := links(t, carrier, len(names)) + for i, name := range names { + mine, mid := core.Addressed(ls[i][0]), core.Addressed(ls[i][1]) + tr.routes[key(paths[name])] = mid + if _, err := mid.Receive(func(p wire.Path, v ontos.Value) { + tr.mu.Lock() + tr.trace = append(tr.trace, v) + dest := tr.routes[key(p)] + tr.mu.Unlock() + if dest != nil { // the middle knows routes and ground values only + _ = dest.Send(p, v) + } + }); err != nil { + t.Fatal(err) + } + sc, err := NewScope(tr.ns, paths[name], mine, DefaultLimits) + if err != nil { + t.Fatal(err) + } + p := &participant{name: name, ep: mine, sc: sc} + tr.parts[name] = p + if _, err := mine.Receive(func(path wire.Path, v ontos.Value) { + // The composition's received context: here, the link it arrived on. + if err := p.scope().Deliver(path, v, name+"-link"); err != nil { + tr.mu.Lock() + tr.diags = append(tr.diags, diagnostic{name, err}) + tr.mu.Unlock() + } + }); err != nil { + t.Fatal(err) + } + } + return tr +} + +func (tr *tree) replace(t *testing.T, name string) *Scope { + p := tr.parts[name] + sc, err := NewScope(tr.ns, paths[name], p.ep, DefaultLimits) + if err != nil { + t.Fatal(err) + } + p.mu.Lock() + old := p.sc + p.sc = sc + p.mu.Unlock() + old.Close() + return sc +} + +// serve exposes a domain endpoint at a participant: the composition bootstrap. +func serve(t *testing.T, s *Scope, handler func(wire.HydratedValue, wire.ReceivedContext)) (*Endpoint, Reference) { + e := NewEndpoint(64) + if _, err := e.Receive(handler); err != nil { + t.Fatal(err) + } + ref, err := s.Expose(e) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = e.Close() }) + return e, ref +} + +type received struct { + value wire.HydratedValue + context wire.ReceivedContext +} + +// call is a domain adapter: it sends (op, args..., reply) and closes its reply +// endpoint after the reply. It knows no reference, table or route. +func call(t *testing.T, target wire.HydratedWire, op string, args ...wire.HydratedValue) received { + t.Helper() + reply := NewEndpoint(1) + defer reply.Close() + got := make(chan received, 1) + if _, err := reply.Receive(func(v wire.HydratedValue, c wire.ReceivedContext) { got <- received{v, c} }); err != nil { + t.Fatal(err) + } + if err := target.Send(tuple(t, append(append([]wire.HydratedValue{text(op)}, args...), reply)...)); err != nil { + t.Fatal(err) + } + select { + case r := <-got: + return r + case <-time.After(5 * time.Second): + t.Fatal("no reply") + } + return received{} +} + +// echo is a domain provider: (op, arg, reply). "echo" replies (arg); "continue" +// replies (arg, continuation), where the continuation forwards one value to the +// wire.HydratedWire it carries and then closes. +func echo(t *testing.T) func(wire.HydratedValue, wire.ReceivedContext) { + return func(v wire.HydratedValue, _ wire.ReceivedContext) { + xs, _ := Items(v) + op, arg, reply := xs[0].(ontos.Atom), xs[1], xs[len(xs)-1].(wire.HydratedWire) + switch string(op.Bytes()) { + case "echo": + _ = reply.Send(arg) + case "continue": + cont := NewEndpoint(1) + _, _ = cont.Receive(func(next wire.HydratedValue, _ wire.ReceivedContext) { + ys, _ := Items(next) + _ = ys[1].(wire.HydratedWire).Send(ys[0]) + _ = cont.Close() + }) + r, _ := NewTuple(arg, cont) + _ = reply.Send(r) + } + } +} + +func carriers(t *testing.T, f func(t *testing.T, carrier string)) { + for _, c := range []string{"pair", "websocket"} { + t.Run(c, func(t *testing.T) { f(t, c) }) + } +} + +func TestObservation2RecursionThroughAnOpaqueRouter(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + _, svc := serve(t, tr.parts["provider"].scope(), echo(t)) + target := tr.parts["client"].scope().Connect(svc) + r := call(t, target, "continue", text("hello")) + xs := items(t, r.value) + if !xs[0].(ontos.Atom).Equal(text("hello")) { + t.Fatal("wrong reply") + } + third := NewEndpoint(1) + got := make(chan wire.HydratedValue, 1) + _, _ = third.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + if err := xs[1].(wire.HydratedWire).Send(tuple(t, text("third wire.HydratedWire"), third)); err != nil { + t.Fatal(err) + } + select { + case v := <-got: + if !v.(ontos.Atom).Equal(text("third wire.HydratedWire")) { + t.Fatal("wrong third value") + } + case <-time.After(5 * time.Second): + t.Fatal("third wire.HydratedWire not reached") + } + if n := tr.frames(); n != 4 { + t.Fatalf("%d frames crossed the middle, want 4", n) + } + if d := tr.diagnostics(); len(d) != 0 { + t.Fatal(d) + } + }) +} + +func TestObservation3LongLivedCallsStayBounded(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + client, provider := tr.parts["client"].scope(), tr.parts["provider"].scope() + _, svc := serve(t, provider, echo(t)) + target := client.Connect(svc) + for i := 0; i < 200; i++ { + r := call(t, target, "echo", text(fmt.Sprint(i))) + if !r.value.(ontos.Atom).Equal(text(fmt.Sprint(i))) { + t.Fatalf("call %d: wrong reply", i) + } + if client.Live() > 1 || provider.Live() != 1 { + t.Fatalf("call %d: live exports client %d provider %d", i, client.Live(), provider.Live()) + } + } + eventually(t, "the last reply export did not end", func() bool { return client.Live() == 0 }) + if d := tr.diagnostics(); len(d) != 0 { + t.Fatal(d) + } + }) +} + +func TestObservation4ASharedWireEndsOnlyWithItsOwner(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider", "third") + a, b, c := tr.parts["client"].scope(), tr.parts["provider"].scope(), tr.parts["third"].scope() + var mu sync.Mutex + var atB, atC wire.HydratedWire + _, refC := serve(t, c, func(v wire.HydratedValue, _ wire.ReceivedContext) { + mu.Lock() + atC = v.(wire.HydratedWire) + mu.Unlock() + }) + toC := b.Connect(refC) + _, refB := serve(t, b, func(v wire.HydratedValue, _ wire.ReceivedContext) { + mu.Lock() + atB = v.(wire.HydratedWire) + mu.Unlock() + _ = toC.Send(v) // B forwards the wire.HydratedWire it received + }) + w := NewEndpoint(8) + var seen []wire.ReceivedContext + _, _ = w.Receive(func(_ wire.HydratedValue, ctx wire.ReceivedContext) { mu.Lock(); seen = append(seen, ctx); mu.Unlock() }) + if err := a.Connect(refB).Send(w); err != nil { + t.Fatal(err) + } + eventually(t, "C did not receive the wire.HydratedWire", func() bool { mu.Lock(); defer mu.Unlock(); return atC != nil }) + if b.Live() != 1 || c.Live() != 1 { + t.Fatalf("forwarding registered state: B %d C %d", b.Live(), c.Live()) + } + _ = atC.Send(text("from C")) + eventually(t, "C's send did not reach W", func() bool { mu.Lock(); defer mu.Unlock(); return len(seen) == 1 }) + _ = w.Close() + mu.Lock() + fromB := atB + mu.Unlock() + _ = fromB.Send(text("late B")) + _ = atC.Send(text("late C")) + eventually(t, "late sends were not refused at A", func() bool { + n := 0 + for _, d := range tr.diagnostics() { + if d.who == "client" && errors.Is(d.err, UnknownExport) { + n++ + } + } + return n == 2 + }) + mu.Lock() + defer mu.Unlock() + if len(seen) != 1 { + t.Fatal("a closed wire.HydratedWire received") + } + }) +} + +func TestObservation5NoProxyOfAProxy(t *testing.T) { + tr := newTree(t, "pair", "client", "provider", "third") + a, b, c := tr.parts["client"].scope(), tr.parts["provider"].scope(), tr.parts["third"].scope() + w := NewEndpoint(1) + ref, _ := a.Expose(w) + imported := b.Connect(ref) + for _, s := range []*Scope{b, c} { // a second local participant encodes it too + got, err := s.encodeBody(imported, map[*Endpoint]ontos.Atom{}) + if err != nil || !got.Equal(ref.value()) { + t.Fatalf("%v: reference changed", err) + } + } + if b.Live() != 0 || c.Live() != 0 { + t.Fatal("a proxy was re-exported") + } +} + +type funcWire func(wire.HydratedValue) error + +func (f funcWire) Send(v wire.HydratedValue) error { return f(v) } + +func TestObservation6RefusalsBeforeAdmission(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() + _, svc := serve(t, b, func(wire.HydratedValue, wire.ReceivedContext) {}) + target := a.Connect(svc) + kept := NewEndpoint(1) + foreignWire := funcWire(func(wire.HydratedValue) error { return nil }) + if _, err := NewTuple(kept, foreignWire); !errors.Is(err, Unexportable) { + t.Fatalf("a tuple built with a foreign Wire: %v", err) + } + if err := target.Send(foreignWire); !errors.Is(err, Unexportable) { + t.Fatalf("arbitrary wire.HydratedWire: %v", err) + } + other, _ := NewScope(NewNamespace("other"), wire.Path{text("x")}, nil, DefaultLimits) + foreign := other.Connect(Reference{wire.Path{text("y")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + if err := target.Send(tuple(t, kept, foreign)); !errors.Is(err, ForeignNamespace) { + t.Fatalf("foreign proxy: %v", err) + } + closed := NewEndpoint(1) + _ = closed.Close() + if err := target.Send(closed); !errors.Is(err, Unexportable) { + t.Fatalf("closed endpoint: %v", err) + } + if a.Live() != 0 || tr.frames() != 0 { + t.Fatalf("a refused send left state: %d exports, %d frames", a.Live(), tr.frames()) + } +} + +func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + _, svc := serve(t, tr.parts["provider"].scope(), func(wire.HydratedValue, wire.ReceivedContext) {}) + old := tr.parts["client"].scope().Connect(svc) + fresh := tr.replace(t, "provider") + var mu sync.Mutex + hit := false + _, _ = serve(t, fresh, func(wire.HydratedValue, wire.ReceivedContext) { mu.Lock(); hit = true; mu.Unlock() }) + _ = old.Send(text("stale")) + eventually(t, "not refused stale-scope", func() bool { + d := tr.diagnostics() + return len(d) == 1 && d[0].who == "provider" && errors.Is(d[0].err, StaleScope) + }) + mu.Lock() + defer mu.Unlock() + if hit { + t.Fatal("the replacement received a stale frame") + } + }) +} + +func TestObservation9ReturningHome(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() + var back wire.HydratedWire + var mu sync.Mutex + proceed := make(chan struct{}) + _, svc := serve(t, b, func(v wire.HydratedValue, _ wire.ReceivedContext) { + <-proceed + xs, _ := Items(v) + r, _ := NewTuple(text("data"), xs[0], xs[1]) // send both Wires home + _ = xs[2].(wire.HydratedWire).Send(r) + }) + home, gone := NewEndpoint(1), NewEndpoint(1) + reply := NewEndpoint(1) + got := make(chan wire.HydratedValue, 1) + _, _ = reply.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + if err := a.Connect(svc).Send(tuple(t, home, gone, reply)); err != nil { + t.Fatal(err) + } + // gone closes before its reference comes back. + eventually(t, "export not registered", func() bool { return a.Live() == 3 }) + _ = gone.Close() + close(proceed) + select { + case v := <-got: + xs := items(t, v) + mu.Lock() + back = xs[1].(wire.HydratedWire) + mu.Unlock() + if !xs[0].(ontos.Atom).Equal(text("data")) { + t.Fatal("the rest of the message was lost") + } + if back != home.Wire() { + t.Fatal("own reference did not return to the original face") + } + if err := xs[2].(wire.HydratedWire).Send(text("x")); !errors.Is(err, UnknownExport) { + t.Fatalf("a withdrawn own reference: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("no reply") + } +} + +func TestObservation10ReceivedContextIsNotData(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + got := make(chan received, 1) + _, svc := serve(t, tr.parts["provider"].scope(), func(v wire.HydratedValue, c wire.ReceivedContext) { got <- received{v, c} }) + forged := ontos.NewTuple(text("context"), text("client-link")) + if err := tr.parts["client"].scope().Connect(svc).Send(forged); err != nil { + t.Fatal(err) + } + r := <-got + if r.context != "provider-link" || !forged.Equal(r.value.(ontos.Value)) { + t.Fatalf("context %v, value %v", r.context, r.value) + } +} + +func TestObservation11AtomicDecoding(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + b := tr.parts["provider"].scope() + var mu sync.Mutex + delivered := 0 + _, svc := serve(t, b, func(wire.HydratedValue, wire.ReceivedContext) { mu.Lock(); delivered++; mu.Unlock() }) + w := NewEndpoint(1) + ref, _ := tr.parts["client"].scope().Expose(w) + bad := ontos.NewTuple(tupleTag, ontos.NewTuple(ref.value(), ontos.NewTuple(tupleTag))) + if err := b.Deliver(svc.Path, ontos.NewTuple(header, svc.Scope, svc.ID, bad), nil); !errors.Is(err, MalformedFrame) { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if delivered != 0 { + t.Fatal("a malformed frame was delivered") + } +} + +type refusingSender struct{} + +func (refusingSender) Send(wire.Path, ontos.Value) error { return errors.New("carrier refused") } + +func TestObservation12RegistrationBeforeAdmission(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, DefaultLimits) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + w := NewEndpoint(1) + if err := target.Send(tuple(t, w)); err == nil { + t.Fatal("the carrier's refusal was hidden") + } + if s.Live() != 1 { + t.Fatal("a refused admission withdrew or never registered the export") + } + if err := target.Send(tuple(t, w, w)); err == nil || s.Live() != 1 { + t.Fatal("a second attempt added an export for the same face") + } + _ = w.Close() + if s.Live() != 0 { + t.Fatal("closing the endpoint did not end its export") + } +} + +func TestObservation14Ordering(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + var mu sync.Mutex + var seen []string + _, svc := serve(t, tr.parts["provider"].scope(), func(v wire.HydratedValue, _ wire.ReceivedContext) { + mu.Lock() + seen = append(seen, string(v.(ontos.Atom).Bytes())) + mu.Unlock() + }) + target := tr.parts["client"].scope().Connect(svc) + for i := 0; i < 50; i++ { + if err := target.Send(text(fmt.Sprint(i))); err != nil { + t.Fatal(err) + } + } + eventually(t, "not all delivered", func() bool { mu.Lock(); defer mu.Unlock(); return len(seen) == 50 }) + for i, s := range seen { + if s != fmt.Sprint(i) { + t.Fatalf("position %d holds %s", i, s) + } + } + }) +} + +func TestObservation15Bounds(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, Limits{Exports: 2, Nodes: 8, Depth: 3, Bytes: 64}) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + deep := wire.HydratedValue(text("x")) + for i := 0; i < 5; i++ { + deep = tuple(t, deep) + } + wide := make([]wire.HydratedValue, 9) + for i := range wide { + wide[i] = text("y") + } + for name, v := range map[string]wire.HydratedValue{ + "depth": deep, + "nodes": tuple(t, wide...), + "bytes": ontos.NewAtom(make([]byte, 65)), + "exports": tuple(t, NewEndpoint(1), NewEndpoint(1), NewEndpoint(1)), + } { + if err := target.Send(v); !errors.Is(err, Limit) { + t.Fatalf("%s: %v", name, err) + } + } + if s.Live() != 0 { + t.Fatal("a refused send registered exports") + } + // A target that does not admit a delivery: queue of one, no receiver. + tr := newTree(t, "pair", "client", "provider") + full := NewEndpoint(1) + svc, _ := tr.parts["provider"].scope().Expose(full) + to := tr.parts["client"].scope().Connect(svc) + _ = to.Send(text("1")) + _ = to.Send(text("2")) + eventually(t, "not refused target-refused", func() bool { + d := tr.diagnostics() + return len(d) == 1 && errors.Is(d[0].err, TargetRefused) + }) +} + +// Observation 8: one reference grants its own wire.HydratedWire and no sibling, including an +// endpoint the owner exported for another holder. +func TestObservation8SiblingForgery(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider", "third") + provider := tr.parts["provider"].scope() + var mu sync.Mutex + private := 0 + _, public := serve(t, provider, func(wire.HydratedValue, wire.ReceivedContext) {}) + _, intended := serve(t, provider, func(wire.HydratedValue, wire.ReceivedContext) { mu.Lock(); private++; mu.Unlock() }) + // The private endpoint really is held by another participant. + if err := tr.parts["third"].scope().Connect(intended).Send(text("legitimate")); err != nil { + t.Fatal(err) + } + eventually(t, "the intended holder was not served", func() bool { mu.Lock(); defer mu.Unlock(); return private == 1 }) + if public.ID.Len() != 16 || public.ID.Equal(intended.ID) { + t.Fatalf("export ids %x and %x", public.ID.Bytes(), intended.ID.Bytes()) + } + // The client holds only the public reference, and so knows the scope token. + client := tr.parts["client"].scope() + bump := append([]byte(nil), public.ID.Bytes()...) + bump[15]++ + forged := []ontos.Atom{ontos.NewAtom(bump), ontos.NewAtom(make([]byte, 16)), text("0000000000000002")} + for _, id := range forged { + _ = client.Connect(Reference{public.Path, public.Scope, id}).Send(text("forged")) + } + // A counter-like id of the wrong length is refused before it leaves. + if err := client.Connect(Reference{public.Path, public.Scope, text("2")}).Send(text("forged")); !errors.Is(err, MalformedFrame) { + t.Fatalf("a 1-octet id: %v", err) + } + eventually(t, "forged references were not refused", func() bool { return len(tr.diagnostics()) == len(forged) }) + for _, d := range tr.diagnostics() { + if d.who != "provider" || !(errors.Is(d.err, UnknownExport) || errors.Is(d.err, MalformedFrame)) { + t.Fatalf("forged reference: %+v", d) + } + } + mu.Lock() + defer mu.Unlock() + if private != 1 { + t.Fatalf("the private endpoint received %d deliveries, want only the legitimate one", private) + } + }) +} + +type recordingSender struct { + mu sync.Mutex + frames []ontos.Value +} + +func (r *recordingSender) Send(_ wire.Path, v ontos.Value) error { + r.mu.Lock() + r.frames = append(r.frames, v) + r.mu.Unlock() + return nil +} + +// Observation 13: concurrent sends agree on one export per face, and never pass +// the export bound. +func TestObservation13ConcurrentSends(t *testing.T) { + run := func(n int, f func(i int)) { + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { defer wg.Done(); <-start; f(i) }() + } + close(start) + wg.Wait() + } + dest := Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + rec := &recordingSender{} + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, rec, DefaultLimits) + target := s.Connect(dest) + face := NewEndpoint(1) + run(32, func(int) { + if err := target.Send(tuple(t, face)); err != nil { + t.Error(err) + } + }) + if s.Live() != 1 || len(rec.frames) != 32 { + t.Fatalf("one face: %d exports, %d frames", s.Live(), len(rec.frames)) + } + registered := s.byEndpoint[face] + for _, f := range rec.frames { + leaf := f.(ontos.Tuple).At(3).(ontos.Tuple).At(1).(ontos.Tuple).At(0) // body (01, ((02, ref))) + ref, err := ReadReference(leaf.(ontos.Tuple).At(1)) + if err != nil || !ref.ID.Equal(registered) { + t.Fatal("a frame names an id that was not registered") + } + } + + bounded, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, &recordingSender{}, Limits{Exports: 4, Nodes: 64, Depth: 8, Bytes: 1 << 16}) + to := bounded.Connect(dest) + var mu sync.Mutex + refused := 0 + run(8, func(int) { + if err := to.Send(tuple(t, NewEndpoint(1))); errors.Is(err, Limit) { + mu.Lock() + refused++ + mu.Unlock() + } else if err != nil { + t.Error(err) + } + }) + if bounded.Live() != 4 || refused != 4 { + t.Fatalf("bound 4: %d live, %d refused", bounded.Live(), refused) + } +} + +// Observation 15, incoming: a frame counts whole against the byte bound, +// reference material included, and nothing is delivered. +func TestObservation15IncomingFrameBytes(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, Limits{Exports: 4, Nodes: 64, Depth: 8, Bytes: 128}) + target := NewEndpoint(4) + ref, _ := s.Expose(target) + huge := Reference{wire.Path{ontos.NewAtom(make([]byte, 4096))}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + body := ontos.NewTuple(tupleTag, ontos.NewTuple(huge.value())) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, body), nil); !errors.Is(err, Limit) { + t.Fatalf("an oversized reference: %v", err) + } + if target.pending() != 0 { + t.Fatal("an oversized frame was delivered") + } +} + +// Observation 17: the Endpoint laws hold on every path. A local send conveys only +// the sending face; a stale detach removes only its own receiver; a receiver's +// failure terminates its endpoint, which ends its export. +func TestObservation17EndpointLaws(t *testing.T) { + got := make(chan wire.HydratedValue, 2) + local := NewEndpoint(4) + if _, err := local.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }); err != nil { + t.Fatal(err) + } + private := NewEndpoint(1) + _ = local.Wire().Send(private) + _ = local.Wire().Send(tuple(t, private)) + if v := <-got; v != private.Wire() { + t.Fatalf("a bare endpoint arrived as %T", v) + } + if v := <-got; items(t, v)[0] != private.Wire() { + t.Fatal("an endpoint inside a tuple kept its receive and close authority") + } + + e := NewEndpoint(4) + stale, _ := e.Receive(func(wire.HydratedValue, wire.ReceivedContext) {}) + stale() + seen := make(chan wire.HydratedValue, 1) + if _, err := e.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { seen <- v }); err != nil { + t.Fatal(err) + } + stale() + _ = e.Send(text("after")) + select { + case <-seen: + case <-time.After(2 * time.Second): + t.Fatal("a stale detach removed the newer receiver") + } + + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, DefaultLimits) + failing := NewEndpoint(4) + if _, err := failing.Receive(func(wire.HydratedValue, wire.ReceivedContext) { panic("receiver fault") }); err != nil { + t.Fatal(err) + } + ref, _ := s.Expose(failing) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("x")), nil); err != nil { + t.Fatal(err) + } + eventually(t, "the failing receiver did not terminate its endpoint", func() bool { + return failing.Termination().Kind == "failed" && s.Live() == 0 + }) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("y")), nil); !errors.Is(err, UnknownExport) { + t.Fatalf("after termination: %v", err) + } + quiet := NewEndpoint(1) + if quiet.Termination() != (wire.Termination{}) { + t.Fatal("an open endpoint reports a termination") + } + _ = quiet.Close() + <-quiet.Closed() + if quiet.Termination().Kind != "closed" { + t.Fatalf("a closed endpoint reports %+v", quiet.Termination()) + } +} + +// The first termination is final: a receiver that fails after Close, while it +// was running, leaves the endpoint closed, and the export stays withdrawn. +// Reproduced by Fiber Composition's final review at 6817e40. +func TestTerminationIsFinalAfterClose(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, DefaultLimits) + e := NewEndpoint(1) + entered, proceed, left := make(chan struct{}), make(chan struct{}), make(chan struct{}) + if _, err := e.Receive(func(wire.HydratedValue, wire.ReceivedContext) { + close(entered) + <-proceed + defer close(left) + panic("late receiver failure") + }); err != nil { + t.Fatal(err) + } + ref, _ := s.Expose(e) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("x")), nil); err != nil { + t.Fatal(err) + } + <-entered + _ = e.Close() + <-e.Closed() + closed := e.Termination() + if closed.Kind != "closed" || s.Live() != 0 { + t.Fatalf("after Close: %+v with %d live exports", closed, s.Live()) + } + close(proceed) + <-left + for deadline := time.Now().Add(100 * time.Millisecond); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if now := e.Termination(); now != closed { + t.Fatalf("termination changed after Closed: %+v, then %+v", closed, now) + } + } + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("y")), nil); !errors.Is(err, UnknownExport) { + t.Fatalf("after termination: %v", err) + } + + // And the other order: a failure first stays the termination after Close. + failing := NewEndpoint(1) + if _, err := failing.Receive(func(wire.HydratedValue, wire.ReceivedContext) { panic("receiver fault") }); err != nil { + t.Fatal(err) + } + _ = failing.Send(text("x")) + <-failing.Closed() + _ = failing.Close() + if got := failing.Termination(); got.Kind != "failed" { + t.Fatalf("a failed endpoint reports %+v after Close", got) + } +} + +// Observation 15, symmetry: under the same bounds, the sender accepts a value +// exactly when the receiver does, at tight node, depth and byte budgets. +func TestObservation15SenderAndReceiverCountAlike(t *testing.T) { + face := NewEndpoint(1) + nested := wire.HydratedValue(text("x")) + for i := 0; i < 3; i++ { + nested = tuple(t, nested, face) + } + values := map[string]wire.HydratedValue{ + "one face three times": tuple(t, face, face, face), + "nested faces": nested, + "a long atom": ontos.NewAtom(make([]byte, 96)), + "atoms and a face": tuple(t, text("a"), text("bb"), face), + } + seen := map[bool]int{} + for nodes := 2; nodes <= 8; nodes++ { + for depth := 0; depth <= 4; depth++ { + for _, bytes := range []int{40, 64, 96, 128, 1024} { + limits := Limits{Exports: 4, Nodes: nodes, Depth: depth + 1, Bytes: bytes} + for name, v := range values { + rec := &recordingSender{} + sender, _ := NewScope(NewNamespace("x"), wire.Path{text("s")}, rec, limits) + receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) + target := NewEndpoint(4) + ref, _ := receiver.Expose(target) + sendErr := sender.Connect(ref).Send(v) + accepted := sendErr == nil + seen[accepted]++ + if !accepted && !errors.Is(sendErr, Limit) { + t.Fatalf("%s: %v", name, sendErr) + } + var recvErr error + if accepted { + recvErr = receiver.Deliver(receiver.path, rec.frames[0], nil) + } else { + // Build the frame without the sender's budget and offer it. + open, _ := NewScope(NewNamespace("x"), wire.Path{text("s")}, &recordingSender{}, Limits{Exports: 4, Nodes: 1 << 20, Depth: 1 << 10, Bytes: 1 << 20}) + frame, err := open.stage(ref, v) + if err != nil { + t.Fatal(err) + } + recvErr = receiver.Deliver(receiver.path, frame, nil) + } + if accepted != (recvErr == nil) { + t.Fatalf("%s at nodes %d depth %d bytes %d: sender %v, receiver %v", name, nodes, depth+1, bytes, sendErr, recvErr) + } + } + } + } + } + if seen[true] == 0 || seen[false] == 0 { + t.Fatalf("the budgets never separated outcomes: %v", seen) + } + t.Logf("accepted %d, refused %d", seen[true], seen[false]) +} + +// Observation 15, paths: a reference path longer than the depth bound is +// reference material, counted in bytes, not refused as depth (D8). +func TestObservation15LongReferencePath(t *testing.T) { + limits := Limits{Exports: 4, Nodes: 8, Depth: 2, Bytes: 4096} + receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) + target := NewEndpoint(4) + got := make(chan wire.HydratedValue, 1) + _, _ = target.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + ref, _ := receiver.Expose(target) + long := wire.Path{text("a"), text("b"), text("c"), text("d"), text("e")} + far := Reference{long, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + body := ontos.NewTuple(tupleTag, ontos.NewTuple(far.value())) + if err := receiver.Deliver(receiver.path, ontos.NewTuple(header, ref.Scope, ref.ID, body), nil); err != nil { + t.Fatalf("a five-segment reference under depth 2: %v", err) + } + if v := <-got; !wire.PathEqual(items(t, v)[0].(*proxy).ref.Path, long) { + t.Fatal("the long path changed") + } +} + +// Observation 17, construction: the zero Tuple, the only one NewTuple does not +// make, arrives as the ground empty tuple. +func TestObservation17ZeroTupleIsGround(t *testing.T) { + got := make(chan wire.HydratedValue, 1) + local := NewEndpoint(1) + _, _ = local.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + _ = local.Wire().Send(Tuple{}) + if v, ok := (<-got).(ontos.Tuple); !ok || v.Len() != 0 { + t.Fatal("the zero hydrated tuple was not delivered as the ground empty tuple") + } +} + +// Observation 17, local edges: a bound below one is refused, and a local send +// recognizes leaves as dehydration would. +func TestObservation17LocalEdges(t *testing.T) { + func() { + defer func() { + if recover() == nil { + t.Fatal("an endpoint bound of zero was accepted") + } + }() + NewEndpoint(0) + }() + got := make(chan wire.HydratedValue, 4) + local := NewEndpoint(8) + _, _ = local.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + foreign := funcWire(func(wire.HydratedValue) error { return nil }) + if err := local.Wire().Send(foreign); !errors.Is(err, Unexportable) { + t.Fatalf("a bare foreign Wire: %v", err) + } + if _, err := NewTuple(text("a"), foreign); !errors.Is(err, Unexportable) { + t.Fatalf("a tuple built with a foreign Wire: %v", err) + } + if err := local.Wire().Send(tuple(t, text("ok"), NewEndpoint(1))); err != nil { + t.Fatal(err) + } + <-got + select { + case v := <-got: + t.Fatalf("a refused local value was delivered: %v", v) + case <-time.After(50 * time.Millisecond): + } +} + +// Observation 15, shared trees: a compact value whose subtrees are shared counts +// every occurrence, and is refused before it can expand: no export is +// committed and nothing is sent. +func TestObservation15SharedTreeRefusedBeforeExpansion(t *testing.T) { + rec := &recordingSender{} + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, rec, DefaultLimits) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + v := tuple(t, NewEndpoint(1)) + for i := 0; i < 40; i++ { // 2^40 occurrences from 41 distinct tuples + v = tuple(t, v, v) + } + start := time.Now() + if err := target.Send(v); !errors.Is(err, Limit) { + t.Fatalf("a shared tree: %v", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("refusal took %v; the tree was expanded", elapsed) + } + if s.Live() != 0 || len(rec.frames) != 0 { + t.Fatalf("a refused send left %d exports and %d frames", s.Live(), len(rec.frames)) + } + // Locally it is delivered as built, without a traversal. + local := NewEndpoint(1) + got := make(chan wire.HydratedValue, 1) + _, _ = local.Receive(func(x wire.HydratedValue, _ wire.ReceivedContext) { got <- x }) + if err := local.Wire().Send(v); err != nil { + t.Fatal(err) + } + <-got +} + +// Observation 15, depth: a deep value within the configured bounds is sent and +// received whole. +func TestObservation15DeepPermittedValue(t *testing.T) { + limits := Limits{Exports: 4, Nodes: 50000, Depth: 20000, Bytes: 1 << 22} + rec := &recordingSender{} + sender, _ := NewScope(NewNamespace("x"), wire.Path{text("s")}, rec, limits) + receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) + target := NewEndpoint(1) + got := make(chan wire.HydratedValue, 1) + _, _ = target.Receive(func(x wire.HydratedValue, _ wire.ReceivedContext) { got <- x }) + ref, _ := receiver.Expose(target) + v := wire.HydratedValue(text("leaf")) + for i := 0; i < 15000; i++ { + v = tuple(t, v) + } + if err := sender.Connect(ref).Send(v); err != nil { + t.Fatal(err) + } + if err := receiver.Deliver(receiver.path, rec.frames[0], nil); err != nil { + t.Fatal(err) + } + if !(<-got).(ontos.Tuple).Equal(v.(ontos.Value)) { + t.Fatal("the deep value changed") + } +} + +// allocated reports the bytes allocated while f runs. +func allocated(f func()) uint64 { + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + f() + runtime.ReadMemStats(&after) + return after.TotalAlloc - before.TotalAlloc +} + +// Observation 15, allocation: a refused value costs memory bounded by the +// limits, not by the value. A wide tuple is neither copied nor reserved at its +// size, and a repeated reference copies its path once. +func TestObservation15RefusalAllocationIsBounded(t *testing.T) { + dest := Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + tight, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, &recordingSender{}, Limits{Exports: 4, Nodes: 4, Depth: 8, Bytes: 4096}) + wide := make([]ontos.Value, 200000) + for i := range wide { + wide[i] = text("w") + } + wideTuple := ontos.NewTuple(wide...) + to := tight.Connect(dest) + var err error + if n := allocated(func() { err = to.Send(wideTuple) }); !errors.Is(err, Limit) || n > 64<<10 { + t.Fatalf("a wide tuple under nodes 4: %v after %d bytes", err, n) + } + + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, &recordingSender{}, DefaultLimits) + empty := make(wire.Path, 20000) + for i := range empty { + empty[i] = ontos.NewAtom(nil) + } + far := s.Connect(Reference{empty, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + occurrences := make([]wire.HydratedValue, 300) + for i := range occurrences { + occurrences[i] = far + } + v := tuple(t, occurrences...) + if n := allocated(func() { err = s.Connect(dest).Send(v) }); !errors.Is(err, Limit) || n > 32<<20 { + t.Fatalf("a repeated long reference path: %v after %d bytes", err, n) + } +} diff --git a/hydrated/go/testdata/hydrated-vectors.json b/hydrated/go/testdata/hydrated-vectors.json new file mode 100644 index 0000000..953df7d --- /dev/null +++ b/hydrated/go/testdata/hydrated-vectors.json @@ -0,0 +1,57 @@ +{ + "basis": "bitwire decision 0019, hydrated frame edition bitwire/hydrated/1. Encodings are hand-derived from ontos-codec-v1 (Atom = 00 uvarint(length) bytes; Tuple = 01 uvarint(arity) items), then checked against the released bitwire 0.5.0 encoder. Atoms are hex. Tags: 01 tuple, 02 wire; 00 and every other value are reserved. A reference is (02, ((path segments...), scope, id)) with a 16-octet scope and a 16-octet id. \"live\" describes the hydrated value with wire{path, scope, id} for a wire leaf; S and S2 are the two fixed scopes and I1, I2, I7, I8 fixed ids below.", + "scopes": {"S":"000102030405060708090a0b0c0d0e0f","S2":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"}, + "ids": {"I1":"101112131415161718191a1b1c1d1e1f","I2":"202122232425262728292a2b2c2d2e2f","I7":"707172737475767778797a7b7c7d7e7f","I8":"808182838485868788898a8b8c8d8e8f"}, + "encode": [ + {"name":"bare atom","live":"hello","body":{"atom":"68656c6c6f"},"hex":"000568656c6c6f"}, + {"name":"empty atom","live":"\"\"","body":{"atom":""},"hex":"0000"}, + {"name":"an atom equal to the tuple tag stays an atom","live":"0x01","body":{"atom":"01"},"hex":"000101"}, + {"name":"an atom equal to the wire tag stays an atom","live":"0x02","body":{"atom":"02"},"hex":"000102"}, + {"name":"empty tuple","live":"()","body":{"tuple":[{"atom":"01"},{"tuple":[]}]},"hex":"01020001010100"}, + {"name":"tuple of two atoms","live":"(a, b)","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"61"},{"atom":"62"}]}]},"hex":"01020001010102000161000162"}, + {"name":"wire leaf","live":"wire{[client42], S, I1}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]},"hex":"0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"wire at the root path","live":"wire{[], S, I1}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]},"hex":"0102000102010301000010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"wire with exact binary path segments","live":"wire{[\"\", ff2f], S2, I7}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":""},{"atom":"ff2f"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"707172737475767778797a7b7c7d7e7f"}]}]},"hex":"01020001020103010200000002ff2f0010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f"}, + {"name":"echo request with a reply wire","live":"(echo, hello, wire{[client42], S, I1})","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"0102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"nested wires in a nested tuple","live":"(hello, (wire{[a,b,service2], S2, I7}, wire{[client42], S, I2}))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"707172737475767778797a7b7c7d7e7f"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"202122232425262728292a2b2c2d2e2f"}]}]}]}]}]}]},"hex":"01020001010102000568656c6c6f01020001010102010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010202122232425262728292a2b2c2d2e2f"}, + {"name":"one wire twice encodes identical references","live":"(wire{[client42], S, I1}, wire{[client42], S, I1})","body":{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"010200010101020102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"data shaped like an encoded wire stays data","live":"(0x02, ((client42), S, \"1\"))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"02"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"},{"tuple":[{"atom":"636c69656e743432"}]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"0102000101010200010201020001010103010200010101010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"data shaped like an encoded tuple stays data","live":"(0x01, ())","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"01"},{"tuple":[{"atom":"01"},{"tuple":[]}]}]}]},"hex":"0102000101010200010101020001010100"} + ], + "frames": [ + {"name":"echo request to the provider","path":["61","62","7365727669636532"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"707172737475767778797a7b7c7d7e7f","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101030001610001620008736572766963653201040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f0102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"reply to the caller carrying a further wire","path":["636c69656e743432"],"scope":"000102030405060708090a0b0c0d0e0f","id":"101112131415161718191a1b1c1d1e1f","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"808182838485868788898a8b8c8d8e8f"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101010008636c69656e74343201040012626974776972652f68796472617465642f310010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f01020001010102000568656c6c6f010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010808182838485868788898a8b8c8d8e8f"}, + {"name":"ground data only","path":["","ff2f"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"707172737475767778797a7b7c7d7e7f","body":{"atom":"70696e67"},"hex":"01030013626974776972652f6164647265737365642f31010200000002ff2f01040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f000470696e67"} + ], + "rejectBody": [ + {"name":"a tag with no payload","value":{"tuple":[{"atom":"01"}]}}, + {"name":"a tag with two payloads","value":{"tuple":[{"atom":"01"},{"tuple":[]},{"tuple":[]}]}}, + {"name":"a tag that is not an atom","value":{"tuple":[{"tuple":[]},{"tuple":[]}]}}, + {"name":"tag 0x00 is reserved","value":{"tuple":[{"atom":"00"},{"tuple":[]}]}}, + {"name":"unknown tag 0x03","value":{"tuple":[{"atom":"03"},{"tuple":[]}]}}, + {"name":"a two-octet tag","value":{"tuple":[{"atom":"0101"},{"tuple":[]}]}}, + {"name":"tuple payload that is an atom","value":{"tuple":[{"atom":"01"},{"atom":"78"}]}}, + {"name":"wire payload that is an atom","value":{"tuple":[{"atom":"02"},{"atom":"78"}]}}, + {"name":"reference without an id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"70"}]},{"atom":"000102030405060708090a0b0c0d0e0f"}]}]}}, + {"name":"reference path that is an atom","value":{"tuple":[{"atom":"02"},{"tuple":[{"atom":"70"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"reference path segment that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"tuple":[]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"15-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"17-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f10"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"scope that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"tuple":[]},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"empty id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":""}]}]}}, + {"name":"1-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}}, + {"name":"15-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e"}]}]}}, + {"name":"17-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f20"}]}]}}, + {"name":"a malformed node deep inside a valid tuple","value":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6f6b"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"}]}]}]}]}]}} + ], + "rejectFrame": [ + {"name":"another version","value":{"tuple":[{"atom":"626974776972652f68796472617465642f32"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"atom":"78"}]}}, + {"name":"three items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}}, + {"name":"five items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"atom":"78"},{"atom":"79"}]}}, + {"name":"15-octet target scope","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"atom":"78"}]}}, + {"name":"empty target id","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":""},{"atom":"78"}]}}, + {"name":"1-octet target id","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"atom":"78"}]}}, + {"name":"a bare atom","value":{"atom":"626974776972652f68796472617465642f31"}}, + {"name":"a malformed body","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"tuple":[{"atom":"01"}]}]}} + ] +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts new file mode 100644 index 0000000..63f7dd6 --- /dev/null +++ b/hydrated/ts/src/index.ts @@ -0,0 +1,471 @@ +// The realization of bitwire decision 0019, the hydrated wire protocol's first +// edition, over bitwire's public hydrated declarations and its shared pure codec. +// The codec owns the grammar, validation and bounds; this module owns export ids +// and staging, reference recognition, liveness and endpoints. +import { + Atom, Tuple, atom, tuple, pathEqual, + HydratedCodecError, HydratedDataAtom, HydratedDataTuple, HydratedReference, + packHydratedBody, unpackHydratedBody, packHydratedFrame, unpackHydratedFrame, + packHydratedReference, unpackHydratedReference, +} from '@bitspark/bitwire'; +import type { + Path, AddressedWire, Termination, HydratedValue, HydratedWire, HydratedEndpoint, ReceivedContext, + HydratedData, HydratedCodecLimits, HydratedTuple as HydratedTupleView, +} from '@bitspark/bitwire'; + +/** The context of a send that never left the process. */ +export const LOCAL: unique symbol = Symbol('local'); + +export const MALFORMED_FRAME = 'malformed-frame'; +export const STALE_SCOPE = 'stale-scope'; +export const UNKNOWN_EXPORT = 'unknown-export'; +export const LIMIT = 'limit'; +export const TARGET_REFUSED = 'target-refused'; +export const UNEXPORTABLE = 'unexportable'; +export const FOREIGN_NAMESPACE = 'foreign-namespace'; +export const SCOPE_ENDED = 'scope-ended'; + +/** A refusal: a host diagnostic at the owner, or a refusal before admission at the sender (D8). */ +export class Refusal extends Error { + readonly reason: string; + constructor(reason: string) { + super(reason); + this.reason = reason; + } +} + +/** A tuple with at least one HydratedWire beneath it; see hydratedTuple (D1). */ +/** Only this module constructs hydrated tuples, so every one is canonical (D1, D7). */ +const CONSTRUCT = Symbol('hydrated tuple'); + +/** + * A tuple with at least one HydratedWire beneath it, shaped like bitwire's HydratedTuple + * (a ground Tuple has the same shape). Construct it with hydratedTuple(). + */ +export class HydratedTuple implements HydratedTupleView { + readonly kind = 'tuple' as const; + readonly #items: readonly HydratedValue[]; + constructor(items: readonly HydratedValue[], token?: symbol) { + if (token !== CONSTRUCT) throw new TypeError('hydrated: construct tuples with hydratedTuple()'); + this.#items = Object.freeze([...items]); + Object.freeze(this); + } + get length(): number { return this.#items.length; } + items(): readonly HydratedValue[] { return this.#items; } + at(index: number): HydratedValue | undefined { return this.#items.at(index); } +} + +// Any HydratedWire may sit in a tuple; encoding refuses one the runtime does not own (D7). +const isWire = (v: unknown): v is HydratedWire => typeof (v as HydratedWire | undefined)?.send === 'function'; + +/** An Endpoint travels only as its sending face, on every path (D7). */ +const sendOnly = (v: HydratedValue): HydratedValue => (v instanceof Endpoint ? v.wire : v); + +/** + * Validates a value for local delivery as dehydration would for a remote one (D1, + * D7): only runtime-recognized leaves arrive, and an endpoint arrives as its face. + */ +function local(v: HydratedValue): HydratedValue { + // A tuple recognized its leaves when it was built and is immutable, so only + // the top level needs checking; no traversal is needed. + const projected = sendOnly(v); + if (projected instanceof Atom || projected instanceof Tuple || projected instanceof HydratedTuple) return projected; + if (projected instanceof Face || projected instanceof Proxy) return projected; + if (projected && typeof (projected as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); + throw new TypeError('hydrated: not a value'); +} + +/** Captures items; returns the ground tuple when no HydratedWire is beneath them. */ +export function hydratedTuple(items: readonly HydratedValue[]): HydratedValue { + const captured = items.map(sendOnly); + let live = false; + for (const item of captured) { + if (item instanceof HydratedTuple || item instanceof Face || item instanceof Proxy) live = true; + else if (isWire(item)) throw new Refusal(UNEXPORTABLE); // only the runtime's own Wires are leaves (D1) + else if (!(item instanceof Atom) && !(item instanceof Tuple)) throw new TypeError('hydrated: not a value'); + } + return live ? new HydratedTuple(captured, CONSTRUCT) : tuple(captured as (Atom | Tuple)[]); +} + +/** Any tuple's items, ground or hydrated. */ +export function items(v: HydratedValue): readonly HydratedValue[] | undefined { + if (v instanceof HydratedTuple) return v.items(); + if (v instanceof Tuple) return v.items(); + return undefined; +} + +/** One set of participants addressed by absolute paths (D6). */ +export class Namespace { + readonly name: string; + constructor(name: string) { this.name = name; } +} + +/** The ground form of a live HydratedWire (D2, D3). */ +export interface Reference { readonly path: Path; readonly scope: Atom; readonly id: Atom } + +const hex = (a: Atom) => Array.from(a.bytes(), (b) => b.toString(16).padStart(2, '0')).join(''); +const refKey = (r: Reference) => `${r.path.map(hex).join('/')}|${r.path.length}|${hex(r.scope)}|${hex(r.id)}`; +/** A codec failure, named as the protocol's refusal (D8). */ +function refusal(error: unknown): never { + if (error instanceof HydratedCodecError) throw new Refusal(error.kind === 'limit' ? LIMIT : MALFORMED_FRAME); + throw error; +} +const codec = (f: () => T): T => { try { return f(); } catch (error) { return refusal(error); } }; +const codecLimits = (l: Limits): HydratedCodecLimits => ({ nodes: l.nodes, depth: l.depth, bytes: l.bytes }); +/** A Wire leaf's value bytes under D8: its path bytes and 32. */ +const refBytes = (path: Path): number => path.reduce((n, a) => n + a.length, 32); +/** + * A lower bound on a path's encoded size (a tag and a length octet per segment, + * plus its bytes): a frame holding the path is at least this large. + */ +const leastPathBytes = (path: Path): number => path.reduce((n, a) => n + 2 + a.length, 0); + +/** + * A local HydratedEndpoint: one receiver, ordered dispatch never inline with the + * admitting send, a bounded queue and an owner lifetime. Its face is what a value + * carries; closing it ends its export in every scope (D7). + */ +export class Endpoint implements HydratedEndpoint { + readonly kind = 'wire' as const; + readonly wire: HydratedWire; + /** Resolves with how the endpoint ended, as bitwire's Endpoint contract does. */ + readonly closed: Promise; + #queue: { value: HydratedValue; context: ReceivedContext }[] = []; + #limit: number; + #handler: ((value: HydratedValue, context: ReceivedContext) => void) | undefined; + #generation = 0; + #failed = false; + #failure: unknown; + #isClosed = false; + #draining = false; + #resolveClosed!: (termination: Termination) => void; + #termination: Termination | undefined; + readonly #exports = new Map(); + + constructor(limit = 1) { + if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError('hydrated: an endpoint bound must be a positive integer'); + this.#limit = limit; + this.wire = new Face(this); + this.closed = new Promise((resolve) => { this.#resolveClosed = resolve; }); + } + + send(value: HydratedValue): Promise { return this.wire.send(value); } + + /** Attaches the one receiver; its detach removes only that receiver. */ + receive(handler: (value: HydratedValue, context: ReceivedContext) => void): () => void { + if (this.#handler) throw new Error('hydrated: receiver already attached'); + const generation = ++this.#generation; + this.#handler = handler; + this.#drain(); + return () => { if (this.#generation === generation) this.#handler = undefined; }; + } + + /** How the endpoint ended: failed with its receiver's failure, or closed; undefined while open. */ + get termination(): Termination | undefined { return this.#termination; } + + async close(): Promise { + if (this.#isClosed) return; + this.#isClosed = true; + this.#queue = []; + const exports = [...this.#exports]; + this.#exports.clear(); + for (const [scope, id] of exports) scope._withdraw(this, id); + this.#termination = !this.#failed + ? Object.freeze({ kind: 'closed' }) + : Object.freeze({ kind: 'failed', message: String((this.#failure as Error)?.message ?? this.#failure) }); + this.#resolveClosed(this.#termination); + } + + get isClosed(): boolean { return this.#isClosed; } + + /** @internal */ + _admit(value: HydratedValue, context: ReceivedContext): void { + if (this.#isClosed || this.#queue.length >= this.#limit) throw new Refusal(TARGET_REFUSED); + this.#queue.push({ value, context }); + this.#drain(); + } + + /** @internal */ + _register(scope: Scope, id: Atom): boolean { + if (this.#isClosed) return false; + this.#exports.set(scope, id); + return true; + } + + /** @internal */ + _unregister(scope: Scope): void { this.#exports.delete(scope); } + + #drain(): void { + if (this.#draining) return; + this.#draining = true; + setImmediate(() => { + this.#draining = false; + while (!this.#isClosed && this.#handler && this.#queue.length) { + const d = this.#queue.shift()!; + try { + this.#handler(d.value, d.context); + } catch (error) { + // A receiver failure terminates the endpoint, which ends its export. + this.#failed = true; + this.#failure = error; + void this.close(); + } + } + }); + } +} + +/** Each face's owner, private to this module: a face reveals no endpoint. */ +const owners = new WeakMap(); + +/** An Endpoint's sending face. It grants sending only. */ +class Face implements HydratedWire { + readonly kind = 'wire' as const; + constructor(endpoint: Endpoint) { owners.set(this, endpoint); Object.freeze(this); } + async send(value: HydratedValue): Promise { owners.get(this)!._admit(local(value), LOCAL); } +} + +/** + * An imported HydratedWire. It carries its reference and namespace, so any participant + * in the namespace forwards it unchanged (D6). A dead reason marks this + * participant's own stale or withdrawn reference (D7, returning home). + */ +class Proxy implements HydratedWire { + readonly kind = 'wire' as const; + readonly ref: Reference; + readonly ns: Namespace; + readonly #via: Scope; + readonly #dead: string | undefined; + constructor(ref: Reference, ns: Namespace, via: Scope, dead?: string) { + this.ref = ref; this.ns = ns; this.#via = via; this.#dead = dead; + Object.freeze(this); + } + async send(value: HydratedValue): Promise { + if (this.#dead) throw new Refusal(this.#dead); + return this.#via._send(this.ref, value); + } +} + +/** Bounds of a scope (D8). */ +export interface Limits { exports: number; nodes: number; depth: number; bytes: number } +export const DEFAULT_LIMITS: Limits = Object.freeze({ exports: 64, nodes: 4096, depth: 64, bytes: 1 << 20 }); + +/** One participant's hydration state for one incarnation (Terms). */ +export class Scope { + readonly path: Path; + readonly token: Atom; + readonly ns: Namespace; + readonly #sender: AddressedWire; + readonly #limits: Limits; + readonly #byId = new Map(); + readonly #byEndpoint = new Map(); + #closed = false; + + constructor(ns: Namespace, path: Path, sender: AddressedWire, limits: Limits = DEFAULT_LIMITS) { + for (const n of Object.values(limits)) if (!Number.isSafeInteger(n) || n < 1) throw new RangeError('hydrated: limits must be positive'); + this.ns = ns; + this.path = Object.freeze([...path]); + this.#sender = sender; + this.#limits = { ...limits }; + this.token = atom(crypto.getRandomValues(new Uint8Array(16))); + } + + get live(): number { return this.#byEndpoint.size; } + + /** Composition bootstrap: export an endpoint's face. Domain adapters never call it. */ + expose(endpoint: Endpoint): Reference { + const staged = new Map(); + const id = this.#exportId(endpoint, staged); + this.#commit(staged); + return { path: this.path, scope: this.token, id }; + } + + /** Composition bootstrap: a proxy for a reference. */ + connect(ref: Reference): HydratedWire { return this.#import(ref, new Map()); } + + /** Ends the scope: its exports end and its references go stale. Closes no endpoint. */ + close(): void { + this.#closed = true; + for (const endpoint of this.#byEndpoint.keys()) endpoint._unregister(this); + this.#byEndpoint.clear(); + this.#byId.clear(); + } + + /** @internal */ + _withdraw(endpoint: Endpoint, id: Atom): void { + if (this.#byId.get(hex(id)) === endpoint) { + this.#byId.delete(hex(id)); + this.#byEndpoint.delete(endpoint); + } + } + + #exportId(endpoint: Endpoint, staged: Map): Atom { + if (endpoint.isClosed) throw new Refusal(UNEXPORTABLE); + if (this.#closed) throw new Refusal(SCOPE_ENDED); + const known = staged.get(endpoint) ?? this.#byEndpoint.get(endpoint); + if (known) return known; + if (this.#byEndpoint.size + staged.size >= this.#limits.exports) throw new Refusal(LIMIT); + // 16 octets from a secure source, redrawn on collision: no reference reveals another's (D3). + for (;;) { + const id = atom(crypto.getRandomValues(new Uint8Array(16))); + if (!this.#byId.has(hex(id)) && ![...staged.values()].some((other) => other.equals(id))) { + staged.set(endpoint, id); + return id; + } + } + } + + #commit(staged: Map): void { + if (this.#closed) throw new Refusal(SCOPE_ENDED); + for (const [endpoint, id] of staged) { + if (this.#byEndpoint.has(endpoint)) continue; + if (endpoint._register(this, id)) { + this.#byEndpoint.set(endpoint, id); + this.#byId.set(hex(id), endpoint); + } + } + } + + /** Stages the value and registers its new exports in one step, then admits one frame (D5). */ + /** @internal */ + async _send(dest: Reference, value: HydratedValue): Promise { + if (this.#closed) throw new Refusal(SCOPE_ENDED); + const staged = new Map(); + const body = this.#toData(value, staged); + const frame = codec(() => packHydratedFrame({ scope: dest.scope, id: dest.id, body }, codecLimits(this.#limits))); + this.#commit(staged); + await this.#sender.send(dest.path, frame); + } + + /** + * Maps a live value onto the codec's structural data, staging an export for each + * new face. The codec checks every bound (D8); the depth guard only keeps a + * pathological value from exhausting the stack first. + */ + #toData(root: HydratedValue, staged: Map): HydratedData { + // Count each occurrence as D8 does and refuse before allocating, so a compact + // value with shared subtrees cannot expand past the bounds; iterate, so a deep + // permitted value cannot exhaust the stack. The codec stays authoritative. + const limits = this.#limits; + let nodes = 0, bytes = 0, paths = 0; + // One descriptor per distinct face or proxy, shared by its occurrences; its + // path is copied once and counted against the byte bound before copying. + const refs = new Map(); + const reference = (w: HydratedWire, path: Path, scope: Atom, id: () => Atom): HydratedReference => { + const known = refs.get(w); + if (known) return known; + paths += leastPathBytes(path); + if (paths > limits.bytes) throw new Refusal(LIMIT); + const r = codec(() => new HydratedReference(path, scope, id())); + refs.set(w, r); + return r; + }; + const spend = (depth: number, n: number) => { + nodes++; bytes += n; + if (nodes > limits.nodes || depth > limits.depth || bytes > limits.bytes) throw new Refusal(LIMIT); + }; + // Tuples are indexed, never copied; outputs grow as children are counted. + const frames: { tuple: Tuple | HydratedTuple; depth: number; out: HydratedData[] }[] = []; + const visit = (value: HydratedValue, depth: number): HydratedData | undefined => { + const v = value instanceof Endpoint ? value.wire : value; + if (v instanceof Atom) { spend(depth, v.length); return new HydratedDataAtom(v); } + if (v instanceof Tuple || v instanceof HydratedTuple) { + spend(depth, 0); + frames.push({ tuple: v, depth, out: [] }); + return undefined; + } + if (v instanceof Face) { + spend(depth, refBytes(this.path)); + return reference(v, this.path, this.token, () => this.#exportId(owners.get(v)!, staged)); + } + if (v instanceof Proxy) { + if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); + spend(depth, refBytes(v.ref.path)); + return reference(v, v.ref.path, v.ref.scope, () => v.ref.id); + } + if (v && typeof (v as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); + throw new TypeError('hydrated: not a value'); + }; + const first = visit(root, 0); + if (first) return first; + for (;;) { + const top = frames[frames.length - 1]!; + if (top.out.length < top.tuple.length) { + const child = visit(top.tuple.at(top.out.length)!, top.depth + 1); + if (child) top.out.push(child); + continue; + } + frames.pop(); + const done = codec(() => new HydratedDataTuple(top.out)); + if (!frames.length) return done; + frames[frames.length - 1]!.out.push(done); + } + } + + /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). Iterative. */ + #fromData(root: HydratedData, interned: Map): HydratedValue { + const leaf = (d: HydratedDataAtom | HydratedReference): HydratedValue => + d instanceof HydratedDataAtom ? d.value : this.#import({ path: d.path, scope: d.scope, id: d.id }, interned); + if (!(root instanceof HydratedDataTuple)) return leaf(root); + const frames: { items: readonly HydratedData[]; out: HydratedValue[] }[] = [{ items: root.items(), out: [] }]; + for (;;) { + const top = frames[frames.length - 1]!; + if (top.out.length < top.items.length) { + const child = top.items[top.out.length]!; + if (child instanceof HydratedDataTuple) frames.push({ items: child.items(), out: [] }); + else top.out.push(leaf(child)); + continue; + } + frames.pop(); + const done = hydratedTuple(top.out); + if (!frames.length) return done; + frames[frames.length - 1]!.out.push(done); + } + } + + /** @internal The body halves of a frame. */ + _encodeBody(v: HydratedValue, staged: Map): Atom | Tuple { + const data = this.#toData(v, staged); + return codec(() => packHydratedBody(data, codecLimits(this.#limits))) as Atom | Tuple; + } + + /** @internal */ + _decodeBody(body: Atom | Tuple, interned: Map): HydratedValue { + return this.#fromData(codec(() => unpackHydratedBody(body, codecLimits(this.#limits))), interned); + } + + /** Decides one value addressed to this participant's own path (D4); throws a Refusal as a host diagnostic. */ + deliver(path: Path, message: Atom | Tuple, context: ReceivedContext): void { + if (!pathEqual(path, this.path)) throw new Refusal(MALFORMED_FRAME); + if (this.#closed) throw new Refusal(SCOPE_ENDED); + const frame = codec(() => unpackHydratedFrame(message, codecLimits(this.#limits))); + if (!frame.scope.equals(this.token)) throw new Refusal(STALE_SCOPE); + const target = this.#byId.get(hex(frame.id)); + if (!target) throw new Refusal(UNKNOWN_EXPORT); + target._admit(this.#fromData(frame.body, new Map()), context); + } + + /** A reference home in the current scope returns the original face; stale or withdrawn, a HydratedWire that refuses (D7). */ + #import(ref: Reference, interned: Map): HydratedWire { + if (pathEqual(ref.path, this.path)) { + if (!ref.scope.equals(this.token)) return new Proxy(ref, this.ns, this, STALE_SCOPE); + const endpoint = this.#byId.get(hex(ref.id)); + return endpoint ? endpoint.wire : new Proxy(ref, this.ns, this, UNKNOWN_EXPORT); + } + const k = refKey(ref); + let w = interned.get(k); + if (!w) { w = new Proxy(ref, this.ns, this); interned.set(k, w); } + return w; + } +} + +/** Reads a reference payload ((path...), scope, id). */ +export function readReference(v: Atom | Tuple): Reference { + const r = codec(() => unpackHydratedReference(v, codecLimits(DEFAULT_LIMITS))); + return Object.freeze({ path: Object.freeze([...r.path]), scope: r.scope, id: r.id }); +} + +/** The reference payload of a reference, for composition bootstrap over ground data. */ +export function referenceValue(ref: Reference): Atom | Tuple { + return codec(() => packHydratedReference(new HydratedReference(ref.path, ref.scope, ref.id), codecLimits(DEFAULT_LIMITS))) as Atom | Tuple; +} diff --git a/hydrated/ts/test/COMPOSITION.md b/hydrated/ts/test/COMPOSITION.md new file mode 100644 index 0000000..0dd7213 --- /dev/null +++ b/hydrated/ts/test/COMPOSITION.md @@ -0,0 +1,71 @@ +# Consumer adapter composition observations + +**Candidate acceptance, 7 October 2026.** This package tests the composition +proposal in bitwire PR85 against the proposed decision 0019 implementation. +The starting runtime is `3adb89cae4daef71501b2b2ee88228b39bb42235`; the proposed +contract is bitwire PR83 at `20a6b6b6c0b26fe16c8e57a4f88241207119880c`. +Neither being testable nor passing these observations adopts that contract. + +## Boundary and expected observations + +The fixtures define two independent domain APIs: Text with a string-producing +operation, and Counter with stateful numeric operations and a declared failure. +One Cell-of-Wire convention supplies get/set. Its bidirectional lifting composes +either inner adapter, then composes itself for Cell>. Test direct and +adapted executions from corresponding initial state using expected results and +provider traces; construction must invoke no provider operation. + +Run each transport observation using the existing opaque-middle harness over +both local pairs and real loopback WebSockets. Domain fixtures import only the +hydrated endpoint/value API and ground atoms, never Scope, references or routes. + +1. Reuse the same outer Cell adapter for Text and Counter. Reads observe values + replaced after construction, and writes carry caller-provided services to the + provider and back. Preserve results, effects, aliases and invocation counts. +2. Compose Cell> without an extra protocol. Read and replace both the + inner service and inner cell, including capabilities sent in either direction. +3. Compare identity lifting, successive coherent bidirectional conversions and + their composition. Compare provider failures and writes, not just read values. +4. Malformed domain requests with a valid reply capability produce a domain + protocol error and invoke no provider. Declared Counter failures remain + distinguishable from admission refusal and unknown outcomes after loss. +5. Over 200 complete Cell reads and Text calls, close each reply endpoint + and keep live export counts bounded by the open service endpoints. Closing an + adapter owner ends its exports, invalidates service use, settles pending local + calls and does not close the carrier or borrowed endpoints. +6. Lose the caller's attachment after a provider operation starts. The caller + settles with outcome unknown; do not infer rollback or cancel provider work. + +## Domain lifetime and failure convention + +An explicit adapter owner captures the service lifetime. Materialization caches +one owned endpoint per domain object per adapter in that lifetime; proxies map +back to their original sending wire. This is local adapter ownership, with no +transport reference IDs or registry management. A production domain can choose +different end events, but must still name them. Shared runtime close performs +export withdrawal. Tests must not raise the runtime's default limits. + +The test-only request convention carries an operation, argument tuple and owned +reply face. Each invocation closes its reply endpoint on every completion path. +An explicit owner-close signal and a finite fixture deadline settle incomplete +local calls with outcome unknown. They do not cancel remote operations. A valid +reply conveys success, a declared domain failure, or a domain-protocol error. +These conventions belong to these fixtures, not the generic protocol stack. + +## Scope and evidence + +Only new files in `hydrated/ts/test` belong to this package. The shared harness, +runtime source, Go realization, wire format and existing observation suite stay +with their current owners. A failing runtime law is reported as a counterexample +to the owning contract/implementation, not hidden by fixture fallback behavior. + +This is bounded evidence for these APIs, schedules and carriers. It does not +establish a law for every generic type, arbitrary partitions, distributed +collection, cross-namespace gateways or cross-language domain protocols. The +earlier hydration spike's call-64 retention counterexample (bitruntime `4ead83f`, +since removed) remains historical evidence. The owning production change still requires its full Go/TypeScript, +package and interoperability gates and independent review. + +Run `npm ci --ignore-scripts`, `npm run check`, and `npm test` at the repository +root. Record exact reviewed revisions and CI results in the pull request; this +document does not assert that an untested later revision has passed. diff --git a/hydrated/ts/test/composition.test.mjs b/hydrated/ts/test/composition.test.mjs new file mode 100644 index 0000000..09a4405 --- /dev/null +++ b/hydrated/ts/test/composition.test.mjs @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Endpoint, UNKNOWN_EXPORT, hydratedTuple } from '../../../dist/hydrated/ts/src/index.js'; +import { eventually, tree } from './harness.mjs'; +import { + AdapterOwner, DomainError, OutcomeUnknown, ProtocolError, invoke, text, tupleItems, +} from './fixtures/domain.mjs'; +import { textAdapter } from './fixtures/text.mjs'; +import { counterAdapter } from './fixtures/counter.mjs'; +import { adaptCell, cellAdapter, wireCellAdapter } from './fixtures/cell.mjs'; + +async function setup(t, carrier) { + const network = await tree(t, carrier, 'client', 'provider'); + const client = new AdapterOwner(), provider = new AdapterOwner(); + for (const [name, owner] of [['client', client], ['provider', provider]]) { + const part = network.parts[name]; + // Attachment ownership is explicit test composition, outside the domain adapters. + void part.ep.closed.then(() => owner.close()); + t.after(async () => { await owner.close(); part.scope.close(); }); + } + return { network, client, provider }; +} + +function remoteView(setup, source, clientAdapter, providerAdapter) { + const local = providerAdapter.toWire(source); + const endpoint = setup.provider.ownedEndpoint(local); + assert.ok(endpoint, 'test bootstrap needs the locally owned root endpoint'); + const reference = setup.network.parts.provider.scope.expose(endpoint); + const remote = setup.network.parts.client.scope.connect(reference); + return { view: clientAdapter.fromWire(remote), wire: remote }; +} + +function textValue(label, trace) { + return { render: async prefix => { trace.push(`${label}.render(${prefix})`); return `${prefix}:${label}`; } }; +} +function counterValue(label, initial, trace) { + let count = initial; + return { + read: async () => { trace.push(`${label}.read`); return count; }, + add: async delta => { + trace.push(`${label}.add(${delta})`); + if (count + delta < 0) throw new DomainError('below-zero'); + count += delta; + return count; + }, + }; +} +function cellValue(initial, trace, label = 'cell') { + let selected = initial; + return { + source: { + get: async () => { trace.push(`${label}.get`); return selected; }, + set: async value => { trace.push(`${label}.set`); selected = value; }, + }, + replace: value => { selected = value; }, + }; +} + +async function textScenario(project) { + const trace = []; + const cell = cellValue(textValue('initial', trace), trace); + const view = project(cell.source); + assert.deepEqual(trace, [], 'constructing adapters invoked the provider'); + cell.replace(textValue('late', trace)); + const results = [await (await view.get()).render('one')]; + await view.set(textValue('caller', trace)); + results.push(await (await cell.source.get()).render('two')); + results.push(await (await view.get()).render('three')); + assert.deepEqual(results, ['one:late', 'two:caller', 'three:caller']); + assert.deepEqual(trace, [ + 'cell.get', 'late.render(one)', 'cell.set', 'cell.get', 'caller.render(two)', + 'cell.get', 'caller.render(three)', + ]); + return { results, trace }; +} + +async function counterScenario(project) { + const trace = []; + const cell = cellValue(counterValue('initial', 0, trace), trace); + const view = project(cell.source); + assert.deepEqual(trace, []); + cell.replace(counterValue('late', 10, trace)); + const returned = await view.get(); + const results = [await returned.add(3), await returned.read()]; + await view.set(counterValue('caller', 50, trace)); + results.push(await (await cell.source.get()).add(2)); + const alias = await view.get(); + results.push(await alias.read()); + await assert.rejects(alias.add(-100), error => error instanceof DomainError && error.code === 'below-zero'); + results.push(await alias.read()); + assert.deepEqual(results, [13, 13, 52, 52, 52]); + assert.deepEqual(trace, [ + 'cell.get', 'late.add(3)', 'late.read', 'cell.set', 'cell.get', 'caller.add(2)', + 'cell.get', 'caller.read', 'caller.add(-100)', 'caller.read', + ]); + return { results, trace }; +} + +for (const carrier of ['pair', 'websocket']) { + for (const [name, makeInner, scenario] of [ + ['Text', textAdapter, textScenario], ['Counter', counterAdapter, counterScenario], + ]) { + test(`${carrier}: Cell<${name}> agrees with direct calls, lazy reads and reverse writes`, async t => { + const s = await setup(t, carrier); + const provider = cellAdapter(wireCellAdapter(s.provider), makeInner(s.provider)); + const client = cellAdapter(wireCellAdapter(s.client), makeInner(s.client)); + const direct = await scenario(source => source); + const adapted = await scenario(source => remoteView(s, source, client, provider).view); + assert.deepEqual(adapted, direct); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + } + + test(`${carrier}: Cell> reuses the outer adapter for both nested write directions`, async t => { + const s = await setup(t, carrier), trace = []; + const outerProvider = wireCellAdapter(s.provider), outerClient = wireCellAdapter(s.client); + const leafProvider = cellAdapter(outerProvider, textAdapter(s.provider)); + const leafClient = cellAdapter(outerClient, textAdapter(s.client)); + const nestedProvider = cellAdapter(outerProvider, leafProvider); + const nestedClient = cellAdapter(outerClient, leafClient); + const inner = cellValue(textValue('original', trace), trace, 'inner'); + const root = cellValue(inner.source, trace, 'root'); + const { view } = remoteView(s, root.source, nestedClient, nestedProvider); + assert.deepEqual(trace, []); + inner.replace(textValue('late', trace)); + const first = await view.get(); + assert.equal(await (await first.get()).render('a'), 'a:late'); + await first.set(textValue('caller-leaf', trace)); + assert.equal(await (await inner.source.get()).render('b'), 'b:caller-leaf'); + + const callerInner = cellValue(textValue('caller-inner', trace), trace, 'caller-cell'); + await view.set(callerInner.source); + // Provider calls an inner cell that originated at the caller. + const atProvider = await root.source.get(); + assert.equal(await (await atProvider.get()).render('c'), 'c:caller-inner'); + await atProvider.set(textValue('provider-leaf', trace)); + // Caller calls the provider capability that travelled through its own cell. + assert.equal(await (await (await view.get()).get()).render('d'), 'd:provider-leaf'); + assert.deepEqual(trace, [ + 'root.get', 'inner.get', 'late.render(a)', 'inner.set', 'inner.get', + 'caller-leaf.render(b)', 'root.set', 'root.get', 'caller-cell.get', + 'caller-inner.render(c)', 'caller-cell.set', 'root.get', 'caller-cell.get', + 'provider-leaf.render(d)', + ]); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: malformed domain messages and declared failures stay distinct`, async t => { + const s = await setup(t, carrier), trace = []; + const provider = counterAdapter(s.provider), client = counterAdapter(s.client); + const { view, wire } = remoteView(s, counterValue('counter', 4, trace), client, provider); + await assert.rejects(invoke(s.client, wire, 'add', [text('not an integer')]), ProtocolError); + await assert.rejects(invoke(s.client, wire, 'missing', []), ProtocolError); + assert.deepEqual(trace, [], 'malformed input reached the provider'); + await assert.rejects(view.add(-5), error => error instanceof DomainError && error.code === 'below-zero'); + assert.equal(await view.read(), 4); + assert.deepEqual(trace, ['counter.add(-5)', 'counter.read']); + + const bad = s.provider.endpoint(request => { + const reply = tupleItems(request, 3)[2]; + return reply.send(hydratedTuple([text('unknown status'), text('data')])); + }); + const badProxy = s.network.parts.client.scope.connect(s.network.parts.provider.scope.expose(bad)); + await assert.rejects(invoke(s.client, badProxy, 'anything', []), ProtocolError); + assert.equal(s.client.open, 0, 'failed calls kept reply endpoints'); + assert.equal(s.network.parts.client.scope.live, 0, 'failed calls kept reply exports'); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: Cell.set preserves asynchronous completion and provider failure`, async t => { + const s = await setup(t, carrier), started = Promise.withResolvers(), finish = Promise.withResolvers(); + const trace = []; + const source = { + get: async () => { throw new Error('get must not be invoked'); }, + set: async () => { + trace.push('started'); + started.resolve(); + await finish.promise; + trace.push('refused'); + throw new DomainError('write-denied'); + }, + }; + const provider = cellAdapter(wireCellAdapter(s.provider), textAdapter(s.provider)); + const client = cellAdapter(wireCellAdapter(s.client), textAdapter(s.client)); + const { view } = remoteView(s, source, client, provider); + assert.deepEqual(trace, []); + let settled = false; + const operation = view.set(textValue('borrowed', [])); + const checked = assert.rejects(operation, error => error instanceof DomainError && error.code === 'write-denied'); + void operation.then(() => { settled = true; }, () => { settled = true; }); + await started.promise; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false, 'local admission was mistaken for domain completion'); + finish.resolve(); + await checked; + assert.deepEqual(trace, ['started', 'refused']); + assert.equal(s.client.open, 1, 'reply must close while the offered service remains owned'); + assert.equal(s.network.parts.client.scope.live, 1); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: 200 Cell reads release replies and keep service exports bounded`, async t => { + const s = await setup(t, carrier), trace = []; + const provider = cellAdapter(wireCellAdapter(s.provider), textAdapter(s.provider)); + const client = cellAdapter(wireCellAdapter(s.client), textAdapter(s.client)); + const source = cellValue(textValue('stable', trace), trace).source; + const { view, wire } = remoteView(s, source, client, provider); + const a = s.network.parts.client.scope, b = s.network.parts.provider.scope; + for (let i = 0; i < 200; i++) { + assert.equal(await (await view.get()).render(String(i)), `${i}:stable`); + assert.equal(a.live, 0, `read ${i}: reply export retained`); + assert.equal(b.live, 2, `read ${i}: service rematerialized`); + assert.equal(s.client.open, 0); + assert.equal(s.provider.open, 2); + } + assert.equal(trace.length, 400, 'extra provider invocations'); + const borrowed = new Endpoint(1), arrived = Promise.withResolvers(); + borrowed.receive(arrived.resolve); + t.after(() => borrowed.close()); + const borrowedProxy = a.connect(b.expose(borrowed)); + await s.provider.close(); + assert.equal(b.live, 1, 'owner closed an independently owned endpoint'); + await wire.send(text('late')); + await eventually(() => s.network.diags.length === 1, 'closed service was not refused'); + assert.deepEqual(s.network.diags, [{ who: 'provider', reason: UNKNOWN_EXPORT }]); + await borrowedProxy.send(text('still alive')); + assert.ok((await arrived.promise).equals(text('still alive'))); + assert.equal(trace.length, 400); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: attachment loss settles a pending call without claiming rollback`, async t => { + const s = await setup(t, carrier); + const started = Promise.withResolvers(), finish = Promise.withResolvers(); + let completed = false; + const source = { render: async () => { started.resolve(); await finish.promise; completed = true; return 'effect completed'; } }; + const { view } = remoteView(s, source, textAdapter(s.client), textAdapter(s.provider)); + const pending = assert.rejects(view.render('lost'), OutcomeUnknown); + await started.promise; + await s.network.parts.client.ep.close(); + await pending; + assert.equal(s.client.open, 0); + assert.equal(s.network.parts.client.scope.live, 0); + // End the provider's service lifetime; already-started work can still finish. + await s.provider.close(); + finish.resolve(); + await eventually(() => completed, 'provider effect was incorrectly cancelled'); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); +} + +test('Cell lifting respects identity and composition for reads, writes and provider failures', async () => { + const r = { to: number => String(number), from: value => Number(value) }; + const s = { to: value => ({ decimal: value }), from: value => value.decimal }; + const composed = { to: value => s.to(r.to(value)), from: value => r.from(s.from(value)) }; + const identity = { to: value => value, from: value => value }; + async function observe(project) { + const trace = []; + let value = 7; + const source = { + get: async () => { trace.push(['get', value]); return value; }, + set: async next => { + trace.push(['set', next]); + if (next < 0) throw new DomainError('negative'); + value = next; + }, + }; + const view = project(source); + assert.deepEqual(trace, []); + const before = await view.get(); + await view.set(19); + await assert.rejects(view.set(-1), error => error instanceof DomainError && error.code === 'negative'); + const after = await view.get(); + assert.equal(before, 7); + assert.equal(after, 19); + assert.deepEqual(trace, [['get', 7], ['set', 19], ['set', -1], ['get', 19]]); + return { before, after, trace }; + } + const direct = await observe(source => source); + assert.deepEqual(await observe(source => adaptCell(source, identity)), direct); + const lower = cell => ({ get: async () => Number((await cell.get()).decimal), set: value => cell.set({ decimal: String(value) }) }); + const successive = await observe(source => lower(adaptCell(adaptCell(source, r), s))); + const together = await observe(source => lower(adaptCell(source, composed))); + assert.deepEqual(successive, direct); + assert.deepEqual(together, direct); +}); diff --git a/hydrated/ts/test/fixtures/cell.mjs b/hydrated/ts/test/fixtures/cell.mjs new file mode 100644 index 0000000..878329f --- /dev/null +++ b/hydrated/ts/test/fixtures/cell.mjs @@ -0,0 +1,37 @@ +import { invoke, memoAdapter, provide, ProtocolError, sendingWire, string, text } from './domain.mjs'; + +// Mixed variance: get uses the forward conversion; set uses the backward one. +export function adaptCell(source, adapters) { + return { + get: async () => adapters.to(await source.get()), + set: async value => source.set(adapters.from(value)), + }; +} + +/** The only Cell operation protocol, independent of the inner domain. */ +export function wireCellAdapter(owner) { + return memoAdapter( + source => provide(owner, async (operation, args) => { + if (operation === 'get' && args.length === 0) return sendingWire(await source.get()); + if (operation === 'set' && args.length === 1) { + await source.set(sendingWire(args[0])); + return text('done'); + } + throw new ProtocolError('invalid Cell request'); + }), + target => ({ + get: () => invoke(owner, target, 'get', [], sendingWire), + set: value => invoke(owner, target, 'set', [value], result => { + if (string(result) !== 'done') throw new ProtocolError('invalid Cell completion'); + }), + }), + ); +} + +/** adapter3 = adapter3_ composed with lift3(adapterT), in both directions. */ +export function cellAdapter(outer, inner) { + return memoAdapter( + source => outer.toWire(adaptCell(source, { to: inner.toWire, from: inner.fromWire })), + target => adaptCell(outer.fromWire(target), { to: inner.fromWire, from: inner.toWire }), + ); +} diff --git a/hydrated/ts/test/fixtures/counter.mjs b/hydrated/ts/test/fixtures/counter.mjs new file mode 100644 index 0000000..81f66ee --- /dev/null +++ b/hydrated/ts/test/fixtures/counter.mjs @@ -0,0 +1,22 @@ +import { invoke, memoAdapter, provide, ProtocolError, string, text } from './domain.mjs'; + +function integer(value) { + const encoded = string(value), number = Number(encoded); + if (!Number.isSafeInteger(number) || String(number) !== encoded) throw new ProtocolError('expected integer'); + return number; +} + +// Counter = { read(): Promise; add(delta: number): Promise }. +export function counterAdapter(owner) { + return memoAdapter( + source => provide(owner, async (operation, args) => { + if (operation === 'read' && args.length === 0) return text(String(await source.read())); + if (operation === 'add' && args.length === 1) return text(String(await source.add(integer(args[0])))); + throw new ProtocolError('invalid Counter request'); + }), + target => ({ + read: () => invoke(owner, target, 'read', [], integer), + add: delta => invoke(owner, target, 'add', [text(String(delta))], integer), + }), + ); +} diff --git a/hydrated/ts/test/fixtures/domain.mjs b/hydrated/ts/test/fixtures/domain.mjs new file mode 100644 index 0000000..0b8b320 --- /dev/null +++ b/hydrated/ts/test/fixtures/domain.mjs @@ -0,0 +1,124 @@ +// Consumer-only request and ownership convention. No Scope, route or reference API. +import { Atom, atom } from '@bitspark/bitwire'; +import { Endpoint, hydratedTuple, items } from '../../../../dist/hydrated/ts/src/index.js'; + +export const text = value => atom(new TextEncoder().encode(value)); +export function string(value) { + if (!(value instanceof Atom)) throw new ProtocolError('expected text atom'); + try { return new TextDecoder('utf-8', { fatal: true }).decode(value.bytes()); } + catch { throw new ProtocolError('invalid UTF-8'); } +} +export function tupleItems(value, length) { + const values = items(value); + if (!values || (length !== undefined && values.length !== length)) { + throw new ProtocolError('invalid domain tuple'); + } + return values; +} +export function sendingWire(value) { + if (!value || typeof value.send !== 'function') throw new ProtocolError('expected Wire'); + return value; +} +export class ProtocolError extends Error { name = 'ProtocolError'; } +export class DomainError extends Error { + name = 'DomainError'; + constructor(code) { super(code); this.code = code; } +} +export class OutcomeUnknown extends Error { + name = 'OutcomeUnknown'; + constructor() { super('domain operation outcome unknown'); } +} + +/** The domain composition owns service endpoints; each call owns its reply. */ +export class AdapterOwner { + #endpoints = new Map(); + #controller = new AbortController(); + faults = []; + get signal() { return this.#controller.signal; } + get open() { return this.#endpoints.size; } + endpoint(handler) { + if (this.signal.aborted) throw new OutcomeUnknown(); + const endpoint = new Endpoint(32); + this.#endpoints.set(endpoint.wire, endpoint); + endpoint.receive((value, context) => { + void Promise.resolve().then(() => handler(value, context)).catch(error => this.faults.push(error)); + }); + return endpoint; + } + // Used only by test assembly to bootstrap an owned root, never by an adapter. + ownedEndpoint(wire) { return this.#endpoints.get(wire); } + async release(endpoint) { + this.#endpoints.delete(endpoint.wire); + await endpoint.close(); + } + async close() { + this.#controller.abort(); + await Promise.all([...this.#endpoints.values()].map(endpoint => this.release(endpoint))); + } +} + +/** Local materialization cache, not a transport reference/export table. */ +export function memoAdapter(toWire, fromWire) { + const provided = new WeakMap(), projected = new WeakMap(); + return { + toWire(value) { + let target = provided.get(value); + if (!target) { target = toWire(value); provided.set(value, target); } + return target; + }, + fromWire(target) { + sendingWire(target); + let value = projected.get(target); + if (!value) { + value = fromWire(target); + projected.set(target, value); + provided.set(value, target); + } + return value; + }, + }; +} + +/** The domain chooses one result per invocation and distinguishes its failures. */ +export function provide(owner, dispatch) { + return owner.endpoint(async message => { + const fields = tupleItems(message); + const reply = sendingWire(fields[2]); + let response; + try { + tupleItems(message, 3); + response = hydratedTuple([text('ok'), await dispatch(string(fields[0]), tupleItems(fields[1]))]); + } catch (error) { + if (error instanceof DomainError) response = hydratedTuple([text('domain'), text(error.code)]); + else if (error instanceof ProtocolError) response = hydratedTuple([text('protocol'), text(error.message)]); + else throw error; + } + // Closing this service lifetime stops replies, but does not cancel started work. + if (!owner.signal.aborted) await reply.send(response); + }).wire; +} + +export async function invoke(owner, target, operation, args, decode = value => value) { + const result = Promise.withResolvers(); + const reply = owner.endpoint(message => { + try { + const [status, value] = tupleItems(message, 2); + switch (string(status)) { + case 'ok': result.resolve(decode(value)); break; + case 'domain': result.reject(new DomainError(string(value))); break; + case 'protocol': result.reject(new ProtocolError(string(value))); break; + default: throw new ProtocolError('unknown response status'); + } + } catch (error) { result.reject(error); } + }); + const end = () => result.reject(new OutcomeUnknown()); + const timer = setTimeout(end, 3000); + owner.signal.addEventListener('abort', end, { once: true }); + void target.send(hydratedTuple([text(operation), hydratedTuple(args), reply.wire])).catch(result.reject); + try { return await result.promise; } + finally { + clearTimeout(timer); + owner.signal.removeEventListener('abort', end); + await owner.release(reply); + } +} diff --git a/hydrated/ts/test/fixtures/text.mjs b/hydrated/ts/test/fixtures/text.mjs new file mode 100644 index 0000000..d472dfa --- /dev/null +++ b/hydrated/ts/test/fixtures/text.mjs @@ -0,0 +1,12 @@ +import { invoke, memoAdapter, provide, ProtocolError, string, text } from './domain.mjs'; + +// Text = { render(prefix: string): Promise }. +export function textAdapter(owner) { + return memoAdapter( + source => provide(owner, async (operation, args) => { + if (operation !== 'render' || args.length !== 1) throw new ProtocolError('invalid Text request'); + return text(await source.render(string(args[0]))); + }), + target => ({ render: prefix => invoke(owner, target, 'render', [text(prefix)], string) }), + ); +} diff --git a/hydrated/ts/test/harness.mjs b/hydrated/ts/test/harness.mjs new file mode 100644 index 0000000..22130df --- /dev/null +++ b/hydrated/ts/test/harness.mjs @@ -0,0 +1,70 @@ +// Shared harness for the hydrated TypeScript observations and consumer suites: +// participants of one namespace around an opaque middle router that knows only +// routes and ground values, over local pairs or real WebSockets. Consumer suites +// import it rather than editing hydrated.test.mjs. +import assert from 'node:assert/strict'; +import { atom } from '@bitspark/bitwire'; +import { pair, addressed } from '../../../dist/core/ts/src/index.js'; +import { listenWebSocket, connectWebSocket } from '../../../dist/websocket/ts/src/index.js'; +import { Endpoint, Namespace, Scope, hydratedTuple } from '../../../dist/hydrated/ts/src/index.js'; + +export const text = (s) => atom(new TextEncoder().encode(s)); +export const str = (a) => new TextDecoder().decode(a.bytes()); +export const tick = () => new Promise((r) => setTimeout(r, 5)); +export async function eventually(cond, what) { + for (let i = 0; i < 1000; i++) { if (cond()) return; await tick(); } + assert.fail(what); +} + +export const PATHS = { client: [text('client42')], provider: [text('a'), text('b'), text('service2')], third: [text('c')] }; +export const key = (p) => p.map((a) => Buffer.from(a.bytes()).toString('hex')).join('/') + `#${p.length}`; + +export async function links(t, carrier, n) { + if (carrier === 'pair') return Array.from({ length: n }, () => { const ends = pair(); t.after(() => ends[0].close()); return ends; }); + const accepted = []; + let wake; + const listener = await listenWebSocket({ host: '127.0.0.1' }, (e) => { accepted.push(e); wake?.(); }); + t.after(() => listener.close()); + const out = []; + for (let i = 0; i < n; i++) { + const c = await connectWebSocket(listener.url); + t.after(() => c.close()); + while (accepted.length <= i) await new Promise((r) => { wake = r; }); + out.push([c, accepted[i]]); + } + return out; +} + +// Participants around an opaque middle that knows routes and ground values only. +export async function tree(t, carrier, ...names) { + const ns = new Namespace('test'); + const routes = new Map(), trace = [], diags = [], parts = {}; + const ls = await links(t, carrier, names.length); + names.forEach((name, i) => { + const mine = addressed(ls[i][0]), mid = addressed(ls[i][1]); + routes.set(key(PATHS[name]), mid); + mid.receive((p, v) => { trace.push(v); void routes.get(key(p))?.send(p, v); }); + const part = { name, ep: mine, scope: new Scope(ns, PATHS[name], mine) }; + mine.receive((p, v) => { + try { part.scope.deliver(p, v, `${name}-link`); } catch (e) { diags.push({ who: name, reason: e.reason ?? e.message }); } + }); + parts[name] = part; + }); + return { ns, trace, diags, parts }; +} + +export function serve(t, scope, handler) { + const e = new Endpoint(64); + e.receive(handler); + t.after(() => e.close()); + return [e, scope.expose(e)]; +} + +export async function call(target, op, ...args) { + const reply = new Endpoint(1); + const got = new Promise((resolve) => reply.receive((value, context) => resolve({ value, context }))); + await target.send(hydratedTuple([text(op), ...args, reply])); + const r = await got; + await reply.close(); + return r; +} diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs new file mode 100644 index 0000000..1ae4ae8 --- /dev/null +++ b/hydrated/ts/test/hydrated.test.mjs @@ -0,0 +1,372 @@ +// Observations of bitwire decision 0019 for the TypeScript realization; +// hydrated/go runs the Go observations; this suite repeats those a second language must. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { Atom, Tuple, atom, tuple, encodeMessage } from '@bitspark/bitwire'; +import { + Endpoint, Namespace, Scope, Refusal, hydratedTuple, items, + UNKNOWN_EXPORT, STALE_SCOPE, UNEXPORTABLE, FOREIGN_NAMESPACE, MALFORMED_FRAME, +} from '../../../dist/hydrated/ts/src/index.js'; +import { text, str, eventually, PATHS, tree, serve, call } from './harness.mjs'; + +test('observation 1: vectors round-trip (bitwire#83 accepted at 6e33fb3, pinned)', () => { + const raw = readFileSync(new URL('../../go/testdata/hydrated-vectors.json', import.meta.url)); + assert.equal(createHash('sha256').update(raw).digest('hex'), '9d5f684871d1b56719b33bdff3114a101d2a7a1a8702d1f4474303df0e109312'); + const v = JSON.parse(raw); + const from = (x) => ('atom' in x ? atom(Buffer.from(x.atom, 'hex')) : tuple(x.tuple.map(from))); + const wires = (x) => (x instanceof Atom || x instanceof Tuple ? 0 : (items(x)?.reduce((n, c) => n + wires(c), 0) ?? 1)); + const s = new Scope(new Namespace('vectors'), [text('vectors')], { send: async () => {} }); + for (const c of v.encode) { + const body = from(c.body); + const live = s._decodeBody(body, new Map()); + assert.equal(wires(live), (c.live.match(/wire\{/g) ?? []).length, c.name); + const again = s._encodeBody(live, new Map()); + assert.equal(Buffer.from(encodeMessage(again)).toString('hex'), c.hex, c.name); + } + for (const c of v.rejectBody) { + assert.throws(() => s._decodeBody(from(c.value), new Map()), (e) => e.reason === MALFORMED_FRAME, c.name); + } +}); + +function echo(value) { + const xs = items(value); + const op = str(xs[0]), arg = xs[1], reply = xs.at(-1); + if (op === 'echo') return void reply.send(arg); + const cont = new Endpoint(1); + cont.receive((next) => { const [v, w] = items(next); void w.send(v); void cont.close(); }); + void reply.send(hydratedTuple([arg, cont])); +} + +for (const carrier of ['pair', 'websocket']) { + test(`${carrier}: observation 2, recursion through an opaque router`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider'); + const [, svc] = serve(t, n.parts.provider.scope, echo); + const r = await call(n.parts.client.scope.connect(svc), 'continue', text('hello')); + const [arg, continuation] = items(r.value); + assert.equal(str(arg), 'hello'); + const third = new Endpoint(1); + const got = new Promise((resolve) => third.receive(resolve)); + await continuation.send(hydratedTuple([text('third Wire'), third])); + assert.equal(str(await got), 'third Wire'); + assert.equal(n.trace.length, 4); + assert.deepEqual(n.diags, []); + }); + + test(`${carrier}: observation 3, 200 long-lived calls stay bounded`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider'); + const client = n.parts.client.scope, provider = n.parts.provider.scope; + const [, svc] = serve(t, provider, echo); + const target = client.connect(svc); + for (let i = 0; i < 200; i++) { + const r = await call(target, 'echo', text(String(i))); + assert.equal(str(r.value), String(i)); + assert.ok(client.live <= 1 && provider.live === 1, `call ${i}: ${client.live} ${provider.live}`); + } + assert.equal(client.live, 0); + assert.deepEqual(n.diags, []); + }); + + test(`${carrier}: observation 4, a shared Wire ends only with its owner`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider', 'third'); + const { client: { scope: a }, provider: { scope: b }, third: { scope: c } } = n.parts; + let atB, atC; + const [, refC] = serve(t, c, (v) => { atC = v; }); + const toC = b.connect(refC); + const [, refB] = serve(t, b, (v) => { atB = v; void toC.send(v); }); + const w = new Endpoint(8); + const seen = []; + w.receive((v, ctx) => seen.push(ctx)); + await a.connect(refB).send(w); + await eventually(() => atC, 'C did not receive the Wire'); + assert.equal(b.live + c.live, 2, 'forwarding registered state'); + await atC.send(text('from C')); + await eventually(() => seen.length === 1, "C's send did not reach W"); + await w.close(); + await atB.send(text('late B')); + await atC.send(text('late C')); + await eventually(() => n.diags.filter((d) => d.who === 'client' && d.reason === UNKNOWN_EXPORT).length === 2, 'late sends not refused at A'); + assert.equal(seen.length, 1); + }); + + test(`${carrier}: observation 7, a stale scope cannot reach the replacement`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider'); + const [, svc] = serve(t, n.parts.provider.scope, () => {}); + const old = n.parts.client.scope.connect(svc); + n.parts.provider.scope.close(); + n.parts.provider.scope = new Scope(n.ns, PATHS.provider, n.parts.provider.ep); + let hit = false; + serve(t, n.parts.provider.scope, () => { hit = true; }); + await old.send(text('stale')); + await eventually(() => n.diags.length === 1, 'not refused'); + assert.deepEqual(n.diags, [{ who: 'provider', reason: STALE_SCOPE }]); + assert.equal(hit, false); + }); +} + +test('observation 6: refusals before admission register and send nothing', async (t) => { + const n = await tree(t, 'pair', 'client', 'provider'); + const a = n.parts.client.scope; + const [, svc] = serve(t, n.parts.provider.scope, () => {}); + const target = a.connect(svc); + const kept = new Endpoint(1); + const foreignWire = { kind: 'wire', send: async () => {} }; + assert.throws(() => hydratedTuple([kept, foreignWire]), (e) => e.reason === UNEXPORTABLE); + await assert.rejects(target.send(foreignWire), (e) => e.reason === UNEXPORTABLE); + const foreign = new Scope(new Namespace('other'), [text('x')], { send: async () => {} }) + .connect({ path: [text('y')], scope: atom(new Uint8Array(16)), id: text('1') }); + await assert.rejects(target.send(hydratedTuple([kept, foreign])), (e) => e.reason === FOREIGN_NAMESPACE); + const closed = new Endpoint(1); + await closed.close(); + await assert.rejects(target.send(closed), (e) => e.reason === UNEXPORTABLE); + assert.equal(a.live, 0); + assert.equal(n.trace.length, 0); +}); + +test('observation 8: returning home', async (t) => { + const n = await tree(t, 'pair', 'client', 'provider'); + const a = n.parts.client.scope; + let proceed; + const gate = new Promise((r) => { proceed = r; }); + const [, svc] = serve(t, n.parts.provider.scope, async (v) => { + await gate; + const [home, gone, reply] = items(v); + void reply.send(hydratedTuple([text('data'), home, gone])); + }); + const home = new Endpoint(1), gone = new Endpoint(1), reply = new Endpoint(1); + const got = new Promise((resolve) => reply.receive(resolve)); + await a.connect(svc).send(hydratedTuple([home, gone, reply])); + await gone.close(); + proceed(); + const [data, back, dead] = items(await got); + assert.equal(str(data), 'data'); + assert.equal(back, home.wire, 'own reference did not return to the original face'); + await assert.rejects(dead.send(text('x')), (e) => e instanceof Refusal && e.reason === UNKNOWN_EXPORT); +}); + +test('observation 9: received context is not data', async (t) => { + const n = await tree(t, 'pair', 'client', 'provider'); + const target = new Endpoint(1); + t.after(() => target.close()); + const seen = new Promise((resolve) => target.receive((value, context) => resolve({ value, context }))); + const forged = tuple([text('context'), text('client-link')]); + await n.parts.client.scope.connect(n.parts.provider.scope.expose(target)).send(forged); + const r = await seen; + assert.equal(r.context, 'provider-link'); + assert.ok(forged.equals(r.value)); +}); + +for (const carrier of ['pair', 'websocket']) { + test(`${carrier}: observation 8, one reference grants no sibling`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider', 'third'); + const provider = n.parts.provider.scope; + let privateHits = 0; + const [, pub] = serve(t, provider, () => {}); + const [, intended] = serve(t, provider, () => { privateHits++; }); + await n.parts.third.scope.connect(intended).send(text('legitimate')); + await eventually(() => privateHits === 1, 'the intended holder was not served'); + assert.equal(pub.id.length, 16); + assert.ok(!pub.id.equals(intended.id)); + const bump = Uint8Array.from(pub.id.bytes()); bump[15]++; + const forged = [atom(bump), atom(new Uint8Array(16)), text('0000000000000002')]; + for (const id of forged) await n.parts.client.scope.connect({ ...pub, id }).send(text('forged')).catch(() => {}); + // A counter-like id of the wrong length is refused before it leaves. + await assert.rejects(n.parts.client.scope.connect({ ...pub, id: text('2') }).send(text('forged')), (e) => e.reason === MALFORMED_FRAME); + await eventually(() => n.diags.length === forged.length, 'forged references were not refused'); + for (const d of n.diags) assert.ok(d.who === 'provider' && ['unknown-export', 'malformed-frame'].includes(d.reason), JSON.stringify(d)); + assert.equal(privateHits, 1); + }); +} + +test('observation 15, incoming: a frame counts whole, reference material included', () => { + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }, { exports: 4, nodes: 64, depth: 8, bytes: 128 }); + const target = new Endpoint(4); + let delivered = 0; + target.receive(() => { delivered++; }); + const ref = s.expose(target); + const huge = tuple([atom([2]), tuple([tuple([atom(new Uint8Array(4096))]), atom(new Uint8Array(16)), atom(new Uint8Array(16))])]); + const frame = tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, tuple([atom([1]), tuple([huge])])]); + assert.throws(() => s.deliver(s.path, frame, undefined), (e) => e.reason === 'limit'); + assert.equal(delivered, 0); +}); + +test('observation 17: Endpoint laws on every path', async () => { + const got = []; + const local = new Endpoint(4); + local.receive((v) => got.push(v)); + const priv = new Endpoint(1); + await local.wire.send(priv); + await local.wire.send(hydratedTuple([priv])); + await eventually(() => got.length === 2, 'local sends not delivered'); + assert.equal(got[0], priv.wire, 'a bare endpoint kept its authority'); + assert.equal(items(got[1])[0], priv.wire, 'an endpoint inside a tuple kept its authority'); + assert.equal('close' in got[0], false); + + const e = new Endpoint(4); + const stale = e.receive(() => {}); + stale(); + let seen = 0; + e.receive(() => { seen++; }); + stale(); + await e.send(text('after')); + await eventually(() => seen === 1, 'a stale detach removed the newer receiver'); + + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }); + const failing = new Endpoint(4); + failing.receive(() => { throw new Error('receiver fault'); }); + const ref = s.expose(failing); + s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('x')]), undefined); + await eventually(() => failing.termination?.kind === 'failed' && s.live === 0, 'the failing receiver did not terminate its endpoint'); + assert.throws(() => s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('y')]), undefined), (e) => e.reason === UNKNOWN_EXPORT); + assert.equal((await failing.closed).kind, 'failed'); + const quiet = new Endpoint(1); + assert.equal(quiet.termination, undefined); + await quiet.close(); + assert.deepEqual(await quiet.closed, { kind: 'closed' }); +}); + +// The first termination is final: a receiver that fails after its endpoint was +// closed leaves it closed, as Go's TestTerminationIsFinalAfterClose shows. +test('the first termination is final', async () => { + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }); + const e = new Endpoint(1); + e.receive(() => { void e.close(); throw new Error('late receiver failure'); }); + const ref = s.expose(e); + s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('x')]), undefined); + assert.deepEqual(await e.closed, { kind: 'closed' }); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(e.termination, { kind: 'closed' }); + assert.equal(s.live, 0); + assert.throws(() => s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('y')]), undefined), (err) => err.reason === UNKNOWN_EXPORT); + + const failing = new Endpoint(1); + failing.receive(() => { throw new Error('receiver fault'); }); + await failing.send(text('x')); + assert.equal((await failing.closed).kind, 'failed'); + await failing.close(); + assert.equal(failing.termination.kind, 'failed'); +}); + +test('observation 15, symmetry: sender and receiver count a value alike', async () => { + const face = new Endpoint(1); + let nested = text('x'); + for (let i = 0; i < 3; i++) nested = hydratedTuple([nested, face]); + const values = { + 'one face three times': hydratedTuple([face, face, face]), + 'nested faces': nested, + 'a long atom': atom(new Uint8Array(96)), + 'atoms and a face': hydratedTuple([text('a'), text('bb'), face]), + }; + const seen = { true: 0, false: 0 }; + for (let nodes = 2; nodes <= 8; nodes++) for (let depth = 1; depth <= 5; depth++) for (const bytes of [40, 64, 96, 128, 1024]) { + const limits = { exports: 4, nodes, depth, bytes }; + for (const [name, v] of Object.entries(values)) { + const frames = []; + const sender = new Scope(new Namespace('x'), [text('s')], { send: async (_p, f) => { frames.push(f); } }, limits); + const receiver = new Scope(new Namespace('x'), [text('r')], { send: async () => {} }, limits); + const ref = receiver.expose(new Endpoint(4)); + let accepted = true; + try { await sender.connect(ref).send(v); } catch (e) { if (e.reason !== 'limit') throw e; accepted = false; } + seen[accepted]++; + let frame = frames[0]; + if (!accepted) { + const open = new Scope(new Namespace('x'), [text('s')], { send: async (_p, f) => { frame = f; } }, { exports: 4, nodes: 1 << 20, depth: 1 << 10, bytes: 1 << 20 }); + await open.connect(ref).send(v); + } + let received = true; + try { receiver.deliver(receiver.path, frame, undefined); } catch (e) { if (e.reason !== 'limit') throw e; received = false; } + assert.equal(received, accepted, `${name} at nodes ${nodes} depth ${depth} bytes ${bytes}`); + } + } + assert.ok(seen.true > 0 && seen.false > 0, JSON.stringify(seen)); +}); + +test('observation 17, construction: the exported tuple class cannot bypass projection', async () => { + const { HydratedTuple } = await import('../../../dist/hydrated/ts/src/index.js'); + const priv = new Endpoint(1); + assert.throws(() => new HydratedTuple([priv]), /hydratedTuple/); + assert.throws(() => new HydratedTuple([text('ground')]), /hydratedTuple/); + const got = []; + const local = new Endpoint(4); + local.receive((v) => got.push(v)); + await local.wire.send(hydratedTuple([hydratedTuple([priv]), text('x')])); + await eventually(() => got.length === 1, 'not delivered'); + const inner = items(items(got[0])[0])[0]; + assert.equal(inner, priv.wire, 'a nested endpoint kept its authority'); + assert.ok(hydratedTuple([text('a')]) instanceof Tuple, 'a tuple without a Wire is not ground'); +}); + +test('observation 17, local edges: bounds, failure tracking and recognition on local sends', async () => { + for (const bad of [0, -1, 1.5, NaN, Infinity]) assert.throws(() => new Endpoint(bad), RangeError, String(bad)); + const silent = new Endpoint(1); + silent.receive(() => { throw undefined; }); + await silent.wire.send(text('x')); + assert.equal((await silent.closed).kind, 'failed', 'a receiver throwing undefined still failed'); + + const got = []; + const local = new Endpoint(8); + local.receive((v) => got.push(v)); + const foreign = { kind: 'wire', send: async () => {} }; + await assert.rejects(local.wire.send(foreign), (e) => e.reason === UNEXPORTABLE); + assert.throws(() => hydratedTuple([text('a'), foreign]), (e) => e.reason === UNEXPORTABLE); + await local.wire.send(hydratedTuple([text('ok'), new Endpoint(1)])); + await eventually(() => got.length === 1, 'the valid value was not delivered'); + assert.equal(got.length, 1, 'a refused local value was delivered'); +}); + +test('observation 15, shared trees: refused before expansion, nothing committed or sent', async () => { + const frames = []; + const s = new Scope(new Namespace('x'), [text('a')], { send: async (_p, f) => { frames.push(f); } }); + const target = s.connect({ path: [text('b')], scope: atom(new Uint8Array(16)), id: atom(new Uint8Array(16)) }); + let v = hydratedTuple([new Endpoint(1)]); + for (let i = 0; i < 40; i++) v = hydratedTuple([v, v]); // 2^40 occurrences, 41 tuples + const start = Date.now(); + await assert.rejects(target.send(v), (e) => e.reason === 'limit'); + assert.ok(Date.now() - start < 2000, 'the tree was expanded before refusal'); + assert.equal(s.live, 0); + assert.equal(frames.length, 0); + const local = new Endpoint(1); + const got = new Promise((r) => local.receive(r)); + await local.wire.send(v); + assert.equal(await got, v, 'a local send traversed or rebuilt the value'); +}); + +test('observation 15, depth: a deep permitted value round-trips without exhausting the stack', async () => { + const limits = { exports: 4, nodes: 50000, depth: 20000, bytes: 1 << 22 }; + const frames = []; + const sender = new Scope(new Namespace('x'), [text('s')], { send: async (_p, f) => { frames.push(f); } }, limits); + const receiver = new Scope(new Namespace('x'), [text('r')], { send: async () => {} }, limits); + const target = new Endpoint(1); + const got = new Promise((r) => target.receive(r)); + const ref = receiver.expose(target); + let v = text('leaf'); + for (let i = 0; i < 15000; i++) v = hydratedTuple([v]); + await sender.connect(ref).send(v); + receiver.deliver(receiver.path, frames[0], undefined); + // Compare iteratively: ontos's recursive Tuple.equals is not part of this check. + let x = await got, depth = 0; + while (x instanceof Tuple) { assert.equal(x.length, 1); x = x.at(0); depth++; } + assert.equal(depth, 15000); + assert.ok(x.equals(text('leaf')), 'the deep value changed'); +}); + +test('observation 15, allocation: wide tuples are not copied; a repeated reference path is copied once', async () => { + const dest = { path: [text('b')], scope: atom(new Uint8Array(16)), id: atom(new Uint8Array(16)) }; + const tight = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }, { exports: 4, nodes: 4, depth: 8, bytes: 4096 }); + const wide = tuple(Array.from({ length: 200000 }, () => text('w'))); + let copies = 0; + const original = Tuple.prototype.items; + Tuple.prototype.items = function () { copies++; return original.call(this); }; + try { + await assert.rejects(tight.connect(dest).send(wide), (e) => e.reason === 'limit'); + } finally { Tuple.prototype.items = original; } + assert.equal(copies, 0, 'the wide tuple was copied before the budget refused it'); + + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }); + const far = s.connect({ path: Array.from({ length: 20000 }, () => atom(new Uint8Array(0))), scope: atom(new Uint8Array(16)), id: atom(new Uint8Array(16)) }); + const v = hydratedTuple(Array.from({ length: 300 }, () => far)); + const start = Date.now(); + await assert.rejects(s.connect(dest).send(v), (e) => e.reason === 'limit'); + assert.ok(Date.now() - start < 5000, 'the repeated path was expanded'); +}); diff --git a/package-lock.json b/package-lock.json index 3ded5e4..fb0ce55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@bitspark/bitruntime", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitspark/bitruntime", - "version": "0.6.0", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { - "@bitspark/bitwire": "0.5.0", + "@bitspark/bitwire": "0.6.0", "ws": "8.22.0" }, "devDependencies": { @@ -23,9 +23,9 @@ } }, "node_modules/@bitspark/bitwire": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.5.0.tgz", - "integrity": "sha512-4nFgd/WfGmWTqlor6xKBcerSMqUwupZbd1sso4mRXCnUz/5r+EGjENoxiT+uZ7EzyRUjZNMBi9XtngAnQGXRRg==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.6.0.tgz", + "integrity": "sha512-rWkRP9I4Thej53yZpyKYIHr98RPcYPRrYSjnB0yWRfCn1s2vFJ1xGro2fGwOVUldR6+vZuK87brOMYOKyQFNNw==", "license": "Apache-2.0" }, "node_modules/@bitspark/deixis-core": { diff --git a/package.json b/package.json index 933e532..56fe123 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitspark/bitruntime", - "version": "0.6.0", + "version": "0.7.0", "description": "Addressless endpoints, addressed access, byte-keyed trees, local pairs and WebSocket carriers.", "license": "Apache-2.0", "type": "module", @@ -17,6 +17,10 @@ "./websocket": { "types": "./dist/websocket/ts/src/index.d.ts", "import": "./dist/websocket/ts/src/index.js" + }, + "./hydrated": { + "types": "./dist/hydrated/ts/src/index.d.ts", + "import": "./dist/hydrated/ts/src/index.js" } }, "files": [ @@ -32,10 +36,10 @@ "scripts": { "check": "tsc -p tsconfig.check.json", "build": "node scripts/build.mjs", - "test": "npm run build && node --test --test-timeout=30000 \"core/ts/test/*.test.mjs\" \"websocket/ts/test/*.test.mjs\"" + "test": "npm run build && node --test --test-timeout=30000 \"core/ts/test/*.test.mjs\" \"websocket/ts/test/*.test.mjs\" \"hydrated/ts/test/*.test.mjs\"" }, "dependencies": { - "@bitspark/bitwire": "0.5.0", + "@bitspark/bitwire": "0.6.0", "ws": "8.22.0" }, "devDependencies": { diff --git a/scripts/go-smoke.mjs b/scripts/go-smoke.mjs index ea04d9d..8dd55dc 100644 --- a/scripts/go-smoke.mjs +++ b/scripts/go-smoke.mjs @@ -1,5 +1,5 @@ -import {spawnSync} from 'node:child_process'; -import {mkdtempSync, writeFileSync, readFileSync} from 'node:fs'; +import {spawn, spawnSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, writeFileSync, readFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -19,4 +19,19 @@ writeFileSync(join(temp, 'main.go'), readFileSync(new URL('../conformance/intero run(['get', `github.com/Bitspark/bitruntime@${revision}`]); run(['mod', 'tidy']); run(['run', '.', 'smoke']); -console.log(`Installed public Go module at ${revision} in ${temp}`); + +// The hydrated peer from the public module: serve, then call with a reply Wire. +const hydrated = join(temp, 'hydrated'); +mkdirSync(hydrated); +writeFileSync(join(hydrated, 'main.go'), readFileSync(new URL('../conformance/interop/hydrated/main.go', import.meta.url))); +const binary = join(temp, process.platform === 'win32' ? 'hydrated.exe' : 'hydrated'); +run(['build', '-o', binary, './hydrated']); +const server = spawn(binary, ['serve'], {stdio: ['pipe', 'pipe', 'inherit']}); +const url = await new Promise((resolve, reject) => { + server.stdout.once('data', (d) => resolve(String(d).trim().split(/\s+/)[0])); + server.once('error', reject); +}); +const client = spawnSync(binary, ['client', url], {encoding: 'utf8', timeout: 30000}); +server.stdin.end(); +if (client.status !== 0 || client.stdout.trim() !== 'ok') throw new Error('hydrated Go peer failed: ' + client.stderr); +console.log(`Installed public Go module at ${revision} in ${temp}, including the hydrated peer`); diff --git a/scripts/hydrated-interop.mjs b/scripts/hydrated-interop.mjs new file mode 100644 index 0000000..19f1752 --- /dev/null +++ b/scripts/hydrated-interop.mjs @@ -0,0 +1,89 @@ +// Observation 16 of bitwire decision 0019: Go and TypeScript peers +// exchange bitwire/hydrated/1 frames over a real WebSocket, each in both roles. +// The exchange: a request carrying a reply Wire, a reply carrying a continuation +// Wire, and a third Wire sent back through the continuation. +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { atom, pathEqual } from '@bitspark/bitwire'; +import { addressed } from '../dist/core/ts/src/index.js'; +import { connectWebSocket, listenWebSocket } from '../dist/websocket/ts/src/index.js'; +import { Endpoint, Namespace, Scope, hydratedTuple, items, readReference, referenceValue } from '../dist/hydrated/ts/src/index.js'; + +const directory = mkdtempSync(join(tmpdir(), 'bitruntime-hydrated-')); +const binary = join(directory, process.platform === 'win32' ? 'hydrated.exe' : 'hydrated'); +assert.equal(spawnSync('go', ['build', '-o', binary, './conformance/interop/hydrated'], { stdio: 'inherit' }).status, 0, 'Go peer build failed'); + +const text = (s) => atom(new TextEncoder().encode(s)); +const str = (a) => new TextDecoder().decode(a.bytes()); +const ns = new Namespace('interop'); +const BOOTSTRAP = [text('bootstrap')]; +const within = (promise, what) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(what)), 10000))]); + +function participant(link, path, onBootstrap) { + const scope = new Scope(ns, path, link); + link.receive((p, v) => { + if (pathEqual(p, BOOTSTRAP)) return onBootstrap(v); + try { scope.deliver(p, v, 'link'); } catch (e) { console.error('diagnostic:', e.reason ?? e.message); } + }); + return scope; +} + +function peer(args) { + const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'inherit'] }); + const lines = [], readers = []; + createInterface({ input: child.stdout }).on('line', (line) => { const r = readers.shift(); if (r) r(line); else lines.push(line); }); + const timeout = setTimeout(() => child.kill(), 30000); + const ended = new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code) => { clearTimeout(timeout); resolve(code); }); }); + return { child, ended, line: () => (lines.length ? Promise.resolve(lines.shift()) : new Promise((r) => readers.push(r))) }; +} + +// Go serves, TypeScript calls. +{ + const server = peer(['serve']); + const url = await server.line(); + assert.match(url ?? '', /^ws:/); + const endpoint = await connectWebSocket(url); + let bootstrapped; + const bootstrap = new Promise((r) => { bootstrapped = r; }); + const scope = participant(addressed(endpoint), [text('c')], (v) => bootstrapped(v)); + const target = scope.connect(readReference(await within(bootstrap, 'no bootstrap reference'))); + const reply = new Endpoint(1); + const replied = new Promise((r) => reply.receive(r)); + await target.send(hydratedTuple([text('continue'), text('hello'), reply])); + const [arg, continuation] = items(await within(replied, 'no reply')); + assert.equal(str(arg), 'hello'); + const third = new Endpoint(1); + const reached = new Promise((r) => third.receive(r)); + await continuation.send(hydratedTuple([text('third Wire'), third])); + assert.equal(str(await within(reached, 'third Wire not reached')), 'third Wire'); + await endpoint.close(); + server.child.stdin.end(); + assert.equal(await server.ended, 0, 'Go server failed'); + console.log('Go serves, TypeScript calls: ok'); +} + +// TypeScript serves, Go calls. +{ + const listener = await listenWebSocket({ host: '127.0.0.1' }, (endpoint) => { + const link = addressed(endpoint); + const scope = participant(link, [text('s')], () => {}); + const service = new Endpoint(16); + service.receive((v) => { + const [, arg, replyWire] = items(v); + const cont = new Endpoint(1); + cont.receive((next) => { const [value, wire] = items(next); void wire.send(value); void cont.close(); }); + void replyWire.send(hydratedTuple([arg, cont])); + }); + void link.send(BOOTSTRAP, referenceValue(scope.expose(service))); + }); + const client = peer(['client', listener.url]); + assert.equal(await within(client.line(), 'Go client did not finish'), 'ok'); + assert.equal(await client.ended, 0, 'Go client failed'); + await listener.close(); + console.log('TypeScript serves, Go calls: ok'); +} +process.exit(0); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 1f0753e..e7852de 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -32,7 +32,7 @@ const packOutput = JSON.parse(run(npm, ['pack', '--json', '--pack-destination', // npm 12 keys results by package name; Node's bundled npm returns a list. const packed = Array.isArray(packOutput) ? packOutput : Object.values(packOutput); const files = packed[0].files.map((file) => file.path); -for (const required of ['LICENSE', 'NOTICE', 'README.md', 'package.json', 'dist/core/ts/src/index.js', 'dist/websocket/ts/src/index.d.ts']) +for (const required of ['LICENSE', 'NOTICE', 'README.md', 'package.json', 'dist/core/ts/src/index.js', 'dist/websocket/ts/src/index.d.ts', 'dist/hydrated/ts/src/index.js', 'dist/hydrated/ts/src/index.d.ts']) assert.ok(files.includes(required), `the tarball lacks ${required}`); assert.ok(!files.some((file) => /\/test\//.test(file) || file.endsWith('.go')), 'the tarball carries tests or Go sources'); writeFileSync(join(temp, 'package.json'), '{"type":"module","private":true}\n'); @@ -42,6 +42,7 @@ import assert from 'node:assert/strict'; import {atom,tuple,pathEqual} from '@bitspark/bitwire'; import {pair,compose,addressed,bind,asAddressed} from '@bitspark/bitruntime/core'; import {connectWebSocket,listenWebSocket} from '@bitspark/bitruntime/websocket'; +import {Endpoint,Namespace,Scope,hydratedTuple,items} from '@bitspark/bitruntime/hydrated'; const message=tuple([atom([255,0])]); const [left,right]=pair();const local=new Promise(r=>right.receive(r));await left.send(message);assert.ok((await local).equals(message));await left.close(); const leaf=compose(7);assert.equal(compose(3,[[atom([]),leaf]]).at([atom([])]),leaf); @@ -53,8 +54,17 @@ const client=await connectWebSocket(server.url);const scoped=addressed(client); const remote=new Promise(r=>scoped.receive((path,value)=>r({path,value}))); await bind(scoped,[atom([]),atom([255])]).send(message);const result=await remote; assert.ok(pathEqual(result.path,[atom([]),atom([255])]));assert.ok(result.value.equals(message));await client.close();await server.close(); +{ + const [x,y]=pair(),ns=new Namespace('smoke'),ax=addressed(x),ay=addressed(y); + const sx=new Scope(ns,[atom([1])],ax),sy=new Scope(ns,[atom([2])],ay); + ax.receive((p,v)=>sx.deliver(p,v,'x'));ay.receive((p,v)=>sy.deliver(p,v,'y')); + const service=new Endpoint(4);service.receive(v=>{const [arg,reply]=items(v);void reply.send(arg);}); + const reply=new Endpoint(1);const back=new Promise(r=>reply.receive(r)); + await sx.connect(sy.expose(service)).send(hydratedTuple([message,reply])); + assert.ok((await back).equals(message));await reply.close();await service.close();await x.close(); +} for(const retired of ['engine','dispatch','transports'])await assert.rejects(import('@bitspark/bitruntime/'+retired),{code:'ERR_PACKAGE_PATH_NOT_EXPORTED'}); -console.log('Fresh installed package: local pair, tree and WebSocket passed.'); +console.log('Fresh installed package: local pair, tree, WebSocket and hydrated reply passed.'); `); run(process.execPath, ['smoke.mjs'], temp); const metadata = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); diff --git a/spikes/hydration/README.md b/spikes/hydration/README.md deleted file mode 100644 index 8d8c52f..0000000 --- a/spikes/hydration/README.md +++ /dev/null @@ -1,224 +0,0 @@ -# Recursive live-Wire hydration spike - -**Status, 7 October 2026:** disposable TypeScript experiment for -[issue 39](https://github.com/Bitspark/bitruntime/issues/39). This is evidence for -the contract owners, not a released API, a production bitnode router, or an -accepted protocol. The root package exports and ontos value definitions are -unchanged. Production work still follows the -[composition plan](../../docs/COMPOSITION.md). - -## Question and owner direction - -The owner requested a spike of this fiber path: a programmatic API has a -domain-specific adapter to a hydrated Wire, whose messages may recursively -contain live Wires. Shared machinery turns those messages into addressed ground -ontos data. The bitnode network routes that data, and the destination reverses -the transformations. Domain adapters must not allocate references, maintain -export tables, declare reference positions or implement hydration themselves. - -```text -Echo API Echo provider - | domain adapter | domain adapter -live Wire + recursive live values live Wire + live values - | shared hydration | shared hydration -addressed ground ontos ---- opaque middle router ---- addressed ground ontos - | addressless Endpoint | addressless Endpoint - +-------------- local pair or WebSocket --------------+ -``` - -There are two physical hops, caller to middle node and middle node to provider. -The same domain and hydration code runs for both carrier arrangements. -[The domain fixture](ts/src/echo.ts) imports only the live-value representation -and ground Atom constructors; it knows nothing about routing or references. -[Hydration](ts/src/hydration.ts) accepts an AddressedWire and incoming dispatch. -The [test composition](ts/test/harness.mjs) owns Endpoint reception, routing and -scope lifetime. Every hop uses the released addressed facade over a raw Endpoint. - -## What the experiment establishes - -The caller adapter places a reply Wire inside a nested tuple. The provider -receives a usable live proxy and replies through it, including another Wire -in its response. That continuation then carries a third Wire in the opposite -direction. All four traversals use the same hydration machinery. The middle -node forwards a destination and opaque ground value; it creates no reference -entries and inspects no application data. - -A locally exported Wire returned to its originating runtime hydrates to the -original object. Repeated occurrences hydrate to the same imported proxy. -Forwarding an imported proxy preserves its reference rather than registering -a proxy of that proxy. Its representation is send-only: it grants neither -Endpoint receiving nor carrier close ownership. - -The 14 observations in [the hydration tests](ts/test/hydration.test.mjs) cover this -recursive exchange over local pairs and actual loopback WebSockets, data that -resembles a reference, aliasing, exact empty/binary/slash-containing path atoms, -replacement at the same route, withdrawal, carrier-bound scope cleanup, failed -encoding/decoding and finite value, registry and active-work limits. The stale -reference test deliberately reuses an export ID at the same route in a new -scope; it observes refusal and no call to the replacement target. - -Local Windows validation passed all 16 hydration and composition observations -with Node 24.21.0 after type checking. CI repeats both suites on Linux and -Windows. Exact reviewed commits and CI runs belong in the pull request; this source-level account does not imply -that a later revision has passed. Existing repository CI remains required. - -## Composing generic API adapters - -The owner's follow-up asks whether an adapter for `API3` can be reused for -`API3` by composing it with the adapter for `T`. The necessary step is to -lift the entity adapters through API3's operations: - -```text -adapter3 = adapter3_ composed with lift_API3(adapterT) -``` - -[The Slot fixture](ts/src/slot.ts) tests a stronger case than a read-only list: -`Slot` both returns T from `read()` and accepts T in `write(T)`. A produced T -uses `T -> LiveWire`; an accepted T needs `LiveWire -> T`. Its `adaptSlot` lifts -both directions. `slotToWire` only knows `Slot`, and is composed with -the independent entity adapters by `slotOfToWire`. - -[Two composition observations](ts/test/composition.test.mjs) use a separate Text -API as T, through the same two-hop tree over both carriers. Reading returns a -remotely callable Text. Writing passes a caller-provided Text to the provider, -which can call it and return it again. Neither adapter knows the other's -operation protocol or the hydration machinery. - -This is evidence for composition preserving the API's observations, not a proof -for every programmatic type. A covariant API such as a read-only collection -needs only the forward mapping; mixed input/output APIs need both directions. -Generic type syntax alone does not supply those mappings. Object identity and -byte-for-byte encoding equality are not implied by behavioral equivalence. - -## Experimental choices, not new upstream laws - -`LiveValue` is an extension **above** ontos: Atom, Tuple, a recursive LiveTuple, -or a LiveWire. A Wire is never an ontos ground Value. `LiveWire` is a deliberately -small nominal class for this experiment, not a decision about public types or -an additional production Wire interface. A future contract could express the -relationship using parameterized types; that deserves an upstream decision. - -Every live value is encoded as a tagged data node. Atoms and tuples are tagged -as data too, so a data tuple cannot accidentally become a capability because -of its contents. The disposable grammar is: - -```text -message = ("hydration-spike/1", destinationScope, destinationID, liveData) -liveData = (0x00, atom) - | (0x01, (liveData ...)) - | (0x02, ((ownerPath ...), ownerScope, exportID)) -``` - -All fields are ground ontos values; byte tags are one-byte atoms. The outer -destination is passed separately to AddressedWire.send. The addressed facade -then supplies its already-released data framing. These experimental bytes are -not proposed canonical conformance vectors. - -The production owner's alternative, ground data plus an explicit table of -reference positions, can also separate data from capabilities. It avoids a tag -around each ordinary data node but needs rules for duplicate/overlapping -positions and placeholders. This spike chooses the fully tagged form to make -the separation easy to inspect, not to settle that encoding tradeoff. Neither -form requires a new kind in ontos or scanning ordinary payload tuples for -reference-shaped values. - -A reference names an owner at an absolute path in one admitted tree, its -random 16-byte incarnation scope, and a never-reused ID within that scope. -The owner retains the target. All holders can route to that owner without -intermediate export tables. This is a concrete counterexample to requiring -per-hop re-export for every transit node. It supports end-to-end hydration -within the chosen namespace, not transparent rewriting across different -namespaces or live re-rooting. A gateway between namespaces needs an explicit -composition; the experiment does not pick its contract. - -## Lifetime and admission meaning - -The composition owns the hydration scope and closes it with its carrying -attachment. Closing the scope clears its imports and exports and makes its -proxies unusable. The export owner can withdraw a target without closing that -target; a future export gets a fresh ID. Work already admitted into a local -target is allowed to finish. Closing a scope does not cancel a domain operation. - -Imports are interned and retained until scope close; there is no remote release -message. A holder cannot safely revoke other holders just because it discards -its own proxy. Likewise, a lost intermediate connection does not by itself -prove that every holder of an end-to-end reference vanished. This implementation -uses bounded retention and explicit owner withdrawal as the experiment's -policy. Distributed release/collection for long-lived scopes remains a concrete -production requirement. It must be derived from ownership and delegation, not -hidden in the domain adapter or guessed from JavaScript garbage collection. - -**Observed limit:** with the fixture's default limits, one long-lived caller -using one callback completes 63 Echo calls; call 64 rejects with `export limit`. -Both ends then retain 64 exports and 64 imports. Every completed call has left -its reply and continuation associations in those scopes. The adapters have no -reference bookkeeping with which to end a finished reply. The regression -observation records this limit explicitly: bounded retention demonstrates the -construction but is not adequate reclamation for a long-running service. -The production contract must derive a usable target lifetime and import policy -while retaining the domain adapter boundary. - -Encoding and decoding stage registry changes until the entire value validates. -Exports are registered before bytes are admitted so a fast reply can find them. -A carrier send failure leaves those exports retained until owner cleanup; there -is no assumption that the provider has or has not acted. Registry sizes and -active sends/receives are finite, and each value has node, depth and byte limits. -The test router's traces and diagnostics are test observations, not production -queues or evidence of aggregate deployment resource bounds. - -`send` resolving means local admission under the carrying Wire contract. A -remote stale-reference refusal is a host diagnostic, not an automatically -correlated response to that Promise. The Echo adapter chooses a result message -and its own fixture timeout. Invocation, outcomes, cancellation and streaming -still belong to domain conventions above the communication layer. - -## What should change in the owning concepts - -| Owner | Finding and proposed follow-up | -| --- | --- | -| bitwire | Specify the recursive live-value/Wire boundary and its relation to ground Wire/AddressedWire, including unambiguous data encoding and reference lifetime. A manual export table alone does not fulfill the owner's fiber layering. Keep raw/addressed separation, exact byte paths and ontos ground meanings intact. | -| bitruntime | Realize generic hydration together with registries, imported send-only proxies, bounds and cleanup. Carriers remain raw Endpoints; reuse the addressed facade. Production Go and TypeScript, independent upstream cases and interoperability remain necessary. | -| bitnode | Compose those runtime facilities with the selected tree namespace, attachments and admission. Opaque transit routing does not require domain decoding or re-export at every hop. Its concrete routing/binding realization still needs qualification. | -| Domain consumers | Adapt typed APIs to live messages and choose operation/reply semantics. A nested reply Wire replaces manually assigned reply-path/correlation machinery for this fixture. No registry or serialized-reference API is required in that adapter. | - -This revises the incomplete export-only construction, not deixis's tree laws. -Addressing routes a message; hydration carries a live send capability as data -and reconstructs a local proxy. Multiplexed Endpoints remain a separate facility -when consumers need independent receive/close lifetimes on shared carriers. -This example needs no logical Endpoint per exported Wire. - -The tree in this experiment is explicitly admitted test composition. Neither -knowledge of a path nor a random scope authenticates an actor or authorizes a -domain operation. Production attachment/authority integration stays with -bitnode and native archon/thesmos; this test does not introduce another identity -or permission model. It also does not establish browser support, network -partitions, cross-process/cross-language hydration or production throughput. - -## Reproduce and inspected inputs - -From `spikes/hydration/ts`, with Node 24: - -```sh -npm run fetch -npm ci --ignore-scripts -npm run check -npm test -``` - -The private package pins bitwire 0.5.0 and downloads the public bitruntime -0.6.0 release artifact, checking both its pinned SHA-256 and release checksum -list. It imports no sibling checkout source and is excluded from the runtime's -published package. The download helper was written for this experiment. - -- bitwire 0.5.0 source: `5bf2737b16fa6ae8c1f5c955cdba658d5adce364`. -- bitruntime 0.6.0 source: `b19458f6f741a0f78729e89ef099bfd2869900a2`; - artifact SHA-256 `da651aee6f14cc5a1116af55ab1bd575650a562ea6efc08f14dc840cff8d9c19`. -- bitruntime composition baseline: `0bc971fd1512457d0591ace825e2ffd9dd0573cd`. -- The production owner's connection-scoped implementation was inspected at - `a383393` on `lane/02-3b-export`. Its table/importer work is useful lower-level - evidence but lacks recursive hydration. No code from that implementation is - copied here and its pending protocol is not adopted. -- Owner clarification and scope coordination are preserved in platform-board - topic record `40a333f0-cac3-4346-9e43-4136b0fd91f0` and owner response - `b810d7a2-b875-464f-ac22-2565072652f9`. The independent assessment supplied by - the owner agrees with this intended boundary; it is not runtime evidence. diff --git a/spikes/hydration/ts/.npmrc b/spikes/hydration/ts/.npmrc deleted file mode 100644 index c5b81e0..0000000 --- a/spikes/hydration/ts/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@bitspark:registry=https://registry.npmjs.org diff --git a/spikes/hydration/ts/fetch-runtime.mjs b/spikes/hydration/ts/fetch-runtime.mjs deleted file mode 100644 index dae8d14..0000000 --- a/spikes/hydration/ts/fetch-runtime.mjs +++ /dev/null @@ -1,20 +0,0 @@ -// Deliberately installs the immutable released runtime, not this checkout. -import { createHash } from 'node:crypto'; -import { mkdir, writeFile } from 'node:fs/promises'; - -const name = 'bitspark-bitruntime-0.6.0.tgz'; -const hash = 'da651aee6f14cc5a1116af55ab1bd575650a562ea6efc08f14dc840cff8d9c19'; -const base = 'https://github.com/Bitspark/bitruntime/releases/download/v0.6.0'; -const response = await fetch(`${base}/${name}`); -if (!response.ok) throw new Error(`Runtime download failed: ${response.status}`); -const bytes = new Uint8Array(await response.arrayBuffer()); -if (createHash('sha256').update(bytes).digest('hex') !== hash) throw new Error('Runtime hash mismatch'); -const sums = await fetch(`${base}/SHA256SUMS`); -if (!sums.ok) throw new Error(`Release sums unavailable: ${sums.status}`); -if (!(await sums.text()).split('\n').some(line => line.trim().split(/\s+\*?/).join(' ') === `${hash} ${name}`)) { - throw new Error('Release does not attest pinned runtime'); -} -const directory = new URL('./artifacts/', import.meta.url); -await mkdir(directory, { recursive: true }); -await writeFile(new URL(name, directory), bytes); -console.log(`${name} sha256 ${hash}`); diff --git a/spikes/hydration/ts/package-lock.json b/spikes/hydration/ts/package-lock.json deleted file mode 100644 index 52952ed..0000000 --- a/spikes/hydration/ts/package-lock.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "bitruntime-hydration-spike", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bitruntime-hydration-spike", - "dependencies": { - "@bitspark/bitruntime": "file:artifacts/bitspark-bitruntime-0.6.0.tgz", - "@bitspark/bitwire": "0.5.0" - }, - "devDependencies": { - "typescript": "5.9.3" - }, - "engines": { - "node": ">=24" - } - }, - "node_modules/@bitspark/bitruntime": { - "version": "0.6.0", - "resolved": "file:artifacts/bitspark-bitruntime-0.6.0.tgz", - "integrity": "sha512-p/8KiswnLqCERIBVyjumR2pytDbszuQh1o0PFV+54T8tZtbN6XCtMprJhyNYB+H0aKO6kbnfXd2Zwl3tC4Xdaw==", - "license": "Apache-2.0", - "dependencies": { - "@bitspark/bitwire": "0.5.0", - "ws": "8.22.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@bitspark/bitwire": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.5.0.tgz", - "integrity": "sha512-4nFgd/WfGmWTqlor6xKBcerSMqUwupZbd1sso4mRXCnUz/5r+EGjENoxiT+uZ7EzyRUjZNMBi9XtngAnQGXRRg==", - "license": "Apache-2.0" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "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 - } - } - } - } -} diff --git a/spikes/hydration/ts/package.json b/spikes/hydration/ts/package.json deleted file mode 100644 index 5c4d6fc..0000000 --- a/spikes/hydration/ts/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "bitruntime-hydration-spike", - "private": true, - "type": "module", - "engines": { "node": ">=24" }, - "scripts": { - "fetch": "node fetch-runtime.mjs", - "check": "tsc --noEmit", - "build": "tsc", - "test": "npm run build && node --test --test-timeout=15000 test/*.test.mjs" - }, - "dependencies": { - "@bitspark/bitruntime": "file:artifacts/bitspark-bitruntime-0.6.0.tgz", - "@bitspark/bitwire": "0.5.0" - }, - "devDependencies": { - "typescript": "5.9.3" - } -} diff --git a/spikes/hydration/ts/src/echo.ts b/spikes/hydration/ts/src/echo.ts deleted file mode 100644 index 6184dd1..0000000 --- a/spikes/hydration/ts/src/echo.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Domain adapter: no hydration, reference, registry, carrier, or routing imports. -import { atom, Atom } from '@bitspark/bitwire'; -import { live, wire, items, LiveWire, type LiveValue } from './live.js'; - -const ECHO = atom(new TextEncoder().encode('echo')); -const expectWire = (v: LiveValue | undefined) => { - if (!(v instanceof LiveWire)) throw new TypeError('Expected reply wire'); - return v; -}; - -export interface EchoResult { value: Atom; continuation: LiveWire; returned: LiveWire } -export interface Echo { echo(value: Atom, callback: LiveWire): Promise } - -/** Tiny domain fixture whose response carries a further Wire. */ -export function provideEcho(): LiveWire { - return wire(async message => { - const [operation, argument, nested] = items(message); - if (!(operation instanceof Atom) || !operation.equals(ECHO) || !(argument instanceof Atom) || !nested) throw new Error('bad echo'); - const [replyValue, callbackValue] = items(nested); - const reply = expectWire(replyValue), callback = expectWire(callbackValue); - const continuation = wire(async next => { - const [value, returnedReply] = items(next); - if (!value) throw new Error('bad continuation'); - await expectWire(returnedReply).send(value); - }); - await reply.send(live(argument, live(continuation, callback))); - }); -} - -export function echoFromWire(target: LiveWire): Echo { - return { - echo(value, callback) { - // This timeout is a fixture/domain policy, never a generic Wire deadline. - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('echo outcome unknown')), 3000); - const reply = wire(message => { - try { - const [answer, nested] = items(message); - if (!(answer instanceof Atom) || !nested) throw new Error('bad response'); - const [continuation, returned] = items(nested); - resolve({ value: answer, continuation: expectWire(continuation), returned: expectWire(returned) }); - } catch (error) { reject(error); } - finally { clearTimeout(timer); } - }); - void target.send(live(ECHO, value, live(reply, callback))).catch(error => { - clearTimeout(timer); reject(error); - }); - }); - } - }; -} diff --git a/spikes/hydration/ts/src/hydration.ts b/spikes/hydration/ts/src/hydration.ts deleted file mode 100644 index ebf654a..0000000 --- a/spikes/hydration/ts/src/hydration.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { atom, tuple, Atom, Tuple, type Value, type Path, type AddressedWire } from '@bitspark/bitwire'; -import { LiveWire, LiveTuple, type LiveValue } from './live.js'; - -// Disposable experiment grammar. These are not bitwire protocol/version names. -const HEADER = atom(new TextEncoder().encode('hydration-spike/1')); -const DATA = atom([0]), SEQUENCE = atom([1]), REFERENCE = atom([2]); -const hex = (a: Atom) => Array.from(a.bytes(), b => b.toString(16).padStart(2, '0')).join(''); -export const pathKey = (path: Path): string => JSON.stringify(path.map(hex)); - -export interface Reference { readonly path: Path; readonly scope: Atom; readonly id: Atom } -interface Entry { readonly reference: Reference; readonly target: LiveWire } -interface Limits { exports: number; imports: number; work: number; nodes: number; bytes: number; depth: number } -const defaults: Limits = { exports: 64, imports: 64, work: 16, nodes: 4096, bytes: 1024 * 1024, depth: 64 }; -const key = (r: Reference) => `${pathKey(r.path)}:${hex(r.scope)}:${hex(r.id)}`; -const fail = (message: string): never => { throw new Error(message); }; -const asTuple = (v: Value | undefined, length?: number): Tuple => - v instanceof Tuple && (length === undefined || v.length === length) ? v : fail('malformed tuple'); -const asAtom = (v: Value | undefined): Atom => v instanceof Atom ? v : fail('malformed atom'); - -/** One owner at an admitted absolute tree location; no carrier or domain knowledge. */ -export class Hydration { - readonly path: Path; - readonly scope = atom(crypto.getRandomValues(new Uint8Array(16))); - readonly #sender: AddressedWire; - readonly #limits: Limits; - readonly #exports = new Map(); - readonly #byWire = new Map(); - readonly #imports = new Map(); - readonly #proxyRefs = new WeakMap(); - #next = 0n; - #sending = 0; - #receiving = 0; - #closed = false; - - constructor(path: Path, sender: AddressedWire, limits: Partial = {}) { - this.path = Object.freeze([...path]); - this.#sender = sender; - this.#limits = { ...defaults, ...limits }; - for (const n of Object.values(this.#limits)) { - if (!Number.isSafeInteger(n) || n < 1) throw new RangeError('positive finite limits required'); - } - if (path.length > this.#limits.depth || path.some(a => !(a instanceof Atom))) fail('invalid owner path'); - } - - get retained() { return { exports: this.#exports.size, imports: this.#imports.size }; } - #open(): void { if (this.#closed) fail('scope ended'); } - - #newReference(): Reference { - return Object.freeze({ path: this.path, scope: this.scope, id: atom(new TextEncoder().encode(String(++this.#next))) }); - } - - /** Composition bootstrap only. Domain adapters never call this. */ - expose(target: LiveWire): Reference { - this.#open(); - const known = this.#byWire.get(target); - if (known) return known; - if (this.#exports.size >= this.#limits.exports) fail('export limit'); - const ref = this.#newReference(); - this.#retain(ref, target); - return ref; - } - - #retain(reference: Reference, target: LiveWire): void { - this.#exports.set(hex(reference.id), { reference, target }); - this.#byWire.set(target, reference); - } - - /** Explicit owner withdrawal; never closes the borrowed target. */ - withdraw(target: LiveWire): void { - const ref = this.#byWire.get(target); - if (ref) this.#exports.delete(hex(ref.id)); - this.#byWire.delete(target); - } - - /** Composition bootstrap only; normal imports happen during decoding. */ - connect(ref: Reference): LiveWire { - this.#open(); - return this.#import(this.#readReference(this.#writeReference(ref)), new Map(), true); - } - - #import(ref: Reference, staged: Map, commit = false): LiveWire { - if (pathKey(ref.path) === pathKey(this.path)) { - if (!ref.scope.equals(this.scope)) return fail('stale scope'); - return this.#exports.get(hex(ref.id))?.target ?? fail('unknown export'); - } - const name = key(ref); - const known = this.#imports.get(name) ?? staged.get(name); - if (known) return known; - if (this.#imports.size + staged.size >= this.#limits.imports) fail('import limit'); - const proxy = new LiveWire(value => this.#send(ref, value)); - this.#proxyRefs.set(proxy, ref); - if (commit) this.#imports.set(name, proxy); else staged.set(name, proxy); - return proxy; - } - - #writeReference(ref: Reference): Value { return tuple([tuple(ref.path), ref.scope, ref.id]); } - #readReference(value: Value): Reference { - const fields = asTuple(value, 3); - const path = asTuple(fields.at(0)).items().map(asAtom); - const scope = asAtom(fields.at(1)), id = asAtom(fields.at(2)); - if (path.length > this.#limits.depth || scope.length !== 16 || id.length === 0 || id.length > 64) fail('invalid reference'); - return Object.freeze({ path: Object.freeze(path), scope, id }); - } - - #budget(): (depth: number, bytes?: number) => void { - let nodes = 0, total = 0; - return (depth, bytes = 0) => { - if (++nodes > this.#limits.nodes || depth > this.#limits.depth || (total += bytes) > this.#limits.bytes) fail('value limit'); - }; - } - - async #send(destination: Reference, value: LiveValue): Promise { - this.#open(); - if (this.#sending >= this.#limits.work) fail('send limit'); - this.#sending++; - try { - const staged = new Map(); - const budget = this.#budget(); - const encode = (v: LiveValue, depth: number): Value => { - budget(depth, v instanceof Atom ? v.length : 0); - if (v instanceof Atom) return tuple([DATA, v]); - if (v instanceof LiveWire) { - let ref = this.#proxyRefs.get(v) ?? this.#byWire.get(v) ?? staged.get(v); - if (!ref) { - if (this.#exports.size + staged.size >= this.#limits.exports) fail('export limit'); - ref = this.#newReference(); - staged.set(v, ref); - } - for (const a of [...ref.path, ref.scope, ref.id]) budget(depth + 1, a.length); - return tuple([REFERENCE, this.#writeReference(ref)]); - } - const children = v instanceof Tuple ? v.items() : v instanceof LiveTuple ? v.values : fail('not a live value'); - return tuple([SEQUENCE, tuple(children.map(child => encode(child, depth + 1)))]); - }; - const encoded = encode(value, 0); - this.#open(); - // Register before admitting bytes, so immediate replies find the targets. - for (const [target, ref] of staged) this.#retain(ref, target); - await this.#sender.send(destination.path, tuple([HEADER, destination.scope, destination.id, encoded])); - } finally { this.#sending--; } - } - - /** Dispatcher owns reception and reports rejection as a host diagnostic. */ - async deliver(path: Path, message: Value): Promise { - this.#open(); - if (pathKey(path) !== pathKey(this.path)) fail('wrong owner route'); - if (this.#receiving >= this.#limits.work) fail('receive limit'); - this.#receiving++; - try { - const frame = asTuple(message, 4); - if (!HEADER.equals(asAtom(frame.at(0)))) fail('not a hydration frame'); - if (!this.scope.equals(asAtom(frame.at(1)))) fail('stale scope'); - const entry = this.#exports.get(hex(asAtom(frame.at(2)))) ?? fail('unknown export'); - const staged = new Map(); - const budget = this.#budget(); - const decode = (encoded: Value, depth: number): LiveValue => { - const fields = asTuple(encoded, 2), tag = asAtom(fields.at(0)), value = fields.at(1)!; - if (DATA.equals(tag)) { const a = asAtom(value); budget(depth, a.length); return a; } - budget(depth); - if (REFERENCE.equals(tag)) { - const ref = this.#readReference(value); - for (const a of [...ref.path, ref.scope, ref.id]) budget(depth + 1, a.length); - return this.#import(ref, staged); - } - if (!SEQUENCE.equals(tag)) return fail('unknown live tag'); - const children = asTuple(value).items().map(child => decode(child, depth + 1)); - return children.every(v => v instanceof Atom || v instanceof Tuple) ? tuple(children) : new LiveTuple(children); - }; - const value = decode(frame.at(3)!, 0); - this.#open(); - for (const [name, proxy] of staged) this.#imports.set(name, proxy); - await entry.target.send(value); - } finally { this.#receiving--; } - } - - /** Ends the whole bounded association, not the carrier or any borrowed Wire. */ - close(): void { - this.#closed = true; - this.#exports.clear(); - this.#byWire.clear(); - this.#imports.clear(); - } -} diff --git a/spikes/hydration/ts/src/live.ts b/spikes/hydration/ts/src/live.ts deleted file mode 100644 index 0767ad9..0000000 --- a/spikes/hydration/ts/src/live.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Atom, Tuple } from '@bitspark/bitwire'; - -/** Experimental recursive extension above Ontos. Ground Value is unchanged. */ -export type LiveValue = Atom | Tuple | LiveTuple | LiveWire; - -export class LiveTuple { - readonly values: readonly LiveValue[]; - constructor(values: readonly LiveValue[]) { - this.values = Object.freeze([...values]); - Object.freeze(this); - } -} - -/** A send-only live capability. It grants no receive/close or registry access. */ -export class LiveWire { - readonly send: (value: LiveValue) => Promise; - constructor(send: (value: LiveValue) => void | Promise) { - this.send = async value => send(value); - Object.freeze(this); - } -} - -export const live = (...values: LiveValue[]): LiveTuple => new LiveTuple(values); -export const wire = (send: (value: LiveValue) => void | Promise): LiveWire => new LiveWire(send); - -export function items(value: LiveValue): readonly LiveValue[] { - if (value instanceof Tuple) return value.items(); - if (value instanceof LiveTuple) return value.values; - throw new TypeError('Expected tuple'); -} diff --git a/spikes/hydration/ts/src/slot.ts b/spikes/hydration/ts/src/slot.ts deleted file mode 100644 index c1ca7ba..0000000 --- a/spikes/hydration/ts/src/slot.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Consumer fixture: an invariant generic API, with T in both input and output. -// No export table, reference grammar, routing or carrier appears here. -import { atom, Atom } from '@bitspark/bitwire'; -import { live, wire, items, LiveWire, type LiveValue } from './live.js'; - -export interface Slot { read(): Promise; write(value: T): Promise } -export interface Adapters { to(value: A): B; from(value: B): A } - -/** Lift both directions through Slot. A covariant map alone cannot do this. */ -export function adaptSlot(source: Slot, adapters: Adapters): Slot { - return { - read: async () => adapters.to(await source.read()), - write: value => source.write(adapters.from(value)), - }; -} - -const READ = atom([0]), WRITE = atom([1]), OK = atom([2]); -function requireWire(value: LiveValue | undefined): LiveWire { - if (!(value instanceof LiveWire)) throw new Error('expected live Wire'); - return value; -} - -/** adapter3_: Slot -> LiveWire; knows only Slot's operation meaning. */ -export function slotToWire(source: Slot): LiveWire { - return wire(async message => { - const [op, response, value] = items(message), reply = requireWire(response); - if (!(op instanceof Atom)) throw new Error('invalid Slot operation'); - if (op.equals(READ)) await reply.send(await source.read()); - else if (op.equals(WRITE)) { await source.write(requireWire(value)); await reply.send(OK); } - else throw new Error('unknown Slot operation'); - }); -} - -/** Domain-local exchange policy for the fixture, not a shared Wire RPC rule. */ -async function exchange(target: LiveWire, op: Atom, value?: LiveWire): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Slot outcome unknown')), 3000); - const reply = wire(result => { clearTimeout(timer); resolve(result); }); - void target.send(value ? live(op, reply, value) : live(op, reply)).catch(error => { - clearTimeout(timer); reject(error); - }); - }); -} - -export function slotFromWire(target: LiveWire): Slot { - return { - read: async () => requireWire(await exchange(target, READ)), - write: async value => { - const result = await exchange(target, WRITE, value); - if (!(result instanceof Atom) || !result.equals(OK)) throw new Error('invalid Slot response'); - }, - }; -} - -/** adapter3 = adapter3_ composed with the lifting of the entity adapters. */ -export function slotOfToWire(source: Slot, adapters: Adapters): LiveWire { - return slotToWire(adaptSlot(source, adapters)); -} - -export function slotOfFromWire(target: LiveWire, adapters: Adapters): Slot { - return adaptSlot(slotFromWire(target), { to: adapters.from, from: adapters.to }); -} diff --git a/spikes/hydration/ts/test/composition.test.mjs b/spikes/hydration/ts/test/composition.test.mjs deleted file mode 100644 index aee261f..0000000 --- a/spikes/hydration/ts/test/composition.test.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { Atom } from '@bitspark/bitwire'; -import { wire, LiveWire } from '../dist/live.js'; -import { slotOfToWire, slotOfFromWire } from '../dist/slot.js'; -import { tree, bytes } from './harness.mjs'; - -// Leaf API: Text = { read(): Promise }. These adapters know only Text. -function textToWire(source) { - return wire(async reply => { - assert.ok(reply instanceof LiveWire); - await reply.send(await source.read()); - }); -} -function textFromWire(target) { - return { - read: () => new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Text outcome unknown')), 3000); - const reply = wire(value => { - clearTimeout(timer); - if (value instanceof Atom) resolve(value); else reject(new Error('invalid Text value')); - }); - void target.send(reply).catch(error => { clearTimeout(timer); reject(error); }); - }), - }; -} -const textAdapters = { to: textToWire, from: textFromWire }; - -for (const carrier of ['pair', 'websocket']) { - test(`${carrier}: Slot composes Slot and Text adapters in both directions`, async t => { - const network = await tree(t, carrier); - let selected = { read: async () => bytes('provider Text') }; - const source = { - read: async () => selected, - write: async value => { selected = value; }, - }; - const service = slotOfToWire(source, textAdapters); - const ref = network.right.expose(service); // composition bootstrap only - const remote = slotOfFromWire(network.left.connect(ref), textAdapters); - - // Covariant occurrence: read returns a Text adapter over the nested Wire. - assert.ok((await (await remote.read()).read()).equals(await (await source.read()).read())); - - // Contravariant occurrence: write imports a client-provided Text at the far end. - const clientText = { read: async () => bytes('caller Text') }; - await remote.write(clientText); - assert.ok((await (await source.read()).read()).equals(await clientText.read())); - assert.ok((await (await remote.read()).read()).equals(await clientText.read())); - assert.deepEqual(network.faults, []); - }); -} diff --git a/spikes/hydration/ts/test/harness.mjs b/spikes/hydration/ts/test/harness.mjs deleted file mode 100644 index ce5820e..0000000 --- a/spikes/hydration/ts/test/harness.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import { pair, addressed } from '@bitspark/bitruntime/core'; -import { listenWebSocket, connectWebSocket } from '@bitspark/bitruntime/websocket'; -import { atom, isValue } from '@bitspark/bitwire'; -import { Hydration, pathKey } from '../dist/hydration.js'; - -export const bytes = text => atom(new TextEncoder().encode(text)); -export const leftPath = [atom([]), atom([0, 255, 47])]; -export const rightPath = [bytes('branch/inside-one-atom'), atom([255, 0])]; - -async function link(t, carrier) { - if (carrier === 'pair') { - const endpoints = pair(); - t.after(() => endpoints[0].close()); - return endpoints; - } - const accepted = Promise.withResolvers(); - const listener = await listenWebSocket({ host: '127.0.0.1' }, accepted.resolve); - t.after(() => listener.close()); - const client = await connectWebSocket(listener.url); - t.after(() => client.close()); - const server = await accepted.promise; - t.after(() => server.close()); - return [client, server]; -} - -// This middle node knows only routes and ground values. It does not decode the -// payload, instantiate hydration, or hold per-Wire/per-invocation state. -function forward(path, value, routes, trace, faults) { - assert.ok(isValue(value)); - trace.push({ path, value }); - const destination = routes.get(pathKey(path)); - if (!destination) { faults.push(new Error('missing route')); return; } - void destination.send(path, value).catch(error => faults.push(error)); -} - -export async function tree(t, carrier) { - const [a, middleA] = (await link(t, carrier)).map(addressed); - const [c, middleC] = (await link(t, carrier)).map(addressed); - const trace = [], faults = []; - const routes = new Map([[pathKey(leftPath), middleA], [pathKey(rightPath), middleC]]); - middleA.receive((p, v) => forward(p, v, routes, trace, faults)); - middleC.receive((p, v) => forward(p, v, routes, trace, faults)); - const left = new Hydration(leftPath, a); - let right = new Hydration(rightPath, c); - a.receive((p, v) => { void left.deliver(p, v).catch(error => faults.push(error)); }); - c.receive((p, v) => { void right.deliver(p, v).catch(error => faults.push(error)); }); - // Scope ownership belongs to composition, not domain adapters or borrowed Wires. - void a.closed.then(() => left.close()); - void c.closed.then(() => right.close()); - t.after(() => { left.close(); right.close(); }); - return { - left, get right() { return right; }, trace, faults, - replaceRight() { right.close(); right = new Hydration(rightPath, c); return right; }, - closeCarrier: () => a.close(), - }; -} - -export async function until(predicate) { - const deadline = Date.now() + 3000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error('observation did not arrive'); - await new Promise(resolve => setTimeout(resolve, 5)); - } -} diff --git a/spikes/hydration/ts/test/hydration.test.mjs b/spikes/hydration/ts/test/hydration.test.mjs deleted file mode 100644 index a002db3..0000000 --- a/spikes/hydration/ts/test/hydration.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { atom, tuple, Tuple, isValue } from '@bitspark/bitwire'; -import { Hydration } from '../dist/hydration.js'; -import { live, wire, items, LiveWire } from '../dist/live.js'; -import { provideEcho, echoFromWire } from '../dist/echo.js'; -import { tree, bytes, leftPath, rightPath, until } from './harness.mjs'; - -for (const carrier of ['pair', 'websocket']) { - test(`${carrier}: nested reply and continuation Wires cross an opaque router`, async t => { - const network = await tree(t, carrier); - const callback = wire(() => {}); - const service = network.right.expose(provideEcho()); // composition only - const api = echoFromWire(network.left.connect(service)); - const request = atom([0, 255, 10, 47]); - const result = await api.echo(request, callback); - assert.ok(result.value.equals(request)); - assert.equal(result.returned, callback, 'returning home restores the original object'); - assert.ok(result.continuation instanceof LiveWire); - assert.deepEqual(Object.keys(result.continuation), ['send']); - assert.equal('close' in result.continuation, false); - assert.equal('receive' in result.continuation, false); - assert.equal(isValue(result.continuation), false); - let delivered; - await result.continuation.send(live(bytes('third Wire'), wire(v => { delivered = v; }))); - await until(() => delivered !== undefined); - assert.ok(delivered.equals(bytes('third Wire'))); - assert.equal(network.trace.length, 4); - assert.ok(network.trace.every(entry => isValue(entry.value))); - assert.deepEqual(network.trace.map(entry => entry.path), [rightPath, leftPath, rightPath, leftPath]); - assert.deepEqual(network.faults, []); - assert.equal(network.right.retained.exports, 2, 'forwarded proxies are not re-exported'); - }); - - test(`${carrier}: reference-shaped data stays data; aliases stay aliases`, async t => { - const network = await tree(t, carrier); - let observed; - const destination = network.right.expose(wire(v => { observed = v; })); - const target = network.left.connect(destination); - const local = wire(() => {}); - const ref = network.left.expose(local); - const lookalike = tuple([bytes('hydration-spike/1'), tuple(ref.path), ref.scope, ref.id]); - const taggedLookalike = tuple([atom([2]), tuple([tuple(ref.path), ref.scope, ref.id])]); - await target.send(live(lookalike, taggedLookalike, local, live(local))); - await until(() => observed !== undefined); - const [ordinary, tagged, first, nested] = items(observed); - assert.ok(ordinary instanceof Tuple && ordinary.equals(lookalike)); - assert.ok(tagged instanceof Tuple && tagged.equals(taggedLookalike)); - assert.equal(first, items(nested)[0]); - assert.equal(network.right.retained.imports, 1); - assert.equal(network.left.retained.exports, 1); - assert.deepEqual(network.faults, []); - }); - - test(`${carrier}: stale scope cannot reach replacement at the same byte path`, async t => { - const network = await tree(t, carrier); - let oldCalls = 0, newCalls = 0; - const oldTarget = wire(() => { oldCalls++; }); - const oldReference = network.right.expose(oldTarget); - const oldProxy = network.left.connect(oldReference); - const replacement = network.replaceRight(); - const newReference = replacement.expose(wire(() => { newCalls++; })); - assert.ok(oldReference.id.equals(newReference.id), 'IDs may repeat in a different incarnation'); - assert.ok(!oldReference.scope.equals(newReference.scope)); - await oldProxy.send(bytes('late')); // local admission, not remote execution - await until(() => network.faults.length === 1); - assert.match(network.faults[0].message, /stale scope/); - assert.equal(newCalls, 0); - await network.left.connect(newReference).send(bytes('fresh')); - await until(() => newCalls === 1); - await oldTarget.send(bytes('still borrowed')); - assert.equal(oldCalls, 1, 'ending export scope does not close its target'); - }); - - test(`${carrier}: owner withdrawal and carrier loss end associations, not borrowed targets`, async t => { - const network = await tree(t, carrier); - let calls = 0; - const original = wire(() => { calls++; }); - const ref = network.right.expose(original), proxy = network.left.connect(ref); - network.right.withdraw(original); - const next = network.right.expose(original); - assert.ok(!ref.id.equals(next.id), 'withdrawn IDs are never reused in the scope'); - await proxy.send(bytes('retired')); - await until(() => network.faults.length === 1); - assert.match(network.faults[0].message, /unknown export/); - assert.equal(calls, 0); - await network.closeCarrier(); - await assert.rejects(proxy.send(bytes('closed')), /scope ended/); - assert.deepEqual(network.left.retained, { imports: 0, exports: 0 }); - await original.send(bytes('owned elsewhere')); - assert.equal(calls, 1); - }); -} - -const reference = () => ({ path: rightPath, scope: atom(new Uint8Array(16)), id: bytes('1') }); - -test('long-lived Echo stops at call 64: scope retention is not production reclamation', async t => { - const network = await tree(t, 'pair'); - const callback = wire(() => {}); - const api = echoFromWire(network.left.connect(network.right.expose(provideEcho()))); - for (let i = 0; i < 63; i++) await api.echo(bytes('retained until scope close'), callback); - await assert.rejects(api.echo(bytes('64th call'), callback), /export limit/); - assert.deepEqual(network.left.retained, { exports: 64, imports: 64 }); - assert.deepEqual(network.right.retained, { exports: 64, imports: 64 }); - assert.deepEqual(network.faults, []); -}); - -test('failed encoding is atomic; registry limits apply to nested and distinct Wires', async () => { - const sent = []; - const host = new Hydration(leftPath, { send: async (p, v) => { sent.push([p, v]); } }, { exports: 1 }); - const proxy = host.connect(reference()); - const a = wire(() => {}), b = wire(() => {}); - await assert.rejects(proxy.send(live(a, live(b))), /export limit/); - assert.deepEqual(host.retained, { exports: 0, imports: 1 }); - assert.equal(sent.length, 0); - await proxy.send(live(a, a)); - assert.equal(host.retained.exports, 1); - assert.equal(sent.length, 1); - host.close(); -}); - -test('value and pending-send bounds reject before adding unbounded work', async () => { - const pending = Promise.withResolvers(); - const host = new Hydration(leftPath, { send: () => pending.promise }, { work: 1, nodes: 8, bytes: 40, depth: 3 }); - const proxy = host.connect(reference()); - await assert.rejects(proxy.send(atom(new Uint8Array(41))), /value limit/); - const deep = live(live(live(live(bytes('deep'))))); - await assert.rejects(proxy.send(deep), /value limit/); - const first = proxy.send(bytes('one')); - await assert.rejects(proxy.send(bytes('two')), /send limit/); - pending.resolve(); - await first; - host.close(); -}); - -test('malformed trailing data rolls back newly staged imports', async () => { - let calls = 0; - const host = new Hydration(leftPath, { send: async () => {} }); - const destination = host.expose(wire(() => { calls++; })); - const ref = reference(); - const encodedRef = tuple([atom([2]), tuple([tuple(ref.path), ref.scope, ref.id])]); - const invalid = tuple([atom([99]), tuple([])]); - const message = tuple([bytes('hydration-spike/1'), destination.scope, destination.id, - tuple([atom([1]), tuple([encodedRef, invalid])])]); - await assert.rejects(host.deliver(leftPath, message), /unknown live tag/); - assert.equal(host.retained.imports, 0); - assert.equal(calls, 0); - host.close(); -}); - -test('import and in-flight receiver limits are bounded independently of carrier', async () => { - const pending = Promise.withResolvers(); - const host = new Hydration(leftPath, { send: async () => {} }, { imports: 1, work: 1 }); - const destination = host.expose(wire(() => pending.promise)); - const ref = reference(); - host.connect(ref); - assert.throws(() => host.connect({ ...ref, id: bytes('2') }), /import limit/); - const message = tuple([bytes('hydration-spike/1'), destination.scope, destination.id, - tuple([atom([0]), bytes('work')])]); - const admitted = host.deliver(leftPath, message); - await assert.rejects(host.deliver(leftPath, message), /receive limit/); - host.close(); - pending.resolve(); - await admitted; // already admitted work is not canceled by scope close - assert.deepEqual(host.retained, { exports: 0, imports: 0 }); -}); - -test('transport rejection retains admitted exports until owner cleanup', async () => { - const host = new Hydration(leftPath, { send: async () => { throw new Error('carrier ended'); } }); - const proxy = host.connect(reference()); - await assert.rejects(proxy.send(wire(() => {})), /carrier ended/); - assert.equal(host.retained.exports, 1); - host.close(); - assert.equal(host.retained.exports, 0); -}); diff --git a/spikes/hydration/ts/tsconfig.json b/spikes/hydration/ts/tsconfig.json deleted file mode 100644 index d320167..0000000 --- a/spikes/hydration/ts/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "noUncheckedIndexedAccess": true, - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} diff --git a/tsconfig.json b/tsconfig.json index 2ee6f53..fd26c2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ }, "include": [ "core/ts/src/**/*.ts", - "websocket/ts/src/**/*.ts" + "websocket/ts/src/**/*.ts", + "hydrated/ts/src/**/*.ts" ] }