From 59ca3945bce2365295047195ecba4eac34e5e546 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:09:45 +0200 Subject: [PATCH 1/7] conformance: run Bitwire's independent cases against bitruntime's Go candidate Add conformance/runtime/go, a test-only module requiring the public bitruntime pseudo-version v0.1.1-0.20260926094813-e3237a7b79b8 and Bitwire v0.3.0 with no replacement. Its drivers port the existing ones to bitruntime: lifecycle through core.Invocation, the six composition groups (from Nightseam v0.6.0's upstream driver), the 39 declared cases through the test-only reference and through bitruntime's child-only core.Mount on local pairs and WebSockets in both directions, and the 0.3 tree observations through core.Compose/Select/Send/AsAddressed. scripts/conformance-runtime.mjs verifies the pinned module graph, revisions and sums, withholds expectations, compares exactly and reports the contract revision, case-file SHA-256s, bitruntime version and carriers. bitruntime keeps its own production gap ledger; its observations equal Nightseam's. A separate ubuntu CI job runs it under the race detector. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 13 + NOTICE | 7 + conformance/runtime/bitruntime.json | 12 + conformance/runtime/go/composition/main.go | 357 ++++++++ conformance/runtime/go/declared/main.go | 837 ++++++++++++++++++ conformance/runtime/go/go.mod | 13 + conformance/runtime/go/go.sum | 8 + .../runtime/go/internal/carrier/carrier.go | 80 ++ conformance/runtime/go/lifecycle/main.go | 113 +++ conformance/runtime/go/trees/main.go | 94 ++ conformance/runtime/production-gaps.json | 71 ++ scripts/conformance-results.test.mjs | 10 + scripts/conformance-runtime.mjs | 151 ++++ 13 files changed, 1766 insertions(+) create mode 100644 conformance/runtime/bitruntime.json create mode 100644 conformance/runtime/go/composition/main.go create mode 100644 conformance/runtime/go/declared/main.go create mode 100644 conformance/runtime/go/go.mod create mode 100644 conformance/runtime/go/go.sum create mode 100644 conformance/runtime/go/internal/carrier/carrier.go create mode 100644 conformance/runtime/go/lifecycle/main.go create mode 100644 conformance/runtime/go/trees/main.go create mode 100644 conformance/runtime/production-gaps.json create mode 100644 scripts/conformance-runtime.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2688498..9332cc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,19 @@ jobs: - run: node scripts/conformance-current.mjs - run: node scripts/conformance-production.mjs + runtime-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: node scripts/conformance-runtime.mjs + packages: runs-on: ubuntu-latest steps: diff --git a/NOTICE b/NOTICE index 9774412..7f963d2 100644 --- a/NOTICE +++ b/NOTICE @@ -14,3 +14,10 @@ from Nightseam's native Wire surfaces at revision 1c63f1c4d7e4b5987d4bd32e294177645c92ed8f. Their package NOTICE files identify the source files and extraction changes. The Haskell presentation is derived from the common Bitwire specification. + +The test-only bitruntime composition driver in +conformance/runtime/go/composition/main.go is adapted from Nightseam's +conformance/bitwire/go/main.go at v0.6.0, revision +5cc9723a24646c40ed1861f892b2b23eb6d785d7, licensed under the Apache License, +Version 2.0: Copyright 2026 Bitspark and the Nightseam contributors. Each +Nightseam facility is replaced by the bitruntime facility its port records. diff --git a/conformance/runtime/bitruntime.json b/conformance/runtime/bitruntime.json new file mode 100644 index 0000000..b51bd2e --- /dev/null +++ b/conformance/runtime/bitruntime.json @@ -0,0 +1,12 @@ +{ + "repository": "https://github.com/Bitspark/bitruntime", + "module": "github.com/Bitspark/bitruntime", + "version": "v0.1.1-0.20260926094813-e3237a7b79b8", + "revision": "e3237a7b79b881d2280111a0933f386db87174ac", + "sum": "h1:Akqj+hbgd+ZMsP7C8thtL971DminLgyyLcEkYjKzso8=", + "status": "unreleased-source", + "profile": "bitwire/1", + "bitwireVersion": "v0.3.0", + "bitwireRevision": "f825f3f4a79135646b775e25dcd770656546b4a1", + "bitwireSum": "h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA=" +} diff --git a/conformance/runtime/go/composition/main.go b/conformance/runtime/go/composition/main.go new file mode 100644 index 0000000..48395b7 --- /dev/null +++ b/conformance/runtime/go/composition/main.go @@ -0,0 +1,357 @@ +// Exercises bitruntime's carriers against Bitwire's six composition +// observation groups (../../../reference/expected.json). A port of Nightseam's +// upstream driver at v0.6.0 (5cc9723a, conformance/bitwire/go/main.go; Apache-2.0, +// Bitspark), with each Nightseam facility replaced by the bitruntime facility +// its port records. It contains no replacement Wire implementation and no +// expected results; Bitwire's runner compares the observations. +package main + +import ( + "encoding/json" + "os" + "reflect" + "runtime" + "strings" + "time" + + "bitwire.conformance/runtime/internal/carrier" + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + transports "github.com/Bitspark/bitruntime/transports/go" + bitwire "github.com/Bitspark/bitwire/wire/go" +) + +func check(err error) { + if err != nil { + panic(err) + } +} + +var cleanups []func() + +func pair() (bitwire.Endpoint, bitwire.Endpoint) { + return carrier.Pair(func(release func()) { cleanups = append(cleanups, release) }) +} +func reply(message bitwire.Message, value any) { + if message.Frame.Kind != bitwire.ProfileRequest || message.Return == nil { + panic("expected a request with return access") + } + encoded, err := json.Marshal(value) + check(err) + check(message.Return.Wire.Send(nil, bitwire.Message{Frame: bitwire.ProfileFrame{ + Version: 1, Kind: bitwire.ProfileResponse, ID: message.Frame.ID, Result: encoded, + }})) +} +func closeWire(wire interface { + Close(bitwire.Code, string) error +}) { + check(wire.Close(transports.CodeNormal, "conformance complete")) +} +func event() bitwire.Message { + return bitwire.Message{Frame: bitwire.ProfileFrame{Version: 1, Kind: bitwire.ProfileEvent, Data: json.RawMessage("null")}} +} +func copyPath(path []string) []string { return append([]string{}, path...) } + +// Every operation is delegated. The spies observe the boundary before/after +// composition, without implementing routing, dispatch or return correlation. +type observer struct { + bitwire.Endpoint + send func([]string, bitwire.Message) + receive func([]string, bitwire.Message) +} + +func (w observer) Send(path []string, message bitwire.Message) error { + if w.send != nil { + w.send(path, message) + } + return w.Endpoint.Send(path, message) +} +func (w observer) Receive(receiver bitwire.Receiver) (func(), error) { + return w.Endpoint.Receive(bitwire.Receiver{Closed: receiver.Closed, + Message: func(path []string, message bitwire.Message) { + if w.receive != nil { + w.receive(path, message) + } + if receiver.Message != nil { + receiver.Message(path, message) + } + }, + }) +} + +// These helpers provide only deadlines and observations. The production pair, +// peer, dispatcher, selected endpoints, mount and forwarder do all delivery. +func wait[T any](ch <-chan T) T { + select { + case v := <-ch: + return v + case <-time.After(5 * time.Second): + panic("delivery deadline exceeded") + } +} +func attach(e bitwire.Endpoint, r bitwire.Receiver) func() { + off, err := e.Receive(r) + check(err) + return off +} +func dispatcher(e bitwire.Endpoint) *dispatch.Dispatcher { + d, err := dispatch.NewDispatcher(e) + check(err) + cleanups = append(cleanups, func() { closeWire(d) }) + return d +} +func send(w bitwire.AddressedWire, path []string, done <-chan struct{}) { + check(w.Send(path, event())) + wait(done) +} +func goroutine() string { + var data [128]byte + n := runtime.Stack(data[:], false) + return strings.Fields(string(data[:n]))[1] +} +func siblings() any { + client, server := pair() + router := dispatcher(server) + deliveries := []string{} + done := make(chan struct{}, 4) + sender := goroutine() + receiverRanDuringSend := false + receiver := func(name string) bitwire.Receiver { + return bitwire.Receiver{Message: func(path []string, _ bitwire.Message) { + receiverRanDuringSend = receiverRanDuringSend || goroutine() == sender + deliveries = append(deliveries, name+":"+strings.Join(path, "/")) + done <- struct{}{} + }} + } + detachA := attach(router.Select([]string{"a"}), receiver("a")) + attach(router.Select([]string{"b"}), receiver("b")) + _, err := server.Receive(bitwire.Receiver{}) + duplicateEndpointAttachmentRefused := err != nil + send(core.At(client, []string{"a"}), []string{"run"}, done) + send(core.At(client, []string{"b"}), []string{"run"}, done) + detachA() + detachA() + check(core.At(client, []string{"a"}).Send([]string{"ignored"}, event())) + send(core.At(client, []string{"b"}), []string{"after-detach"}, done) + closeWire(router) + attachmentReusableAfterDetach := false + replacement := attach(server, bitwire.Receiver{Message: func([]string, bitwire.Message) { attachmentReusableAfterDetach = true; done <- struct{}{} }}) + closeWire(router) + send(client, []string{"replacement"}, done) + replacement() + return map[string]any{"deliveries": deliveries, "receiverRanDuringSend": receiverRanDuringSend, "duplicateEndpointAttachmentRefused": duplicateEndpointAttachmentRefused, "attachmentReusableAfterDetach": attachmentReusableAfterDetach} +} +func overlap() any { + client, server := pair() + router := dispatcher(server) + deliveries := []string{} + done := make(chan struct{}, 2) + attach(router.Select([]string{"a"}), bitwire.Receiver{Message: func(path []string, _ bitwire.Message) { + deliveries = append(deliveries, "parent:"+strings.Join(path, "/")) + done <- struct{}{} + }}) + detach := attach(router.Select([]string{"a", "b"}), bitwire.Receiver{Message: func(path []string, _ bitwire.Message) { + deliveries = append(deliveries, "deep:"+strings.Join(path, "/")) + done <- struct{}{} + }}) + _, err := router.Select([]string{"a"}).Receive(bitwire.Receiver{}) + duplicateRouteRefused := err != nil + send(client, []string{"a", "b", "run"}, done) + detach() + send(client, []string{"a", "b", "run"}, done) + return map[string]any{"deliveries": deliveries, "duplicateRouteRefused": duplicateRouteRefused} +} +func composition() any { + caller, inbound := pair() + outbound, destination := pair() + var entered, arrived bitwire.Message + framePreserved, returnIdentityPreserved, associatedContextPreserved := true, true, false + marker := new(int) + associated := map[*bitwire.ReturnAddress]*int{} + left := observer{Endpoint: inbound, receive: func(_ []string, m bitwire.Message) { entered = m }} + right := observer{Endpoint: outbound, send: func(_ []string, m bitwire.Message) { + framePreserved = framePreserved && reflect.DeepEqual(m.Frame, entered.Frame) + returnIdentityPreserved = returnIdentityPreserved && m.Return != nil && m.Return == entered.Return + }} + detach, err := core.Forward(left, right) + check(err) + defer detach() + router := dispatcher(observer{Endpoint: destination, receive: func(_ []string, m bitwire.Message) { arrived = m; associated[m.Return] = marker }}) + view := router.Select([]string{"a"}).Select([]string{"b"}) + captured := make(chan bitwire.Message, 1) + deliveredPath := []string{} + stop := attach(view, bitwire.Receiver{Message: func(path []string, m bitwire.Message) { + deliveredPath = copyPath(path) + framePreserved = framePreserved && reflect.DeepEqual(m.Frame, arrived.Frame) + returnIdentityPreserved = returnIdentityPreserved && m.Return != nil && m.Return == arrived.Return + associatedContextPreserved = m.Return != nil && associated[m.Return] == marker + captured <- m + }}) + result := make(chan any, 1) + original := bitwire.Message{Frame: bitwire.ProfileFrame{Version: 1, Kind: bitwire.ProfileRequest, ID: "c:1", Params: json.RawMessage(`{"nested":[null,42,"value"]}`), Traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", Tracestate: "vendor=opaque", Meta: map[string]string{"key": "value"}}, Return: &bitwire.ReturnAddress{Wire: replySink(func(_ []string, m bitwire.Message) error { + if m.Frame.Kind != bitwire.ProfileResponse || m.Frame.Error != nil { + panic("expected successful reply") + } + var value any + check(json.Unmarshal(m.Frame.Result, &value)) + result <- value + return nil + })}} + callerRouter := dispatcher(observer{Endpoint: caller, send: func(_ []string, m bitwire.Message) { + framePreserved = framePreserved && reflect.DeepEqual(m.Frame, original.Frame) + returnIdentityPreserved = returnIdentityPreserved && m.Return == original.Return + }}) + mounted := core.Mount(map[string]bitwire.Endpoint{"": callerRouter.Select([]string{"a"}).Select([]string{"b"})}) + defer closeWire(mounted) + composed := core.At(mounted, []string{""}) + if _, ok := composed.(bitwire.Endpoint); ok { + panic("selected access grants ownership") + } + check(composed.Send([]string{"run", ""}, original)) + request := wait(captured) + detach() + detach() + stop() + closeWire(view) + closeWire(mounted) + closeWire(callerRouter) + reply(request, "answer") + answer := wait(result) + done := make(chan struct{}, 2) + sourceStillUsable, targetStillUsable := false, false + stopSource := attach(inbound, bitwire.Receiver{Message: func([]string, bitwire.Message) { sourceStillUsable = true; done <- struct{}{} }}) + send(caller, []string{"probe"}, done) + stopSource() + attach(router.Select([]string{"probe"}), bitwire.Receiver{Message: func([]string, bitwire.Message) { targetStillUsable = true; done <- struct{}{} }}) + send(outbound, []string{"probe"}, done) + return map[string]any{"deliveredPath": deliveredPath, "framePreserved": framePreserved, "returnIdentityPreserved": returnIdentityPreserved, "associatedContextPreserved": associatedContextPreserved, "reply": answer, "borrowedEndpointUsableAfterDetach": sourceStillUsable && targetStillUsable} +} +func opaquePaths() any { + client, server := pair() + router := dispatcher(server) + result := []string{} + for _, entry := range []struct { + path []string + name string + }{{[]string{""}, "empty"}, {[]string{"a/b"}, "slash"}, {[]string{"a", "b"}, "split"}, {[]string{"é"}, "composed"}, {[]string{"e\u0301"}, "decomposed"}} { + done := make(chan struct{}, 1) + attach(router.Select(entry.path), bitwire.Receiver{Message: func([]string, bitwire.Message) { result = append(result, entry.name); done <- struct{}{} }}) + send(client, entry.path, done) + } + return result +} +func selectedEndpoints() any { + client, root := pair() + router := dispatcher(root) + a := router.Select([]string{"scope"}).Select([]string{"a"}) + b := router.Select([]string{"scope", "b"}) + detached := router.Select([]string{"detached"}) + notifications := map[string]int{"active": 0, "detached": 0, "sibling": 0} + nestedPath, selectedSendPath := []string{}, []string{} + siblingAfterViewClose, routeReusable := false, false + initial := attach(a, bitwire.Receiver{Message: func([]string, bitwire.Message) { panic("detached receiver ran") }}) + _, err := a.Receive(bitwire.Receiver{}) + duplicateReceiveRefused := err != nil + initial() + initial() + done := make(chan struct{}, 4) + rootClosed := make(chan struct{}, 1) + attach(a, bitwire.Receiver{Message: func(path []string, _ bitwire.Message) { nestedPath = copyPath(path); done <- struct{}{} }, Closed: func(bitwire.Code, string) { notifications["active"]++ }}) + initial() + attach(b, bitwire.Receiver{Message: func([]string, bitwire.Message) { siblingAfterViewClose = true; done <- struct{}{} }, Closed: func(bitwire.Code, string) { notifications["sibling"]++; rootClosed <- struct{}{} }}) + unused := attach(detached, bitwire.Receiver{Closed: func(bitwire.Code, string) { notifications["detached"]++ }}) + unused() + closeWire(detached) + closeWire(detached) + attach(client, bitwire.Receiver{Message: func(path []string, _ bitwire.Message) { selectedSendPath = copyPath(path); done <- struct{}{} }}) + send(client, []string{"scope", "a", "in"}, done) + send(a, []string{"out"}, done) + closeWire(a) + closeWire(a) + _, err = a.Receive(bitwire.Receiver{}) + closedReceiveRefused := err != nil + closedSendRefused := a.Send([]string{"ignored"}, event()) != nil + stop := attach(router.Select([]string{"scope", "a"}), bitwire.Receiver{Message: func([]string, bitwire.Message) { routeReusable = true; done <- struct{}{} }}) + send(client, []string{"scope", "a", "replacement"}, done) + send(client, []string{"scope", "b", "sibling"}, done) + stop() + closeWire(root) + closeWire(root) + wait(rootClosed) + _, err = root.Receive(bitwire.Receiver{}) + rootReceiveRefused := err != nil + _, err = b.Receive(bitwire.Receiver{}) + viewAfterRootCloseRefused := err != nil + closeWire(b) + return map[string]any{"nestedPath": nestedPath, "selectedSendPath": selectedSendPath, "duplicateReceiveRefused": duplicateReceiveRefused, "notifications": notifications, "closedReceiveRefused": closedReceiveRefused, "closedSendRefused": closedSendRefused, "rootReceiveRefused": rootReceiveRefused, "viewAfterRootCloseRefused": viewAfterRootCloseRefused, "siblingAfterViewClose": siblingAfterViewClose, "routeReusable": routeReusable} +} + +type replySink func([]string, bitwire.Message) error + +func (s replySink) Send(path []string, m bitwire.Message) error { return s(path, m) } + +// The requests use "c:1", not the oracle's "same-id" placeholder: bitwire/1 +// (Nightseam v0.6.0's profile, which bitruntime ports) admits only role-prefixed +// decimal request identifiers. Bitwire's runner instantiates the placeholder. +func sameIDDelayedReplies() any { + client, root := pair() + var admitted *bitwire.ReturnAddress + router := dispatcher(observer{Endpoint: root, receive: func(_ []string, m bitwire.Message) { admitted = m.Return }}) + view := router.Select([]string{"service"}) + requests := []bitwire.Message{} + capturedReturnIdentities := []bool{} + done := make(chan struct{}, 2) + stop := attach(view, bitwire.Receiver{Message: func(_ []string, m bitwire.Message) { + requests = append(requests, m) + capturedReturnIdentities = append(capturedReturnIdentities, m.Return != nil && m.Return == admitted) + done <- struct{}{} + }}) + lateReplies, replyIDs := []string{}, []string{} + replied := make(chan struct{}, 2) + for _, entry := range []struct{ name, payload string }{{"left", "first"}, {"right", "second"}} { + sink := replySink(func(_ []string, m bitwire.Message) error { + if m.Frame.Kind != bitwire.ProfileResponse { + panic("expected reply") + } + var result string + check(json.Unmarshal(m.Frame.Result, &result)) + lateReplies = append(lateReplies, entry.name+":"+result) + replyIDs = append(replyIDs, m.Frame.ID) + replied <- struct{}{} + return nil + }) + payload, err := json.Marshal(entry.payload) + check(err) + check(core.At(client, []string{"service"}).Send([]string{"call"}, bitwire.Message{Frame: bitwire.ProfileFrame{Version: 1, Kind: bitwire.ProfileRequest, ID: "c:1", Params: payload}, Return: &bitwire.ReturnAddress{Wire: sink}})) + wait(done) + } + stop() + closeWire(view) + replacementDeliveries := []string{} + attach(router.Select([]string{"service"}), bitwire.Receiver{Message: func(_ []string, m bitwire.Message) { + if m.Frame.Kind != bitwire.ProfileEvent { + panic("old request reached replacement") + } + var value string + check(json.Unmarshal(m.Frame.Data, &value)) + replacementDeliveries = append(replacementDeliveries, value) + done <- struct{}{} + }}) + check(client.Send([]string{"service", "probe"}, bitwire.Message{Frame: bitwire.ProfileFrame{Version: 1, Kind: bitwire.ProfileEvent, Data: json.RawMessage(`"probe"`)}})) + wait(done) + for i := len(requests) - 1; i >= 0; i-- { + reply(requests[i], requests[i].Frame.Params) + wait(replied) + } + return map[string]any{"capturedReturnIdentities": capturedReturnIdentities, "lateReplies": lateReplies, "replyIDs": replyIDs, "replacementDeliveries": replacementDeliveries} +} +func main() { + defer func() { + for i := len(cleanups) - 1; i >= 0; i-- { + cleanups[i]() + } + }() + carrier.Kind() + observations := map[string]any{"siblings": siblings(), "overlap": overlap(), "composition": composition(), "opaquePaths": opaquePaths(), "selectedEndpoints": selectedEndpoints(), "sameIDDelayedReplies": sameIDDelayedReplies()} + check(json.NewEncoder(os.Stdout).Encode(observations)) +} diff --git a/conformance/runtime/go/declared/main.go b/conformance/runtime/go/declared/main.go new file mode 100644 index 0000000..996d27c --- /dev/null +++ b/conformance/runtime/go/declared/main.go @@ -0,0 +1,837 @@ +// Declared-composite driver for ADR0006 against bitruntime: a port of +// ../../../current/go/declared/main.go, which runs this harness over Nightseam +// v0.6.0. Two realizations share one harness: a test-only reference +// interpreter and bitruntime's child-only addressed Mount. Both use +// bitruntime's At, Forward, local pairs and WebSocket peers. Neither is a +// Bitwire API, and the reference is not evidence about a production runtime. +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "sort" + "time" + "unicode/utf8" + + "bitwire.conformance/runtime/internal/carrier" + core "github.com/Bitspark/bitruntime/core/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// origin is a node's own value: behavior at its empty relative path. It never +// receives a path. Refusal is a value too, not a missing origin. +type origin struct { + name string + instance int + handle func(wire.Message) error +} + +var refuse = &origin{handle: func(wire.Message) error { return core.ErrMissingPath }} + +type entry struct { + key string + child wire.AddressedWire +} + +// A realization constructs declared composites from their own value and +// complete child access. Parts are the construction owner's retained description. +type realization interface { + compose(own *origin, entries []entry) (wire.AddressedWire, error) + parts(composite wire.AddressedWire) (*origin, []entry, bool) + expose(composite wire.AddressedWire) wire.AddressedWire + teardown() +} + +var errUnsupported = errors.New("origin-bearing composite") + +// ---- Reference realization: a test-only interpreter of ADR0006 ---- + +type composite struct { + own *origin + children map[string]wire.AddressedWire +} +type sendAccess struct { + send func([]string, wire.Message) error +} + +func (a *sendAccess) Send(p []string, m wire.Message) error { return a.send(p, m) } + +type reference struct { + retained map[wire.AddressedWire]*composite +} + +func (r *reference) compose(own *origin, entries []entry) (wire.AddressedWire, error) { + if own == nil { + return nil, errors.New("a composite requires its own value") + } + c := &composite{own, make(map[string]wire.AddressedWire, len(entries))} + for _, e := range entries { + if !utf8.ValidString(e.key) { + return nil, errors.New("segment outside the exact UTF-8 key image") + } + if e.child == nil { + return nil, errors.New("missing child access") + } + if _, found := c.children[e.key]; found { + return nil, errors.New("conflicting child segment") + } + c.children[e.key] = e.child + } + access := &sendAccess{func(p []string, m wire.Message) error { + for _, segment := range p { + if !utf8.ValidString(segment) { + return core.ErrInvalidPath + } + } + return c.send(p, m) + }} + r.retained[access] = c + return access, nil +} +func (c *composite) send(p []string, m wire.Message) error { + if len(p) == 0 { + return c.own.handle(m) + } + child, found := c.children[p[0]] + if !found { + return core.ErrMissingPath + } + return child.Send(append([]string{}, p[1:]...), m) +} +func (r *reference) parts(w wire.AddressedWire) (*origin, []entry, bool) { + c := r.retained[w] + if c == nil { + return nil, nil, false + } + entries := []entry{} + for key, child := range c.children { + entries = append(entries, entry{key, child}) + } + return c.own, entries, true +} +func (r *reference) expose(w wire.AddressedWire) wire.AddressedWire { return w } +func (r *reference) teardown() {} + +// ---- Production realization: bitruntime's child-only addressed Mount ---- + +type production struct { + retained map[wire.AddressedWire][]entry + mounts []wire.Endpoint +} + +// sendOnlyChild adapts complete send access to Mount's Endpoint parameter. It +// grants no receive attachment and owns nothing to close. +type sendOnlyChild struct{ wire.AddressedWire } + +func (sendOnlyChild) Receive(wire.Receiver) (func(), error) { + return nil, errors.New("send-only child") +} +func (sendOnlyChild) Close(wire.Code, string) error { return nil } + +func (r *production) compose(own *origin, entries []entry) (wire.AddressedWire, error) { + if own != refuse { + return nil, errUnsupported + } + routes := make(map[string]wire.Endpoint, len(entries)) + for _, e := range entries { + switch child := e.child.(type) { + case nil: + routes[e.key] = nil + case wire.Endpoint: + routes[e.key] = child + default: + routes[e.key] = sendOnlyChild{child} + } + } + mounted := core.Mount(routes) + for key := range routes { + delete(routes, key) // A retained description must not depend on the caller's map. + } + r.retained[mounted] = append([]entry{}, entries...) + r.mounts = append(r.mounts, mounted) + return mounted, nil +} +func (r *production) parts(w wire.AddressedWire) (*origin, []entry, bool) { + entries, found := r.retained[w] + if !found { + return nil, nil, false + } + return refuse, append([]entry{}, entries...), true +} +func (r *production) expose(w wire.AddressedWire) wire.AddressedWire { return core.At(w, nil) } +func (r *production) teardown() { + for _, mounted := range r.mounts { + check(mounted.Close(transports.CodeNormal, "released")) + } +} + +// ---- Instrumented child access and interception used by the fixtures ---- + +type forwarded struct { + path []string + count int +} +type env struct { + sender wire.AddressedWire + expected *wire.Message + contexts map[*wire.ReturnAddress]*struct{} + marker *struct{} + unchanged bool + last *forwarded + trace []any + instances map[string]int +} + +func (e *env) verify(m wire.Message) { + e.unchanged = e.unchanged && e.expected != nil && reflect.DeepEqual(m.Frame, e.expected.Frame) && + m.Return == e.expected.Return && m.Return != nil && e.contexts[m.Return] == e.marker +} +func (e *env) next(name string) int { + e.instances[name]++ + return e.instances[name] +} +func (e *env) newOrigin(name string) *origin { + o := &origin{name: name, instance: e.next(name)} + count := 0 + o.handle = func(m wire.Message) error { + e.verify(m) + count++ + e.last = &forwarded{[]string{name}, count} + return core.At(e.sender, []string{name}).Send(nil, m) + } + return o +} + +// access is complete, stateful child access with its own instance counter. +type access struct { + name string + instance int + count int + env *env +} + +func (e *env) newAccess(name string) *access { return &access{name, e.next(name), 0, e} } +func (a *access) Send(p []string, m wire.Message) error { + a.env.verify(m) + a.count++ + a.env.last = &forwarded{append([]string{a.name}, p...), a.count} + return core.At(a.env.sender, []string{a.name}).Send(p, m) +} + +type policy struct { + id string + instance, limit, remaining int +} + +// guard is interception composed around access; it is not a node value. +type guard struct { + policy *policy + inner wire.AddressedWire + env *env +} + +func (g *guard) Send(p []string, m wire.Message) error { + g.env.trace = append(g.env.trace, []any{"check", g.policy.id, append([]string{}, p...)}) + if g.policy.remaining == 0 { + return errors.New("guard refused") + } + if g.policy.remaining > 0 { + g.policy.remaining-- + } + return g.inner.Send(p, m) +} + +// ---- Fixture interpretation ---- + +type declaration struct { + ID string + Origin *string + Children [][2]string + Access string +} +type step struct { + Op string + Target []string `json:"path"` + Keep [][]string + Selections [][]string + Via, Key string + To, Node string + Mode, ID string + Origin *string + Limit int +} + +type testCase struct { + ID, Kind, Root, Fault string + Steps []step + Relay, Mount bool +} +type input struct { + Declarations []declaration + Cases []testCase +} + +type harness struct { + R realization + env *env + decls map[string]declaration + built map[string]wire.AddressedWire + origins map[string]*origin + root, view wire.AddressedWire + partsExact bool +} + +func sameEntries(got, want []entry) bool { + if len(got) != len(want) { + return false + } + index := map[string]wire.AddressedWire{} + for _, e := range want { + index[e.key] = e.child + } + for _, e := range got { + child, found := index[e.key] + if !found || child != e.child { + return false + } + delete(index, e.key) + } + return len(index) == 0 +} + +// construct records R1 for every composite and mutates the caller's input +// afterward: the composite must retain its own copy of the description. +func (h *harness) construct(own *origin, entries []entry) (wire.AddressedWire, error) { + input := append([]entry{}, entries...) + w, err := h.R.compose(own, input) + if err != nil { + return nil, err + } + for i := range input { + input[i] = entry{"mutated", nil} + } + o, got, ok := h.R.parts(w) + exact := ok && o == own && sameEntries(got, entries) + if exact && len(got) > 0 { + got[0].child = nil // Returned parts are a copy of the retained description. + o, got, ok = h.R.parts(w) + exact = ok && o == own && sameEntries(got, entries) + } + h.partsExact = h.partsExact && exact + return w, nil +} +func (h *harness) compose(own *origin, entries []entry) wire.AddressedWire { + w, err := h.construct(own, entries) + check(err) + return w +} +func (h *harness) originNamed(name string) *origin { + if o := h.origins[name]; o != nil { + return o + } + o := h.env.newOrigin(name) + h.origins[name] = o + return o +} +func (h *harness) build(id string, fault string, rootID string, visiting map[string]bool) (wire.AddressedWire, error) { + if w := h.built[id]; w != nil { + return w, nil + } + d, found := h.decls[id] + if !found { + return nil, errors.New("missing declaration") + } + if d.Access != "" { + w := h.env.newAccess(d.Access) + h.built[id] = w + return w, nil + } + if visiting[id] { + return nil, errors.New("cyclic declaration") + } + visiting[id] = true + children := d.Children + if id == rootID && fault == "cycle" { + children = append(append([][2]string{}, children...), [2]string{"loop", rootID}) + } + entries := []entry{} + for _, c := range children { + child, err := h.build(c[1], fault, rootID, visiting) + if err != nil { + return nil, err + } + entries = append(entries, entry{c[0], child}) + } + if id == rootID { + switch fault { + case "duplicate": + leaf, err := h.build("leaf", "", rootID, visiting) + check(err) + entries = append(entries, entry{"a", leaf}) + case "invalidKey": + leaf, err := h.build("leaf", "", rootID, visiting) + check(err) + entries = append(entries, entry{string([]byte{0xff}), leaf}) + case "missingChild": + entries = append(entries, entry{"hole", nil}) + } + } + own := refuse + if d.Origin != nil { + own = h.originNamed(*d.Origin) + } + w, err := h.construct(own, entries) + if err != nil { + return nil, err + } + delete(visiting, id) + h.built[id] = w + return w, nil +} + +func (h *harness) caller() wire.AddressedWire { + if g, ok := h.root.(*guard); ok { + return g + } + return h.R.expose(h.root) +} +func renderOrigin(o *origin) any { + if o == refuse { + return nil + } + return []any{o.name, o.instance} +} +func (h *harness) render(w wire.AddressedWire) any { + if own, entries, ok := h.R.parts(w); ok { + sort.Slice(entries, func(i, j int) bool { return bytes.Compare([]byte(entries[i].key), []byte(entries[j].key)) < 0 }) + children := []any{} + for _, e := range entries { + children = append(children, []any{e.key, h.render(e.child)}) + } + return map[string]any{"origin": renderOrigin(own), "children": children} + } + switch v := w.(type) { + case *access: + return map[string]any{"access": []any{v.name, v.instance}} + case *guard: + return map[string]any{"guard": []any{v.policy.id, v.policy.instance}, "inner": h.render(v.inner)} + } + return "opaque" +} +func (h *harness) structure(w wire.AddressedWire, path []string) any { + if len(path) == 0 { + return h.render(w) + } + _, entries, ok := h.R.parts(w) + if !ok { + panic("structure path leaves the declared composites") + } + for _, e := range entries { + if e.key == path[0] { + return h.structure(e.child, path[1:]) + } + } + return "missing" +} + +// replaceAt rebuilds the declared ancestors of path from their retained parts. +// A guard retains its original policy instance around a rebuilt inner access. +func (h *harness) replaceAt(w wire.AddressedWire, path []string, f func(wire.AddressedWire) wire.AddressedWire) wire.AddressedWire { + if g, ok := w.(*guard); ok && len(path) > 0 { + return &guard{g.policy, h.replaceAt(g.inner, path, f), h.env} + } + if len(path) == 0 { + return f(w) + } + own, entries, ok := h.R.parts(w) + if !ok { + panic("edit path leaves the declared composites") + } + for i := range entries { + if entries[i].key == path[0] { + entries[i].child = h.replaceAt(entries[i].child, path[1:], f) + } + } + return h.compose(own, entries) +} +func (h *harness) rootComposite(f func(wire.AddressedWire) wire.AddressedWire) { + if g, ok := h.root.(*guard); ok { + h.root = &guard{g.policy, f(g.inner), h.env} + return + } + h.root = f(h.root) +} +func containsPath(paths [][]string, path []string) bool { + for _, p := range paths { + if reflect.DeepEqual(append([]string{}, p...), append([]string{}, path...)) { + return true + } + } + return false +} + +// rebuild performs one complete cut: kept subtrees are reused whole, and every +// other declared composite is rebuilt from its retained parts. +func (h *harness) rebuild(w wire.AddressedWire, at []string, keep [][]string) wire.AddressedWire { + if containsPath(keep, at) { + return w + } + if g, ok := w.(*guard); ok { + return &guard{g.policy, h.rebuild(g.inner, at, keep), h.env} + } + own, entries, ok := h.R.parts(w) + if !ok { + return w // Opaque child access is retained whole. + } + for i := range entries { + entries[i].child = h.rebuild(entries[i].child, append(append([]string{}, at...), entries[i].key), keep) + } + return h.compose(own, entries) +} +func (h *harness) copySubtree(w wire.AddressedWire) wire.AddressedWire { + if a, ok := w.(*access); ok { + return h.env.newAccess(a.name) + } + own, entries, ok := h.R.parts(w) + if !ok { + panic("cannot copy opaque access") + } + if own != refuse { + own = h.env.newOrigin(own.name) + } + for i := range entries { + entries[i].child = h.copySubtree(entries[i].child) + } + return h.compose(own, entries) +} + +func check(err error) { + if err != nil { + panic(err) + } +} +func wait[T any](ch <-chan T) T { + select { + case v := <-ch: + return v + case <-time.After(5 * time.Second): + panic("delivery deadline exceeded") + } +} + +type scope struct{ cleanups []func() } + +func (s *scope) close() { + for i := len(s.cleanups) - 1; i >= 0; i-- { + s.cleanups[i]() + } +} +func (s *scope) pair() (wire.Endpoint, wire.Endpoint) { + return carrier.Pair(func(release func()) { s.cleanups = append(s.cleanups, release) }) +} +func event(value string) wire.Message { + data, err := json.Marshal(value) + check(err) + return wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: data}} +} + +type delivery struct { + path []string + message wire.Message +} + +var refuseWire wire.AddressedWire = &sendAccess{func([]string, wire.Message) error { return core.ErrMissingPath }} + +func (h *harness) send(w wire.AddressedWire, path []string, message wire.Message, deliveries <-chan delivery) { + h.env.expected, h.env.last = &message, nil + if err := w.Send(path, message); err != nil { + if h.env.last != nil { + panic("a destination accepted a refused send") + } + h.env.trace = append(h.env.trace, []any{"refused"}) + return + } + got := wait(deliveries) + if !bytes.Equal(got.message.Frame.Data, message.Frame.Data) || got.message.Frame.Kind != message.Frame.Kind { + panic("message changed or unexpected delivery") + } + if h.env.last == nil || !reflect.DeepEqual(got.path, h.env.last.path) { + panic("delivery does not match its declared destination") + } + h.env.trace = append(h.env.trace, []any{"delivered", got.path, h.env.last.count}) +} +func (h *harness) marked(value string) wire.Message { + m := event(value) + m.Return = &wire.ReturnAddress{Wire: refuseWire} + h.env.contexts[m.Return] = h.env.marker + return m +} +func borrowed(sender wire.AddressedWire, deliveries <-chan delivery) bool { + if sender.Send([]string{"borrowed"}, event("borrowed")) != nil { + return false + } + got := wait(deliveries) + return reflect.DeepEqual(got.path, []string{"borrowed"}) && string(got.message.Frame.Data) == `"borrowed"` +} + +func newHarness(R realization, fixture input, sender wire.AddressedWire) *harness { + h := &harness{R: R, decls: map[string]declaration{}, built: map[string]wire.AddressedWire{}, origins: map[string]*origin{}, partsExact: true} + h.env = &env{sender: sender, contexts: map[*wire.ReturnAddress]*struct{}{}, marker: &struct{}{}, unchanged: true, trace: []any{}, instances: map[string]int{}} + for _, d := range fixture.Declarations { + if _, found := h.decls[d.ID]; found { + panic("duplicate declaration") + } + h.decls[d.ID] = d + } + return h +} +func refusal(err error) any { + if errors.Is(err, errUnsupported) { + return map[string]any{"unsupported": "originBearingComposite"} + } + return map[string]any{"construction": "refused"} +} +func (h *harness) node(id string) wire.AddressedWire { + w, err := h.build(id, "", "", map[string]bool{}) + check(err) + return w +} + +func (h *harness) apply(t testCase, index int, s step, deliveries <-chan delivery) { + switch s.Op { + case "send", "invalidPath": + w := h.caller() + if s.Via == "view" { + if h.view == nil { + panic("no captured view") + } + w = h.view + } + for _, prefix := range s.Selections { + w = core.At(w, prefix) + } + path := s.Target + if s.Op == "invalidPath" { + path = []string{string([]byte{0xff})} + } + h.send(w, path, h.marked(fmt.Sprintf("%s:%d", t.ID, index)), deliveries) + case "direct": + h.send(h.node(s.Node), s.Target, h.marked(fmt.Sprintf("%s:%d", t.ID, index)), deliveries) + case "structure": + root := h.root + if g, ok := root.(*guard); ok && len(s.Target) > 0 { + root = g.inner + } + h.env.trace = append(h.env.trace, []any{"structure", h.structure(root, s.Target)}) + case "rebuild": + h.root = h.rebuild(h.root, []string{}, s.Keep) + case "origin": + h.root = h.replaceAt(h.root, s.Target, func(w wire.AddressedWire) wire.AddressedWire { + own, entries, ok := h.R.parts(w) + if !ok { + panic("origin of opaque access") + } + if s.Origin == nil { + own = refuse + } else { + own = h.env.newOrigin(own.name) + } + return h.compose(own, entries) + }) + case "substitute": + h.root = h.replaceAt(h.root, s.Target, func(w wire.AddressedWire) wire.AddressedWire { + if s.Mode == "copy" { + return h.copySubtree(w) + } + return h.rebuild(w, []string{}, nil) + }) + case "omit", "rename", "add": + h.root = h.replaceAt(h.root, s.Target, func(w wire.AddressedWire) wire.AddressedWire { + own, entries, ok := h.R.parts(w) + if !ok { + panic("edit of opaque access") + } + next := []entry{} + for _, e := range entries { + if e.key == s.Key && s.Op == "omit" { + continue + } + if e.key == s.Key && s.Op == "rename" { + e.key = s.To + } + next = append(next, e) + } + if s.Op == "add" { + next = append(next, entry{s.Key, h.node(s.Node)}) + } + return h.compose(own, next) + }) + case "replace": + h.root = h.replaceAt(h.root, s.Target, func(wire.AddressedWire) wire.AddressedWire { return h.node(s.Node) }) + case "view": + h.view = h.caller() + for _, prefix := range s.Selections { + h.view = core.At(h.view, prefix) + } + case "guard": + h.root = &guard{&policy{s.ID, h.env.next("policy:" + s.ID), s.Limit, s.Limit}, h.root, h.env} + case "freshGuard": + g := h.root.(*guard) + h.root = &guard{&policy{g.policy.id, h.env.next("policy:" + g.policy.id), g.policy.limit, g.policy.limit}, g.inner, h.env} + case "guardChild": + h.root = h.replaceAt(h.root, append(append([]string{}, s.Target...), s.Key), func(w wire.AddressedWire) wire.AddressedWire { + return &guard{&policy{s.ID, h.env.next("policy:" + s.ID), s.Limit, s.Limit}, w, h.env} + }) + case "rebuildFromViews": + g := h.root.(*guard) + own, entries, ok := h.R.parts(g.inner) + if !ok { + panic("guarded access is not a composite") + } + for i := range entries { + entries[i].child = core.At(g, []string{entries[i].key}) + } + h.root = &guard{g.policy, h.compose(own, entries), h.env} + case "teardown": + h.R.teardown() + default: + panic("unknown step: " + s.Op) + } +} + +func (s *scope) carriers(t testCase) (wire.Endpoint, wire.AddressedWire, wire.Endpoint) { + source, receiver := s.pair() + var sender wire.AddressedWire = source + if t.Mount { + mounted := core.Mount(map[string]wire.Endpoint{"mounted": source}) + sender = core.At(mounted, []string{"mounted"}) + s.cleanups = append(s.cleanups, func() { _ = mounted.Close(transports.CodeNormal, "done") }) + } + if t.Relay { + outgoing, target := s.pair() + off, err := core.Forward(receiver, outgoing) + check(err) + s.cleanups = append(s.cleanups, off) + receiver = target + } + return source, sender, receiver +} + +func observe(R realization, fixture input, t testCase) any { + s := &scope{} + defer s.close() + source, sender, receiver := s.carriers(t) + deliveries := make(chan delivery, 64) + off, err := receiver.Receive(wire.Receiver{Message: func(p []string, m wire.Message) { + deliveries <- delivery{append([]string{}, p...), m} + }}) + check(err) + s.cleanups = append(s.cleanups, off) + h := newHarness(R, fixture, sender) + root, err := h.build(t.Root, t.Fault, t.Root, map[string]bool{}) + if err != nil { + return refusal(err) + } + h.root = root + for index, action := range t.Steps { + h.apply(t, index, action, deliveries) + } + _, endpoint := h.caller().(wire.Endpoint) + h.R.teardown() + return map[string]any{"trace": h.env.trace, "partsExact": h.partsExact, "unchanged": h.env.unchanged, "sendOnly": !endpoint, "borrowedUsable": borrowed(source, deliveries)} +} + +// pending admits a real request through the composite, then rebuilds, rebinds +// and tears the composition down before its late reply and captured cancel. +func pending(R realization, fixture input, t testCase) any { + s := &scope{} + defer s.close() + source, sender, receiver := s.carriers(t) + captured := make(chan wire.Message, 1) + cancelled := make(chan string, 1) + deliveries := make(chan delivery, 4) + replies := make(chan string, 1) + off, err := receiver.Receive(wire.Receiver{Message: func(p []string, m wire.Message) { + if m.Frame.Kind == wire.ProfileEvent { + deliveries <- delivery{append([]string{}, p...), m} + return + } + lifecycle := m.Return.Wire + check(lifecycle.Send([]string{"invocation.capture", "old"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: json.RawMessage("null")}, Return: &wire.ReturnAddress{Wire: &sendAccess{func(p []string, m wire.Message) error { + if len(p) != 0 || m.Frame.Kind != wire.ProfileCancel { + panic("invalid captured control") + } + cancelled <- "old" + return nil + }}}})) + check(lifecycle.Send([]string{"invocation.ready", "old"}, event("ready"))) + check(lifecycle.Send([]string{"invocation.begin", "old"}, event("begin"))) + captured <- m + }}) + check(err) + s.cleanups = append(s.cleanups, off) + h := newHarness(R, fixture, sender) + root, err := h.build(t.Root, "", t.Root, map[string]bool{}) + if err != nil { + return refusal(err) + } + h.root = root + original := &wire.ReturnAddress{Wire: &sendAccess{func(p []string, m wire.Message) error { + if len(p) != 0 || m.Frame.Kind != wire.ProfileResponse || m.Frame.Error != nil { + panic("unexpected reply") + } + var result string + check(json.Unmarshal(m.Frame.Result, &result)) + replies <- result + return nil + }}} + request := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "c:1", Params: json.RawMessage("null")}, Return: original} + h.env.contexts[original] = h.env.marker + h.env.expected = &request + check(core.At(core.At(h.caller(), []string{"a"}), []string{"b"}).Send(nil, request)) + old := wait(captured) + h.root = h.rebuild(h.root, []string{}, nil) + for _, path := range [][]string{{"a", "b"}, {"alias"}} { + h.apply(t, 0, step{Op: "replace", Target: path, Node: "replacement"}, deliveries) + } + h.send(core.At(h.caller(), []string{"alias"}), nil, h.marked("new"), deliveries) + h.R.teardown() + check(old.Return.Wire.Send(nil, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: old.Frame.ID, Result: json.RawMessage(`"old"`)}})) + late := wait(replies) + check(old.Return.Wire.Send([]string{"invocation.control"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: old.Frame.ID}})) + controls := []string{wait(cancelled)} + check(old.Return.Wire.Send([]string{"invocation.release", "old"}, event("release"))) + check(old.Return.Wire.Send([]string{"invocation.done", "old"}, event("done"))) + return map[string]any{"trace": h.env.trace, "lateReply": late, "cancelled": controls, "unchanged": h.env.unchanged, "borrowedUsable": borrowed(source, deliveries)} +} + +func main() { + carrier.Kind() + if len(os.Args) != 3 || (os.Args[1] != "reference" && os.Args[1] != "production") { + panic("usage: declared reference|production inputs.json") + } + data, err := os.ReadFile(os.Args[2]) + check(err) + var fixture input + check(json.Unmarshal(data, &fixture)) + output := []any{} + for _, t := range fixture.Cases { + var R realization = &reference{map[wire.AddressedWire]*composite{}} + if os.Args[1] == "production" { + R = &production{retained: map[wire.AddressedWire][]entry{}} + } + var observations any + if t.Kind == "pending" { + observations = pending(R, fixture, t) + } else { + observations = observe(R, fixture, t) + } + output = append(output, map[string]any{"id": t.ID, "observations": observations}) + } + check(json.NewEncoder(os.Stdout).Encode(output)) +} diff --git a/conformance/runtime/go/go.mod b/conformance/runtime/go/go.mod new file mode 100644 index 0000000..c7b9b2c --- /dev/null +++ b/conformance/runtime/go/go.mod @@ -0,0 +1,13 @@ +module bitwire.conformance/runtime + +go 1.26.0 + +require ( + github.com/Bitspark/bitruntime v0.1.1-0.20260926094813-e3237a7b79b8 + github.com/Bitspark/bitwire v0.3.0 +) + +require ( + github.com/coder/websocket v1.8.15 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect +) diff --git a/conformance/runtime/go/go.sum b/conformance/runtime/go/go.sum new file mode 100644 index 0000000..6067b11 --- /dev/null +++ b/conformance/runtime/go/go.sum @@ -0,0 +1,8 @@ +github.com/Bitspark/bitruntime v0.1.1-0.20260926094813-e3237a7b79b8 h1:Akqj+hbgd+ZMsP7C8thtL971DminLgyyLcEkYjKzso8= +github.com/Bitspark/bitruntime v0.1.1-0.20260926094813-e3237a7b79b8/go.mod h1:7nj/LDSISLQZhUm8+gE4MrmmGFgT70PKzTzC/zhdtw4= +github.com/Bitspark/bitwire v0.3.0 h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA= +github.com/Bitspark/bitwire v0.3.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= diff --git a/conformance/runtime/go/internal/carrier/carrier.go b/conformance/runtime/go/internal/carrier/carrier.go new file mode 100644 index 0000000..cf92746 --- /dev/null +++ b/conformance/runtime/go/internal/carrier/carrier.go @@ -0,0 +1,80 @@ +// Package carrier selects the bitruntime carrier a conformance driver runs on, +// exactly as the Nightseam baseline selects its carriers: a local pair, or a +// WebSocket connection whose client or server side sends. It supplies only +// construction and cleanup; every delivery is bitruntime's own. +package carrier + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + engine "github.com/Bitspark/bitruntime/engine/go" + websocket "github.com/Bitspark/bitruntime/engine/websocket/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// Kind is BITRUNTIME_CARRIER: "local" or "peer". A peer is a real WebSocket +// between two bitruntime engines; BITRUNTIME_REVERSE=1 makes the server side +// the sending origin. +func Kind() string { + kind := os.Getenv("BITRUNTIME_CARRIER") + if kind != "local" && kind != "peer" { + panic("expected BITRUNTIME_CARRIER=local or peer") + } + if reverse := os.Getenv("BITRUNTIME_REVERSE"); reverse != "" && reverse != "0" && reverse != "1" { + panic("expected BITRUNTIME_REVERSE=0 or 1") + } + return kind +} + +func check(err error) { + if err != nil { + panic(err) + } +} + +// Pair returns two connected endpoints. Sending on the first delivers to the +// receiver of the second, and the reverse. cleanup registers each release. +func Pair(cleanup func(func())) (wire.Endpoint, wire.Endpoint) { + if Kind() == "local" { + a, b, err := core.NewPair(core.PairOptions{}) + check(err) + cleanup(func() { + _ = a.Close(transports.CodeNormal, "done") + _ = b.Close(transports.CodeNormal, "done") + }) + return a, b + } + connected := make(chan *engine.Peer, 1) + handler, err := websocket.NewHandler(websocket.ServerOptions{ + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *engine.Peer) { connected <- peer }, + }) + check(err) + server := httptest.NewServer(handler) + cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + cleanup(cancel) + client, _, err := websocket.Dial(ctx, server.URL, websocket.DialOptions{ConnectTimeout: 5 * time.Second}) + check(err) + cleanup(func() { _ = client.Close() }) + var remote *engine.Peer + select { + case remote = <-connected: + case <-time.After(5 * time.Second): + panic("the server did not publish its accepted peer") + case <-ctx.Done(): + panic(ctx.Err()) + } + cleanup(func() { _ = remote.Close() }) + if os.Getenv("BITRUNTIME_REVERSE") == "1" { + return remote.Wire(), client.Wire() + } + return client.Wire(), remote.Wire() +} diff --git a/conformance/runtime/go/lifecycle/main.go b/conformance/runtime/go/lifecycle/main.go new file mode 100644 index 0000000..61ba349 --- /dev/null +++ b/conformance/runtime/go/lifecycle/main.go @@ -0,0 +1,113 @@ +// Records public invocation lifecycle observations from bitruntime's +// core.Invocation. A port of ../../../current/go/main.go, which drives +// Nightseam v0.6.0's runtime.Invocation with the same operations. Expectations +// are read only by Bitwire's runner; this driver has no replacement ledger or +// routing machinery. +package main + +import ( + "encoding/json" + "fmt" + "os" + "slices" + + core "github.com/Bitspark/bitruntime/core/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +type step struct{ Op, ID, Label string } +type testCase struct { + ID string + Limits core.InvocationLimits + Steps []step +} +type sending func([]string, wire.Message) error + +func (f sending) Send(path []string, message wire.Message) error { return f(path, message) } + +func observe(test testCase) []any { + retirements := 0 + controls := []string{} + newInvocation := func() *core.Invocation { + return core.NewInvocation(test.Limits, func() { retirements++ }) + } + invocation := newInvocation() + // An opaque send-only facade: participants cannot discover the Invocation. + facade := func(v *core.Invocation) wire.AddressedWire { return sending(v.Deliver) } + access := facade(invocation) + var previous wire.AddressedWire + rows := []any{} + cancel := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: "c:1"}} + for _, action := range test.Steps { + message := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: json.RawMessage("null")}} + var err error + switch action.Op { + case "snapshot": + ordered := slices.Clone(controls) + slices.Sort(ordered) + rows = append(rows, map[string]any{"label": action.Label, "retired": invocation.Retired(), "retirements": retirements, "controls": ordered}) + continue + case "replace": + previous = access + invocation = newInvocation() + access = facade(invocation) + case "settle": + invocation.Settle() + case "dispatchDone": + invocation.DispatchDone() + case "cancel": + err = access.Send([]string{"invocation.control"}, cancel) + case "oldCancel": + if previous == nil { + panic("oldCancel without a previous invocation") + } + err = previous.Send([]string{"invocation.control"}, cancel) + case "unknown": + err = access.Send([]string{"unsupported.operation", "participant"}, message) + case "wrongKind": + err = access.Send([]string{"invocation.capture", "participant"}, cancel) + case "missingSink": + err = access.Send([]string{"invocation.capture", "participant"}, message) + case "capture", "ready", "release", "begin", "done": + if action.Op == "capture" { + message.Return = &wire.ReturnAddress{Wire: sending(func(path []string, control wire.Message) error { + if len(path) != 0 || control.Frame.Kind != wire.ProfileCancel { + panic("invalid control delivery") + } + controls = append(controls, action.ID+":"+control.Frame.ID) + return nil + })} + } + err = access.Send([]string{"invocation." + action.Op, action.ID}, message) + default: + panic("unknown fixture operation: " + action.Op) + } + if action.Label != "" { + rows = append(rows, map[string]any{"label": action.Label, "admitted": err == nil}) + } else if err != nil { + panic(fmt.Sprintf("%s/%s: %v", test.ID, action.Op, err)) + } + } + return rows +} + +func main() { + if len(os.Args) != 2 { + panic("expected the input-only fixture path") + } + data, err := os.ReadFile(os.Args[1]) + if err != nil { + panic(err) + } + var fixture struct{ Cases []testCase } + if err := json.Unmarshal(data, &fixture); err != nil { + panic(err) + } + rows := []any{} + for _, test := range fixture.Cases { + rows = append(rows, map[string]any{"id": test.ID, "observations": observe(test)}) + } + if err := json.NewEncoder(os.Stdout).Encode(rows); err != nil { + panic(err) + } +} diff --git a/conformance/runtime/go/trees/main.go b/conformance/runtime/go/trees/main.go new file mode 100644 index 0000000..48594e6 --- /dev/null +++ b/conformance/runtime/go/trees/main.go @@ -0,0 +1,94 @@ +// Produces the observation object of ../../../trees/go/main.go through +// bitruntime's production tree operations instead of the test-only +// interpreter: core.Compose constructs every node, core.Select and the node's +// At select, and core.Send is the derived sending. Where a send is refused and +// its path lies in the UTF-8 image, core.AsAddressed must refuse it too; that +// strengthens an observation without adding one. Expectations stay in +// ../../../trees/expected.json and are compared only by Bitwire's runner. +package main + +import ( + "encoding/hex" + "encoding/json" + "errors" + "os" + "sort" + + core "github.com/Bitspark/bitruntime/core/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +type primitive struct { + name string + admissions *[]string + refuse bool +} + +func (p *primitive) Send(message wire.Message) error { + if p.refuse { + return errors.New("refused") + } + *p.admissions = append(*p.admissions, p.name+":"+string(message.Frame.Kind)) + return nil +} + +func compose(own wire.Wire, children []wire.Child[wire.Wire]) wire.WireTree { + tree, err := core.Compose(own, children) + if err != nil { + panic(err) + } + return tree +} + +func main() { + admissions := []string{} + makeNode := func(name string, children []wire.Child[wire.Wire]) wire.WireTree { + return compose(&primitive{name: name, admissions: &admissions}, children) + } + leaf := makeNode("leaf", nil) + refusing := compose(&primitive{refuse: true}, nil) + tree := makeNode("root", []wire.Child[wire.Wire]{ + {Key: []byte{}, Tree: makeNode("empty", nil)}, {Key: []byte{255}, Tree: makeNode("binary", nil)}, + {Key: []byte("a/b"), Tree: refusing}, {Key: []byte("a"), Tree: makeNode("branch", []wire.Child[wire.Wire]{{Key: []byte("b"), Tree: leaf}})}, + }) + at := func(path wire.TreePath) wire.WireTree { + n, ok := core.Select(tree, path) + if !ok { + panic("missing") + } + return n + } + label := func(path wire.TreePath) string { return at(path).Own().(*primitive).name } + own, children := tree.Decompose() + rebuilt := compose(own, children) + exposed := tree.Children() + exposed[1].Key[0] = 0 + event := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent}} + _ = core.Send(tree, wire.TreePath{{255}}, event) + refusal := core.Send(tree, wire.TreePath{[]byte("a/b")}, wire.Message{}) + addressedRefusal := core.AsAddressed(tree).Send([]string{"a/b"}, wire.Message{}) + self, _ := core.Select(tree, nil) + viaAt, _ := tree.At(nil) + _, found := core.Select(tree, wire.TreePath{{0}}) + missingSend := core.Send(tree, wire.TreePath{{0}}, event) + missingAddressed := core.AsAddressed(tree).Send([]string{"\x00"}, event) + nested, _ := at(wire.TreePath{[]byte("a")}).At(wire.TreePath{[]byte("b")}) + reconstructed, _ := core.Select(rebuilt, wire.TreePath{[]byte("a"), []byte("b")}) + keys := []string{} + for _, c := range tree.Children() { + keys = append(keys, hex.EncodeToString(c.Key)) + } + sort.Strings(keys) + _, exists := core.Select(tree, wire.TreePath{[]byte("a/b")}) + result := map[string]any{ + "self": self == tree && viaAt == tree, "binary": label(wire.TreePath{{255}}), "emptyKey": label(wire.TreePath{{}}), + "missing": !found && missingSend != nil && missingAddressed != nil, + "nested": label(wire.TreePath{[]byte("a"), []byte("b")}), "nestedLaw": nested == at(wire.TreePath{[]byte("a"), []byte("b")}), + "children": keys, "slashIsLiteral": at(wire.TreePath{[]byte("a/b")}) != at(wire.TreePath{[]byte("a"), []byte("b")}), + "partsIdentity": own == tree.Own() && children[1].Tree == at(wire.TreePath{{255}}), "rebuildIdentity": reconstructed.Own() == leaf.Own(), + "keyCopy": label(wire.TreePath{{255}}) == "binary", "refusingExists": exists, "refusingSend": refusal != nil && addressedRefusal != nil, "admissions": admissions, + } + if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { + panic(err) + } +} diff --git a/conformance/runtime/production-gaps.json b/conformance/runtime/production-gaps.json new file mode 100644 index 0000000..f234da7 --- /dev/null +++ b/conformance/runtime/production-gaps.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "implementation": "bitruntime v0.1.1-0.20260926094813-e3237a7b79b8 at e3237a7b79b881d2280111a0933f386db87174ac: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go)", + "gaps": [ + { + "id": "origin-bearing-construction", + "requirement": "ADR0006: compose(origin, children) admits a composite whose own value is not the refusing origin.", + "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L56-L84 Its port deliberately omits Nightseam's unreleased declared-composition API, which Bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree.", + "cases": [ + "origin-and-descendants", + "nested-selection-agrees", + "missing-never-falls-back", + "empty-branch-versus-missing", + "root-cut-reconstruction", + "complete-cuts-agree", + "children-alone-lose-origin", + "fresh-origin-resets-parent-state", + "equivalent-substitution", + "copied-subtree-breaks-sharing", + "altered-children-detected", + "invalid-path-never-reaches-origin", + "selection-retains-original-binding", + "forwarded-reconstruction", + "mounted-carrier-reconstruction", + "pending-across-reconstruction" + ], + "observed": { + "go": {"unsupported": "originBearingComposite"} + } + }, + { + "id": "conflicting-segments-accepted", + "requirement": "ADR0006: construction refuses conflicting child segments before a native map can discard one.", + "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L61-L67", + "cases": ["conflicting-children"], + "observed": { + "go": {"trace": [["delivered", ["leaf"], 1]], "partsExact": false, "unchanged": true, "sendOnly": true, "borrowedUsable": true} + } + }, + { + "id": "invalid-segments-accepted", + "requirement": "ADR0006: construction refuses a segment outside the exact UTF-8 key image.", + "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L61-L78", + "cases": ["invalid-key"], + "observed": { + "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} + } + }, + { + "id": "missing-children-accepted", + "requirement": "ADR0006: every child is complete Wire access; construction refuses a missing child value.", + "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L61-L84", + "cases": ["missing-child-value"], + "observed": { + "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} + } + } + ], + "limitations": [ + { + "id": "endpoint-typed-children", + "requirement": "ADR0006: a child is complete send access; composition requires no receive attachment or closure authority from it.", + "evidence": "core.Mount types its children as Endpoint. The production driver adapts send-only children (instrumented access, guards, selected views) with a receive attachment that refuses and a Close that owns nothing. Mount then works for sending; its receiving mode would refuse such a child." + }, + { + "id": "owner-retained-parts", + "requirement": "ADR0006: the construction owner retains Parts; send access reveals none.", + "evidence": "core.Mount exposes no parts. The production driver retains the entries it supplied, which ADR0006 permits. Clearing the supplied map after construction left routing unchanged, so Mount copies its input." + } + ] +} diff --git a/scripts/conformance-results.test.mjs b/scripts/conformance-results.test.mjs index f1ef915..ac07b00 100644 --- a/scripts/conformance-results.test.mjs +++ b/scripts/conformance-results.test.mjs @@ -101,3 +101,13 @@ test('production differs from the oracle only by exactly recorded gaps', () => { assert.throws(() => compareProduction(declared, productionRows('go'), repeated, 'go', 'repeated')); assert.throws(() => compareProduction(declared, productionRows('go').slice(1), ledger, 'go', 'missing')); }); + +test('bitruntime keeps its own gap ledger, recorded only for the languages it runs', () => { + const runtime = JSON.parse(readFileSync(new URL('../conformance/runtime/production-gaps.json', import.meta.url))); + const gaps = new Map(runtime.gaps.flatMap(gap => gap.cases.map(id => [id, gap.observed.go]))); + const rows = expectedRows().map(row => gaps.has(row.id) ? { id: row.id, observations: structuredClone(gaps.get(row.id)) } : row); + const result = compareProduction(declared, rows, runtime, 'go', 'bitruntime'); + assert.equal(result.gaps.length, gaps.size); + // No TypeScript observation is claimed before a TypeScript driver runs. + assert.throws(() => compareProduction(declared, rows, runtime, 'ts', 'bitruntime/ts'), /no ts observation/); +}); diff --git a/scripts/conformance-runtime.mjs b/scripts/conformance-runtime.mjs new file mode 100644 index 0000000..dddc7ba --- /dev/null +++ b/scripts/conformance-runtime.mjs @@ -0,0 +1,151 @@ +// Runs Bitwire's existing independent cases against bitruntime's Go +// implementation. Expectations come only from Bitwire's case files; drivers +// receive inputs without them. bitruntime is consumed as a pinned public module +// with no replacement, never as a local checkout. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compareCases, compareProduction, declaredInputs, lifecycleInputs, nightseamCompositionExpected } from './conformance-results.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const module = join(root, 'conformance/runtime/go'); +const bytes = path => readFileSync(join(root, path)); +const read = path => JSON.parse(bytes(path)); +const pin = read('conformance/runtime/bitruntime.json'); +assert.equal(pin.repository, 'https://github.com/Bitspark/bitruntime'); +assert.equal(pin.module, 'github.com/Bitspark/bitruntime'); +assert.match(pin.revision, /^[a-f0-9]{40}$/); +assert.ok(pin.version.endsWith(`-${pin.revision.slice(0, 12)}`) || /^v\d+\.\d+\.\d+$/.test(pin.version), 'version names the pinned revision'); +const args = process.argv.slice(2); +if (args.some(arg => !['--keep-scratch'].includes(arg))) { + throw new Error('Usage: node scripts/conformance-runtime.mjs [--keep-scratch]'); +} + +// The oracle files, exactly as this checkout holds them. +const files = { + lifecycle: 'conformance/current/lifecycle.json', + declared: 'conformance/declared/cases.json', + composition: 'conformance/reference/expected.json', + trees: 'conformance/trees/expected.json', + gaps: 'conformance/runtime/production-gaps.json', +}; +const sha256 = path => createHash('sha256').update(bytes(path)).digest('hex'); +const lifecycle = read(files.lifecycle); +const declared = read(files.declared); +// bitwire/1 admits only role-prefixed decimal request IDs, as Nightseam v0.6.0's +// profile does; the two echoed placeholders are instantiated as c:1 there too. +const composition = nightseamCompositionExpected(read(files.composition)); +const trees = read(files.trees); +const gaps = read(files.gaps); +const carriers = [['local', '0'], ['peer', '0'], ['peer', '1']]; +const scratch = mkdtempSync(join(tmpdir(), 'bitwire-runtime-')); + +function run(command, arguments_, env = {}, cwd = module) { + try { + return execFileSync(command, arguments_, { + cwd, encoding: 'utf8', timeout: 600_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GOWORK: 'off', GOFLAGS: '-mod=readonly', ...env }, + }); + } catch (error) { + process.stderr.write(error.stdout ?? ''); + process.stderr.write(error.stderr ?? ''); + throw error; + } +} + +// Exactly the pinned public modules, with no replacement anywhere in the graph. +function verifyModules() { + const graph = run('go', ['list', '-m', '-f', '{{.Path}}\t{{.Version}}\t{{with .Replace}}{{.Path}} {{.Version}}{{end}}', 'all']) + .trim().split(/\r?\n/).map(line => line.split('\t')); + assert.deepEqual(graph[0].slice(0, 1), ['bitwire.conformance/runtime']); + for (const [path, , replacement] of graph) assert.equal(replacement ?? '', '', `${path} must not be replaced`); + const version = path => graph.find(([name]) => name === path)?.[1]; + assert.equal(version(pin.module), pin.version, 'bitruntime is not the pinned candidate'); + assert.equal(version('github.com/Bitspark/bitwire'), pin.bitwireVersion, 'Bitwire is not the pinned contract'); + for (const [path, version_, revision, sum] of [ + [pin.module, pin.version, pin.revision, pin.sum], + ['github.com/Bitspark/bitwire', pin.bitwireVersion, pin.bitwireRevision, pin.bitwireSum], + ]) { + const artifact = JSON.parse(run('go', ['mod', 'download', '-json', `${path}@${version_}`])); + assert.equal(artifact.Origin?.Hash, revision, `${path}@${version_} is not ${revision}`); + assert.equal(artifact.Sum, sum, `${path}@${version_} changed`); + } + run('go', ['mod', 'verify']); +} + +function build(name) { + const program = join(scratch, process.platform === 'win32' ? `${name}.exe` : name); + run('go', ['build', ...(race ? ['-race'] : []), '-o', program, `./${name}`]); + return program; +} +const carrierEnv = (carrier, reverse) => ({ BITRUNTIME_CARRIER: carrier, BITRUNTIME_REVERSE: reverse }); + +const race = run('go', ['env', 'CGO_ENABLED']).trim() === '1'; +if (!race && process.env.CI) throw new Error('CI must run the bitruntime drivers under the race detector'); +const results = []; +function pass(label, detail) { + results.push(`${label}: ${detail}`); + console.log(`PASS ${label}: ${detail}`); +} + +const checkout = run('git', ['rev-parse', 'HEAD'], {}, root).trim(); +const modified = run('git', ['status', '--porcelain', '--', ...Object.values(files)], {}, root).trim() !== ''; +console.log(`bitruntime ${pin.version} (${pin.revision}) against Bitwire ${pin.bitwireVersion}; scratch ${scratch}`); +try { + verifyModules(); + const programs = Object.fromEntries(['lifecycle', 'composition', 'declared', 'trees'].map(name => [name, build(name)])); + + const lifecycleInput = join(scratch, 'lifecycle-inputs.json'); + writeFileSync(lifecycleInput, JSON.stringify(lifecycleInputs(lifecycle))); + compareCases(lifecycle, JSON.parse(run(programs.lifecycle, [lifecycleInput])), 'go/lifecycle'); + pass('go/lifecycle', `${lifecycle.cases.length}/${lifecycle.cases.length} independent cases through core.Invocation`); + + for (const [carrier, reverse] of carriers) { + const label = `go/composition/${carrier}/${reverse}`; + assert.deepEqual(JSON.parse(run(programs.composition, [], carrierEnv(carrier, reverse))), composition, `${label}: composition differs from Bitwire's oracle`); + pass(label, `${Object.keys(composition).length}/${Object.keys(composition).length} composition groups through NewPair/peers, dispatch, At, Mount and Forward`); + } + + // The reference interpreter must meet every expectation. Production runs + // bitruntime's addressed Mount and may differ only by a gap in its own ledger. + const declaredInput = join(scratch, 'declared-inputs.json'); + writeFileSync(declaredInput, JSON.stringify(declaredInputs(declared))); + const total = declared.cases.length; + for (const realization of ['reference', 'production']) { + for (const [carrier, reverse] of carriers) { + const label = `go/declared/${realization}/${carrier}/${reverse}`; + const actual = JSON.parse(run(programs.declared, [realization, declaredInput], carrierEnv(carrier, reverse))); + if (realization === 'reference') { + compareCases(declared, actual, label); + pass(label, `${total}/${total} cases (test-only reference interpreter over bitruntime carriers)`); + } else { + const result = compareProduction(declared, actual, gaps, 'go', label); + pass(label, `${result.conforming.length}/${total} conform through core.Mount/At/Forward; ${result.gaps.length} match bitruntime's recorded gaps`); + } + } + } + + assert.deepEqual(JSON.parse(run(programs.trees, [])), trees, 'go/trees: full tree observations differ from Bitwire\'s oracle'); + pass('go/trees', `${Object.keys(trees).length}/${Object.keys(trees).length} observations through core.Compose/Select/Send/AsAddressed`); + + console.log('\nReport'); + console.log(` Bitwire contract: ${pin.bitwireVersion} (github.com/Bitspark/bitwire at ${pin.bitwireRevision})`); + console.log(` Bitwire cases: checkout ${checkout}${modified ? ' with local changes to the case files' : ''}`); + for (const [name, path] of Object.entries(files)) console.log(` ${sha256(path)} ${path}${name === 'gaps' ? ' (bitruntime gap ledger)' : ''}`); + console.log(` Implementation: ${pin.module} ${pin.version} (${pin.revision}), ${pin.profile}, Go only`); + console.log(` Toolchain: ${run('go', ['env', 'GOVERSION']).trim()}${race ? ' with race detector' : ' (race detector unavailable locally)'}`); + console.log(' Carriers: local = core.NewPair; peer/0 = WebSocket client sends; peer/1 = WebSocket server sends (engine/websocket)'); + for (const line of results) console.log(` ${line}`); + console.log('Historical 0.2 addressed evidence (lifecycle, declared, composition) and 0.3 structural evidence (trees). TypeScript follows bitruntime\'s TS package.'); +} finally { + if (args.includes('--keep-scratch')) console.log(`Retained scratch: ${scratch}`); + else { + assert.equal(dirname(resolve(scratch)), resolve(tmpdir())); + assert.ok(basename(scratch).startsWith('bitwire-runtime-')); + rmSync(scratch, { recursive: true, force: true, maxRetries: 5 }); + } +} From d0b985ca7b86c809aff5e34da8906a73b9ed7db3 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:09:45 +0200 Subject: [PATCH 2/7] docs: describe the bitruntime runtime conformance module and its scope Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/README.md | 37 ++++++++++++++++++++++++++++++++++++- scripts/README.md | 5 ++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 99f0926..3d5a611 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -7,7 +7,42 @@ not establish the new structural contract. See [decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md). **Status: current released 0.2 composition and scoped lifecycle evidence, a -test-only reference, and a preserved historical 0.1.0 runtime baseline.** +test-only reference, bitruntime Go candidate evidence against the same cases, +and a preserved historical 0.1.0 runtime baseline.** + +## bitruntime runtime conformance + +Run `node scripts/conformance-runtime.mjs`. The test-only Go module +[`runtime/go`](runtime/go/go.mod) runs Bitwire's existing independent cases +against bitruntime, as the current baseline runs them against Nightseam v0.6.0. +It requires Bitwire v0.3.0 and the public bitruntime module pinned in +[bitruntime.json](runtime/bitruntime.json), with no replacement or local path. +The runner refuses any other module graph, revision or sum, runs every driver +with `GOWORK=off` under the race detector (required in CI), withholds every +expectation from the drivers, and reports the case files' SHA-256s. + +| Family | Driver and oracle | Carriers | Result at the pin | +| --- | --- | --- | --- | +| Lifecycle | [Cases](current/lifecycle.json) through `core.Invocation` | none, public state | 5/5 | +| Composition | The six [reference](reference/expected.json) groups; driver ported from Nightseam's upstream one | local pair, WebSocket client and server sending | 6/6 on each | +| Declared, reference | The 39 [declared](declared/cases.json) cases through the test-only interpreter over bitruntime's carriers | the same three | 39/39 on each | +| Declared, production | The same cases through bitruntime's child-only addressed `core.Mount`, `At` and `Forward` | the same three | 20 conform; 19 match bitruntime's own [gap ledger](runtime/production-gaps.json) | +| Trees | The [0.3 observations](trees/expected.json) through `core.Compose`, `Select`, `Send` and `AsAddressed` | none, structural | 14/14 | + +As in the Nightseam baseline, the two echoed `same-id` placeholders are +instantiated as `c:1`: bitruntime's `bitwire/1` is Nightseam v0.6.0's profile +and refuses `same-id` as a request identifier on both carriers. Nothing else in +any oracle changes. bitruntime keeps its own gap ledger so a gap it closes is +removed there, never from Nightseam's. Its observations equal Nightseam's: +`core.Mount` is a renamed port, and bitruntime deliberately omits the unreleased +declared-composition API that decision 0012 supersedes. No case reaches +bitruntime's documented forwarding and disconnection changes, because every +refusal in these fixtures happens before a carrier or forwarder. + +Lifecycle, composition and declared results remain evidence about the 0.2 +addressed contract (`AddressedWire` in 0.3); trees is the 0.3 structural +contract. The module is Go only; TypeScript follows once bitruntime publishes +its TypeScript package. ## Current released runtime baseline diff --git a/scripts/README.md b/scripts/README.md index 494a9ae..527150d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -30,7 +30,10 @@ cases through pinned public Nightseam implementations. `node scripts/conformance-current.mjs` executes the current released production composition and scoped lifecycle baseline, described in [current conformance](../conformance/current/README.md). Both run in the required -conformance CI job. `node scripts/smoke-packed.mjs` installs npm +conformance CI job. `node scripts/conformance-runtime.mjs` runs the same +independent cases against the pinned bitruntime Go module in its own CI job; see +[bitruntime runtime conformance](../conformance/README.md#bitruntime-runtime-conformance). +`node scripts/smoke-packed.mjs` installs npm and Go artifacts outside the checkout. These are separate from declaration checks. The [release procedure](../RELEASING.md) describes rehearsal and public registry verification; native binding READMEs describe their package-consumer checks. From c857c3fc5af8666f719fbd670c6446bb3df88231 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:20:37 +0200 Subject: [PATCH 3/7] conformance: pin bitruntime's candidate with the engine and reply fixes Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/runtime/bitruntime.json | 6 +++--- conformance/runtime/go/go.mod | 2 +- conformance/runtime/go/go.sum | 4 ++-- conformance/runtime/production-gaps.json | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/conformance/runtime/bitruntime.json b/conformance/runtime/bitruntime.json index b51bd2e..c8fc1c3 100644 --- a/conformance/runtime/bitruntime.json +++ b/conformance/runtime/bitruntime.json @@ -1,9 +1,9 @@ { "repository": "https://github.com/Bitspark/bitruntime", "module": "github.com/Bitspark/bitruntime", - "version": "v0.1.1-0.20260926094813-e3237a7b79b8", - "revision": "e3237a7b79b881d2280111a0933f386db87174ac", - "sum": "h1:Akqj+hbgd+ZMsP7C8thtL971DminLgyyLcEkYjKzso8=", + "version": "v0.1.1-0.20260926101907-9c93bb77b9b8", + "revision": "9c93bb77b9b8a592b364962c0fc90414e715b229", + "sum": "h1:/kiJpZfuDA7uBQiCpVtRpDXPR7LEEvNySioBdMud9LY=", "status": "unreleased-source", "profile": "bitwire/1", "bitwireVersion": "v0.3.0", diff --git a/conformance/runtime/go/go.mod b/conformance/runtime/go/go.mod index c7b9b2c..ec7caab 100644 --- a/conformance/runtime/go/go.mod +++ b/conformance/runtime/go/go.mod @@ -3,7 +3,7 @@ module bitwire.conformance/runtime go 1.26.0 require ( - github.com/Bitspark/bitruntime v0.1.1-0.20260926094813-e3237a7b79b8 + github.com/Bitspark/bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 github.com/Bitspark/bitwire v0.3.0 ) diff --git a/conformance/runtime/go/go.sum b/conformance/runtime/go/go.sum index 6067b11..9af02a9 100644 --- a/conformance/runtime/go/go.sum +++ b/conformance/runtime/go/go.sum @@ -1,5 +1,5 @@ -github.com/Bitspark/bitruntime v0.1.1-0.20260926094813-e3237a7b79b8 h1:Akqj+hbgd+ZMsP7C8thtL971DminLgyyLcEkYjKzso8= -github.com/Bitspark/bitruntime v0.1.1-0.20260926094813-e3237a7b79b8/go.mod h1:7nj/LDSISLQZhUm8+gE4MrmmGFgT70PKzTzC/zhdtw4= +github.com/Bitspark/bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 h1:/kiJpZfuDA7uBQiCpVtRpDXPR7LEEvNySioBdMud9LY= +github.com/Bitspark/bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8/go.mod h1:2Jtfp03Neyk5cCKP/WizMw+w+5ABT4yt/Dzq/yeKsMY= github.com/Bitspark/bitwire v0.3.0 h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA= github.com/Bitspark/bitwire v0.3.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= diff --git a/conformance/runtime/production-gaps.json b/conformance/runtime/production-gaps.json index f234da7..1d92387 100644 --- a/conformance/runtime/production-gaps.json +++ b/conformance/runtime/production-gaps.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "implementation": "bitruntime v0.1.1-0.20260926094813-e3237a7b79b8 at e3237a7b79b881d2280111a0933f386db87174ac: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go)", + "implementation": "bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 at 9c93bb77b9b8a592b364962c0fc90414e715b229: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go)", "gaps": [ { "id": "origin-bearing-construction", "requirement": "ADR0006: compose(origin, children) admits a composite whose own value is not the refusing origin.", - "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L56-L84 Its port deliberately omits Nightseam's unreleased declared-composition API, which Bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree.", + "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L56-L84 Its port deliberately omits Nightseam's unreleased declared-composition API, which Bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree.", "cases": [ "origin-and-descendants", "nested-selection-agrees", @@ -31,7 +31,7 @@ { "id": "conflicting-segments-accepted", "requirement": "ADR0006: construction refuses conflicting child segments before a native map can discard one.", - "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L61-L67", + "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L67", "cases": ["conflicting-children"], "observed": { "go": {"trace": [["delivered", ["leaf"], 1]], "partsExact": false, "unchanged": true, "sendOnly": true, "borrowedUsable": true} @@ -40,7 +40,7 @@ { "id": "invalid-segments-accepted", "requirement": "ADR0006: construction refuses a segment outside the exact UTF-8 key image.", - "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L61-L78", + "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L78", "cases": ["invalid-key"], "observed": { "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} @@ -49,7 +49,7 @@ { "id": "missing-children-accepted", "requirement": "ADR0006: every child is complete Wire access; construction refuses a missing child value.", - "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/e3237a7b79b881d2280111a0933f386db87174ac/core/go/addressed.go#L61-L84", + "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L84", "cases": ["missing-child-value"], "observed": { "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} From 0240a66496668d971fa7117f30a3cb012d54c16b Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:54:56 +0200 Subject: [PATCH 4/7] docs: spell the projects the runtime conformance adds as their repositories are named Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 3d5a611..fe574f1 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -13,9 +13,9 @@ and a preserved historical 0.1.0 runtime baseline.** ## bitruntime runtime conformance Run `node scripts/conformance-runtime.mjs`. The test-only Go module -[`runtime/go`](runtime/go/go.mod) runs Bitwire's existing independent cases -against bitruntime, as the current baseline runs them against Nightseam v0.6.0. -It requires Bitwire v0.3.0 and the public bitruntime module pinned in +[`runtime/go`](runtime/go/go.mod) runs bitwire's existing independent cases +against bitruntime, as the current baseline runs them against nightseam v0.6.0. +It requires bitwire v0.3.0 and the public bitruntime module pinned in [bitruntime.json](runtime/bitruntime.json), with no replacement or local path. The runner refuses any other module graph, revision or sum, runs every driver with `GOWORK=off` under the race detector (required in CI), withholds every @@ -24,16 +24,16 @@ expectation from the drivers, and reports the case files' SHA-256s. | Family | Driver and oracle | Carriers | Result at the pin | | --- | --- | --- | --- | | Lifecycle | [Cases](current/lifecycle.json) through `core.Invocation` | none, public state | 5/5 | -| Composition | The six [reference](reference/expected.json) groups; driver ported from Nightseam's upstream one | local pair, WebSocket client and server sending | 6/6 on each | +| Composition | The six [reference](reference/expected.json) groups; driver ported from nightseam's upstream one | local pair, WebSocket client and server sending | 6/6 on each | | Declared, reference | The 39 [declared](declared/cases.json) cases through the test-only interpreter over bitruntime's carriers | the same three | 39/39 on each | | Declared, production | The same cases through bitruntime's child-only addressed `core.Mount`, `At` and `Forward` | the same three | 20 conform; 19 match bitruntime's own [gap ledger](runtime/production-gaps.json) | | Trees | The [0.3 observations](trees/expected.json) through `core.Compose`, `Select`, `Send` and `AsAddressed` | none, structural | 14/14 | -As in the Nightseam baseline, the two echoed `same-id` placeholders are -instantiated as `c:1`: bitruntime's `bitwire/1` is Nightseam v0.6.0's profile +As in the nightseam baseline, the two echoed `same-id` placeholders are +instantiated as `c:1`: bitruntime's `bitwire/1` is nightseam v0.6.0's profile and refuses `same-id` as a request identifier on both carriers. Nothing else in any oracle changes. bitruntime keeps its own gap ledger so a gap it closes is -removed there, never from Nightseam's. Its observations equal Nightseam's: +removed there, never from nightseam's. Its observations equal nightseam's: `core.Mount` is a renamed port, and bitruntime deliberately omits the unreleased declared-composition API that decision 0012 supersedes. No case reaches bitruntime's documented forwarding and disconnection changes, because every From b71c98f462c9ac94e912244dd5f286dcca006de9 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 13:27:27 +0200 Subject: [PATCH 5/7] conformance: run bitwire's independent cases against bitruntime's TypeScript release Add conformance/runtime/ts, a private, test-only package depending on the bitruntime v0.2.0 release asset (sha256 d19d377a..., tag v0.2.0 at e28bb1ad), @bitspark/bitwire 0.3.0 and ws 8.21.3, with an .npmrc taking the @bitspark scope from the public npm registry. Its drivers port the existing ones to bitruntime's TypeScript package: lifecycle through Invocation, the six composition groups (from nightseam v0.6.0's upstream TypeScript driver), the 39 declared cases through the test-only reference and through the child-only mount on local pairs and WebSockets in both directions, and the 0.3 tree observations through compose/select/send/asAddressed. scripts/conformance-runtime.mjs runs them after Go with the same withheld inputs and carriers. It downloads the release asset and checks its SHA-256 and integrity and the tag's revision, refuses any lockfile entry that is not a public, integrity-pinned artifact or that duplicates bitwire, installs with npm ci in scratch, type-checks with TypeScript 7.0.2 and runs the drivers with Node 24's type stripping. --language=go|ts selects one half. All TypeScript families pass: lifecycle 5/5, composition 6/6 and declared reference 39/39 on each carrier, declared production 20 conforming with 19 exact gaps on each carrier, trees 14/14. The TypeScript gap observations are recorded in bitruntime's ledger and equal both its Go ones and nightseam's recorded TypeScript ones. Co-Authored-By: Claude Opus 5.5 (1M context) --- NOTICE | 13 +- conformance/runtime/bitruntime.json | 15 +- conformance/runtime/production-gaps.json | 26 +- conformance/runtime/ts/.npmrc | 4 + conformance/runtime/ts/carrier.ts | 62 +++ conformance/runtime/ts/composition.ts | 455 ++++++++++++++++++++ conformance/runtime/ts/declared.ts | 502 +++++++++++++++++++++++ conformance/runtime/ts/lifecycle.ts | 71 ++++ conformance/runtime/ts/package-lock.json | 467 +++++++++++++++++++++ conformance/runtime/ts/package.json | 19 + conformance/runtime/ts/trees.ts | 63 +++ conformance/runtime/ts/tsconfig.json | 15 + scripts/conformance-results.test.mjs | 15 +- scripts/conformance-runtime.mjs | 190 +++++++-- 14 files changed, 1848 insertions(+), 69 deletions(-) create mode 100644 conformance/runtime/ts/.npmrc create mode 100644 conformance/runtime/ts/carrier.ts create mode 100644 conformance/runtime/ts/composition.ts create mode 100644 conformance/runtime/ts/declared.ts create mode 100644 conformance/runtime/ts/lifecycle.ts create mode 100644 conformance/runtime/ts/package-lock.json create mode 100644 conformance/runtime/ts/package.json create mode 100644 conformance/runtime/ts/trees.ts create mode 100644 conformance/runtime/ts/tsconfig.json diff --git a/NOTICE b/NOTICE index 7f963d2..735f4cb 100644 --- a/NOTICE +++ b/NOTICE @@ -15,9 +15,10 @@ from Nightseam's native Wire surfaces at revision the source files and extraction changes. The Haskell presentation is derived from the common Bitwire specification. -The test-only bitruntime composition driver in -conformance/runtime/go/composition/main.go is adapted from Nightseam's -conformance/bitwire/go/main.go at v0.6.0, revision -5cc9723a24646c40ed1861f892b2b23eb6d785d7, licensed under the Apache License, -Version 2.0: Copyright 2026 Bitspark and the Nightseam contributors. Each -Nightseam facility is replaced by the bitruntime facility its port records. +The test-only bitruntime composition drivers +conformance/runtime/go/composition/main.go and +conformance/runtime/ts/composition.ts are adapted from nightseam's +conformance/bitwire/go/main.go and conformance/ts/src/bitwire.ts at v0.6.0, +revision 5cc9723a24646c40ed1861f892b2b23eb6d785d7, licensed under the Apache +License, Version 2.0: Copyright 2026 Bitspark and the Nightseam contributors. +Each nightseam facility is replaced by the bitruntime facility its port records. diff --git a/conformance/runtime/bitruntime.json b/conformance/runtime/bitruntime.json index c8fc1c3..93a34da 100644 --- a/conformance/runtime/bitruntime.json +++ b/conformance/runtime/bitruntime.json @@ -8,5 +8,18 @@ "profile": "bitwire/1", "bitwireVersion": "v0.3.0", "bitwireRevision": "f825f3f4a79135646b775e25dcd770656546b4a1", - "bitwireSum": "h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA=" + "bitwireSum": "h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA=", + "npm": { + "package": "@bitspark/bitruntime", + "version": "0.2.0", + "tag": "v0.2.0", + "revision": "e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2", + "tarball": "https://github.com/Bitspark/bitruntime/releases/download/v0.2.0/bitspark-bitruntime-0.2.0.tgz", + "sha256": "d19d377ad60037083b3158a7200f734e8bb37b17e7d1f7f7c2cd15e0ff2fb9d1", + "integrity": "sha512-1S62Lft9WdC9eX9lrEWYWgLMkeiEhZh1QTU/Ybd9NjleDAxYizi/n9D98w/sBGorKI2lkUmMBvTO1xFYul7lYQ==", + "status": "release", + "bitwireVersion": "0.3.0", + "bitwireTarball": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.3.0.tgz", + "bitwireIntegrity": "sha512-6tqme+2nfJAp2xwmYIrO7hSSh+6TrJ7gPIb8SZX4dQAoZZHs/iqPPsZFv8vJsMUBejzgBxwNtOoC3PjI0KMPRw==" + } } diff --git a/conformance/runtime/production-gaps.json b/conformance/runtime/production-gaps.json index 1d92387..c469333 100644 --- a/conformance/runtime/production-gaps.json +++ b/conformance/runtime/production-gaps.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "implementation": "bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 at 9c93bb77b9b8a592b364962c0fc90414e715b229: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go)", + "implementation": "bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 at 9c93bb77b9b8a592b364962c0fc90414e715b229: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go); @bitspark/bitruntime 0.2.0, tag v0.2.0 at e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2: at, mount, forward, pair and engine Peers over WebSockets (TypeScript)", "gaps": [ { "id": "origin-bearing-construction", "requirement": "ADR0006: compose(origin, children) admits a composite whose own value is not the refusing origin.", - "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L56-L84 Its port deliberately omits Nightseam's unreleased declared-composition API, which Bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree.", + "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L56-L84 Its port deliberately omits Nightseam's unreleased declared-composition API, which Bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree. The TypeScript mount is the same specialization: it takes only a map of children and refuses [] with MissingPathError. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L29-L48 compose builds a WireTree of addressless Wires there too.", "cases": [ "origin-and-descendants", "nested-selection-agrees", @@ -25,34 +25,38 @@ "pending-across-reconstruction" ], "observed": { - "go": {"unsupported": "originBearingComposite"} + "go": {"unsupported": "originBearingComposite"}, + "ts": {"unsupported": "originBearingComposite"} } }, { "id": "conflicting-segments-accepted", "requirement": "ADR0006: construction refuses conflicting child segments before a native map can discard one.", - "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L67", + "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L67 The TypeScript mount likewise accepts only an already-built Map, with the same result. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L38", "cases": ["conflicting-children"], "observed": { - "go": {"trace": [["delivered", ["leaf"], 1]], "partsExact": false, "unchanged": true, "sendOnly": true, "borrowedUsable": true} + "go": {"trace": [["delivered", ["leaf"], 1]], "partsExact": false, "unchanged": true, "sendOnly": true, "borrowedUsable": true}, + "ts": {"trace": [["delivered", ["leaf"], 1]], "partsExact": false, "unchanged": true, "sendOnly": true, "borrowedUsable": true} } }, { "id": "invalid-segments-accepted", "requirement": "ADR0006: construction refuses a segment outside the exact UTF-8 key image.", - "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L78", + "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L78 The TypeScript mount copies a lone-surrogate key without validation; sending validates the path later (InvalidPathError). https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L47", "cases": ["invalid-key"], "observed": { - "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} + "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true}, + "ts": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} } }, { "id": "missing-children-accepted", "requirement": "ADR0006: every child is complete Wire access; construction refuses a missing child value.", - "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L84", + "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L84 The TypeScript mount accepts an undefined child and later refuses sends to that key with MissingPathError. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L47", "cases": ["missing-child-value"], "observed": { - "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} + "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true}, + "ts": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true} } } ], @@ -60,12 +64,12 @@ { "id": "endpoint-typed-children", "requirement": "ADR0006: a child is complete send access; composition requires no receive attachment or closure authority from it.", - "evidence": "core.Mount types its children as Endpoint. The production driver adapts send-only children (instrumented access, guards, selected views) with a receive attachment that refuses and a Close that owns nothing. Mount then works for sending; its receiving mode would refuse such a child." + "evidence": "core.Mount (Go) and mount (TypeScript, a ReadonlyMap) type their children as Endpoint. The production driver adapts send-only children (instrumented access, guards, selected views) with a receive attachment that refuses and a Close that owns nothing. Mount then works for sending; its receiving mode would refuse such a child." }, { "id": "owner-retained-parts", "requirement": "ADR0006: the construction owner retains Parts; send access reveals none.", - "evidence": "core.Mount exposes no parts. The production driver retains the entries it supplied, which ADR0006 permits. Clearing the supplied map after construction left routing unchanged, so Mount copies its input." + "evidence": "core.Mount and mount expose no parts. The production driver retains the entries it supplied, which ADR0006 permits. Clearing the supplied map after construction left routing unchanged, so Mount copies its input." } ] } diff --git a/conformance/runtime/ts/.npmrc b/conformance/runtime/ts/.npmrc new file mode 100644 index 0000000..17527f8 --- /dev/null +++ b/conformance/runtime/ts/.npmrc @@ -0,0 +1,4 @@ +# Every @bitspark package comes from the public npm registry, whatever a +# user-level configuration names for the scope. bitruntime is a pinned release +# tarball; its URL and integrity are in package-lock.json and ../bitruntime.json. +@bitspark:registry=https://registry.npmjs.org/ diff --git a/conformance/runtime/ts/carrier.ts b/conformance/runtime/ts/carrier.ts new file mode 100644 index 0000000..68dae1d --- /dev/null +++ b/conformance/runtime/ts/carrier.ts @@ -0,0 +1,62 @@ +// Selects the bitruntime carrier a conformance driver runs on, as ../go's +// internal/carrier does: a local pair, or a real WebSocket between two +// bitruntime peers whose client or server side sends. It supplies only +// construction and cleanup; every delivery is bitruntime's own. +import { once } from 'node:events'; +import { WebSocket, WebSocketServer } from 'ws'; +import type { Endpoint } from '@bitspark/bitwire'; +import { pair } from '@bitspark/bitruntime/core'; +import { Peer } from '@bitspark/bitruntime/engine'; +import { CODE_NORMAL, webSocketConnection, type WebSocketLike } from '@bitspark/bitruntime/transports'; + +/** BITRUNTIME_CARRIER is local or peer; BITRUNTIME_REVERSE=1 makes a peer's server side the sending origin. */ +export function kind(): 'local' | 'peer' { + const carrier = process.env.BITRUNTIME_CARRIER; + if (carrier !== 'local' && carrier !== 'peer') throw new Error('Expected BITRUNTIME_CARRIER=local or peer'); + if (!['', '0', '1'].includes(process.env.BITRUNTIME_REVERSE ?? '')) throw new Error('Expected BITRUNTIME_REVERSE=0 or 1'); + return carrier; +} + +// ws implements the browser event surface the adapter uses; its overloaded +// addEventListener declaration has a different TypeScript shape. +function asLike(socket: WebSocket): WebSocketLike { + socket.binaryType = 'arraybuffer'; + return socket as unknown as WebSocketLike; +} + +/** + * Returns two connected endpoints: sending on the first delivers to the + * receiver of the second, and the reverse. cleanup registers each release. + */ +export async function connected(cleanup: (release: () => void) => void): Promise<[Endpoint, Endpoint]> { + if (kind() === 'local') { + const [a, b] = pair(); + cleanup(() => { + a.close(CODE_NORMAL, 'done'); + b.close(CODE_NORMAL, 'done'); + }); + return [a, b]; + } + const listener = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + cleanup(() => listener.close()); + await once(listener, 'listening', { signal: AbortSignal.timeout(5_000) }); + const address = listener.address(); + if (!address || typeof address === 'string') throw new Error('No WebSocket address'); + const accepted = once(listener, 'connection', { signal: AbortSignal.timeout(5_000) }); + // Both awaits can fail independently; keep a rejection handler while the + // client's handshake is pending, then await this same promise. + accepted.catch(() => {}); + listener.on('connection', (socket: WebSocket) => cleanup(() => socket.terminate())); + const socket = new WebSocket(`ws://127.0.0.1:${address.port}`); + cleanup(() => socket.terminate()); + await once(socket, 'open', { signal: AbortSignal.timeout(5_000) }); + const [remote] = (await accepted) as [WebSocket]; + const client = new Peer({ role: 'client' }); + const server = new Peer({ role: 'server' }); + cleanup(() => { + client.close(); + server.close(); + }); + await Promise.all([client.attach(webSocketConnection(asLike(socket))), server.attach(webSocketConnection(asLike(remote)))]); + return process.env.BITRUNTIME_REVERSE === '1' ? [server.wire(), client.wire()] : [client.wire(), server.wire()]; +} diff --git a/conformance/runtime/ts/composition.ts b/conformance/runtime/ts/composition.ts new file mode 100644 index 0000000..24c99dd --- /dev/null +++ b/conformance/runtime/ts/composition.ts @@ -0,0 +1,455 @@ +// Exercises bitruntime's carriers against bitwire's six composition observation +// groups (../../reference/expected.json). A port of nightseam's upstream driver +// at v0.6.0 (5cc9723a, conformance/ts/src/bitwire.ts; Apache-2.0, Bitspark), +// with each nightseam facility replaced by the bitruntime facility its port +// records. It contains no replacement Wire implementation and no expected +// results; bitwire's runner compares the observations. +import type { AddressedWire, Endpoint, Message, Path, Receiver, ReturnAddress } from '@bitspark/bitwire'; +import { at, forward, mount } from '@bitspark/bitruntime/core'; +import { createDispatcher } from '@bitspark/bitruntime/dispatch'; +import { connected, kind } from './carrier.ts'; + +kind(); +const cleanups: (() => void)[] = []; + +// Observations contain no oracle values. All delivery, matching, attachment, +// correlation and closure below use bitruntime's pair, peers and dispatcher. +const event: Message = { frame: { version: 1, kind: 'event', data: null } }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { + resolve, + promise: Promise.race([ + promise, + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error('Delivery deadline exceeded.')), 5_000); + void promise.then(() => clearTimeout(timer)); + }), + ]), + }; +} +function refused(action: () => unknown): boolean { + try { + action(); + return false; + } catch { + return true; + } +} +// Every operation is delegated. The spies observe the boundary before/after +// composition, without implementing routing, dispatch or return correlation. +function observe( + endpoint: Endpoint, + hooks: { send?: (path: Path, message: Message) => void; receive?: (path: Path, message: Message) => void }, +): Endpoint { + return { + send(path, message) { + hooks.send?.(path, message); + endpoint.send(path, message); + }, + receive(receiver) { + return endpoint.receive({ + ...receiver, + message(path, message) { + hooks.receive?.(path, message); + return receiver.message?.(path, message); + }, + }); + }, + close: (code, reason) => endpoint.close(code, reason), + }; +} +async function ownedPair(): Promise<[Endpoint, Endpoint]> { + const pair = await connected((release) => cleanups.push(release)); + cleanups.push(() => { + pair[0].close(); + pair[1].close(); + }); + return pair; +} +function respond(message: Message, result: unknown): void { + if (message.frame.kind !== 'request' || !message.return) throw new Error('Expected admitted request.'); + message.return.wire.send([], { frame: { version: 1, kind: 'response', id: message.frame.id, result } }); +} +async function sendObserved(wire: AddressedWire, path: Path, delivered: ReturnType>): Promise { + wire.send(path, event); + await delivered.promise; +} + +async function siblings() { + const [client, server] = await ownedPair(); + const router = createDispatcher(server); + const deliveries: string[] = []; + let signal = deferred(); + let sending = false, + receiverRanDuringSend = false; + const receiver = (name: string): Receiver => ({ + message(path) { + receiverRanDuringSend ||= sending; + deliveries.push(`${name}:${path.join('/')}`); + signal.resolve(); + }, + }); + const detachA = router.select(['a']).receive(receiver('a')); + router.select(['b']).receive(receiver('b')); + const duplicateEndpointAttachmentRefused = refused(() => server.receive({})); + sending = true; + at(client, ['a']).send(['run'], event); + sending = false; + await signal.promise; + signal = deferred(); + await sendObserved(at(client, ['b']), ['run'], signal); + detachA(); + detachA(); + signal = deferred(); + at(client, ['a']).send(['ignored'], event); + await sendObserved(at(client, ['b']), ['after-detach'], signal); + router.close(); + signal = deferred(); + let attachmentReusableAfterDetach = false; + const replacement = server.receive({ + message() { + attachmentReusableAfterDetach = true; + signal.resolve(); + }, + }); + router.close(); + await sendObserved(client, ['replacement'], signal); + replacement(); + return { deliveries, receiverRanDuringSend, duplicateEndpointAttachmentRefused, attachmentReusableAfterDetach }; +} +async function overlap() { + const [client, server] = await ownedPair(); + const router = createDispatcher(server), + deliveries: string[] = []; + let signal = deferred(); + router.select(['a']).receive({ + message(path) { + deliveries.push(`parent:${path.join('/')}`); + signal.resolve(); + }, + }); + const detach = router.select(['a', 'b']).receive({ + message(path) { + deliveries.push(`deep:${path.join('/')}`); + signal.resolve(); + }, + }); + const duplicateRouteRefused = refused(() => router.select(['a']).receive({})); + await sendObserved(client, ['a', 'b', 'run'], signal); + detach(); + signal = deferred(); + await sendObserved(client, ['a', 'b', 'run'], signal); + router.close(); + return { deliveries, duplicateRouteRefused }; +} +async function composition() { + const [caller, inbound] = await ownedPair(), + [outbound, destination] = await ownedPair(); + let entered: Message | undefined, arrived: Message | undefined; + const associated = new WeakMap(), + marker = {}; + let framePreserved = true, + returnIdentityPreserved = true, + associatedContextPreserved = false; + const left = observe(inbound, { + receive(_path, message) { + entered = message; + }, + }); + const right = observe(outbound, { + send(_path, message) { + framePreserved &&= message === entered && message.frame === entered?.frame; + returnIdentityPreserved &&= message.return !== undefined && message.return === entered?.return; + }, + }); + const detach = forward(left, right); + const router = createDispatcher( + observe(destination, { + receive(_path, message) { + arrived = message; + if (message.return) associated.set(message.return, marker); + }, + }), + ); + const view = router.select(['a']).select(['b']); + const captured = deferred(); + let deliveredPath: Path = []; + const stop = view.receive({ + message(path, message) { + deliveredPath = [...path]; + framePreserved &&= message === arrived && message.frame === arrived?.frame; + returnIdentityPreserved &&= message.return !== undefined && message.return === arrived?.return; + associatedContextPreserved = !!message.return && associated.get(message.return) === marker; + captured.resolve(message); + }, + }); + const result = deferred(); + const original: Message = { + frame: { + version: 1, + kind: 'request', + id: 'c:1', + params: { nested: [null, 42, 'value'] }, + traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', + tracestate: 'vendor=opaque', + meta: { key: 'value' }, + }, + return: { + wire: { + send(_path, message) { + if (message.frame.kind !== 'response' || message.frame.error) throw new Error('Expected successful reply.'); + result.resolve(message.frame.result); + }, + }, + }, + }; + const callerRouter = createDispatcher( + observe(caller, { + send(_path, message) { + framePreserved &&= message === original && message.frame === original.frame; + returnIdentityPreserved &&= message.return === original.return; + }, + }), + ); + const mounted = mount(new Map([['', callerRouter.select(['a']).select(['b'])]])); + const composed = at(mounted, ['']); + if ('receive' in composed || 'close' in composed) throw new Error('Selected access grants ownership.'); + composed.send(['run', ''], original); + const request = await captured.promise; + detach(); + detach(); + stop(); + view.close(); + mounted.close(); + callerRouter.close(); + respond(request, 'answer'); + const reply = await result.promise; + let sourceStillUsable = false, + targetStillUsable = false; + let signal = deferred(); + const stopSource = inbound.receive({ + message() { + sourceStillUsable = true; + signal.resolve(); + }, + }); + await sendObserved(caller, ['probe'], signal); + stopSource(); + signal = deferred(); + router.select(['probe']).receive({ + message() { + targetStillUsable = true; + signal.resolve(); + }, + }); + await sendObserved(outbound, ['probe'], signal); + router.close(); + return { + deliveredPath, + framePreserved, + returnIdentityPreserved, + associatedContextPreserved, + reply, + borrowedEndpointUsableAfterDetach: sourceStillUsable && targetStillUsable, + }; +} +async function opaquePaths() { + const [client, server] = await ownedPair(); + const router = createDispatcher(server), + result: string[] = []; + for (const [path, name] of [ + [[''], 'empty'], + [['a/b'], 'slash'], + [['a', 'b'], 'split'], + [['é'], 'composed'], + [['é'], 'decomposed'], + ] as const) { + const signal = deferred(); + router.select(path).receive({ + message() { + result.push(name); + signal.resolve(); + }, + }); + await sendObserved(client, path, signal); + } + router.close(); + return result; +} +async function selectedEndpoints() { + const [client, root] = await ownedPair(), + router = createDispatcher(root); + const a = router.select(['scope']).select(['a']), + b = router.select(['scope', 'b']), + detached = router.select(['detached']); + const notifications = { active: 0, detached: 0, sibling: 0 }; + let nestedPath: Path = [], + selectedSendPath: Path = [], + siblingAfterViewClose = false, + routeReusable = false; + const initial = a.receive({ + message() { + throw new Error('Detached receiver ran.'); + }, + }); + const duplicateReceiveRefused = refused(() => a.receive({})); + initial(); + initial(); + let signal = deferred(); + const rootClosed = deferred(); + a.receive({ + message(path) { + nestedPath = [...path]; + signal.resolve(); + }, + closed() { + notifications.active++; + }, + }); + initial(); + b.receive({ + message() { + siblingAfterViewClose = true; + signal.resolve(); + }, + closed() { + notifications.sibling++; + rootClosed.resolve(); + }, + }); + const unused = detached.receive({ + closed() { + notifications.detached++; + }, + }); + unused(); + detached.close(); + detached.close(); + client.receive({ + message(path) { + selectedSendPath = [...path]; + signal.resolve(); + }, + }); + await sendObserved(client, ['scope', 'a', 'in'], signal); + signal = deferred(); + await sendObserved(a, ['out'], signal); + a.close(); + a.close(); + const closedReceiveRefused = refused(() => a.receive({})), + closedSendRefused = refused(() => a.send(['ignored'], event)); + signal = deferred(); + const stop = router.select(['scope', 'a']).receive({ + message() { + routeReusable = true; + signal.resolve(); + }, + }); + await sendObserved(client, ['scope', 'a', 'replacement'], signal); + signal = deferred(); + await sendObserved(client, ['scope', 'b', 'sibling'], signal); + stop(); + root.close(); + root.close(); + await rootClosed.promise; + const rootReceiveRefused = refused(() => root.receive({})), + viewAfterRootCloseRefused = refused(() => b.receive({})); + b.close(); + return { + nestedPath, + selectedSendPath, + duplicateReceiveRefused, + notifications, + closedReceiveRefused, + closedSendRefused, + rootReceiveRefused, + viewAfterRootCloseRefused, + siblingAfterViewClose, + routeReusable, + }; +} +// The requests use "c:1", not the oracle's "same-id" placeholder: bitwire/1 +// (nightseam v0.6.0's profile, which bitruntime ports) admits only role-prefixed +// decimal request identifiers. bitwire's runner instantiates the placeholder. +async function sameIDDelayedReplies() { + const [client, root] = await ownedPair(); + let admitted: ReturnAddress | undefined; + const router = createDispatcher( + observe(root, { + receive(_path, message) { + admitted = message.return; + }, + }), + ); + const view = router.select(['service']), + requests: Message[] = [], + capturedReturnIdentities: boolean[] = []; + let signal: ReturnType>; + const stop = view.receive({ + message(_path, message) { + requests.push(message); + capturedReturnIdentities.push(message.return !== undefined && message.return === admitted); + signal.resolve(); + }, + }); + const lateReplies: string[] = [], + replyIDs: string[] = []; + let replied: ReturnType>; + for (const [name, payload] of [ + ['left', 'first'], + ['right', 'second'], + ]) { + const reply: AddressedWire = { + send(_path, message) { + if (message.frame.kind !== 'response') throw new Error('Expected reply.'); + lateReplies.push(`${name}:${message.frame.result}`); + replyIDs.push(message.frame.id); + replied.resolve(); + }, + }; + signal = deferred(); + at(client, ['service']).send(['call'], { + frame: { version: 1, kind: 'request', id: 'c:1', params: payload }, + return: { wire: reply }, + }); + await signal.promise; + } + stop(); + view.close(); + const replacementDeliveries: string[] = []; + signal = deferred(); + router.select(['service']).receive({ + message(_path, message) { + if (message.frame.kind !== 'event') throw new Error('Old request reached replacement.'); + replacementDeliveries.push(String(message.frame.data)); + signal.resolve(); + }, + }); + client.send(['service', 'probe'], { frame: { version: 1, kind: 'event', data: 'probe' } }); + await signal.promise; + for (const request of requests.reverse()) { + replied = deferred(); + if (request.frame.kind !== 'request') throw new Error('Expected request.'); + respond(request, request.frame.params); + await replied.promise; + } + router.close(); + return { capturedReturnIdentities, lateReplies, replyIDs, replacementDeliveries }; +} +try { + const observations = { + siblings: await siblings(), + overlap: await overlap(), + composition: await composition(), + opaquePaths: await opaquePaths(), + selectedEndpoints: await selectedEndpoints(), + sameIDDelayedReplies: await sameIDDelayedReplies(), + }; + process.stdout.write(`${JSON.stringify(observations)}\n`); +} finally { + for (const close of cleanups.reverse()) close(); +} diff --git a/conformance/runtime/ts/declared.ts b/conformance/runtime/ts/declared.ts new file mode 100644 index 0000000..2988c92 --- /dev/null +++ b/conformance/runtime/ts/declared.ts @@ -0,0 +1,502 @@ +// Declared-composite driver for ADR0006 against bitruntime: a port of +// ../../current/ts/declared.ts, which runs this harness over nightseam v0.6.0. +// Two realizations share one harness: a test-only reference interpreter and +// bitruntime's child-only addressed mount. Both use bitruntime's at, forward, +// local pairs and WebSocket peers. Neither is a bitwire API, and the reference +// is not evidence about a production runtime. +import { readFileSync } from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; +import type { AddressedWire, Endpoint, Message, Path, ReturnAddress } from '@bitspark/bitwire'; +import { InvalidPathError, MissingPathError, at, forward, mount } from '@bitspark/bitruntime/core'; +import { CODE_NORMAL } from '@bitspark/bitruntime/transports'; +import { connected, kind } from './carrier.ts'; + +/** A node's own value: behavior at its empty relative path. Refusal is a value. */ +interface Origin { readonly name: string; readonly instance: number; handle(message: Message): void } +const refuse: Origin = { name: '', instance: 0, handle() { throw new MissingPathError(); } }; +type Entry = readonly [string, AddressedWire | undefined]; + +/** Constructs declared composites; parts are the construction owner's retained description. */ +interface Realization { + compose(own: Origin, entries: readonly Entry[]): AddressedWire; + parts(composite: AddressedWire): [Origin, Entry[]] | undefined; + expose(composite: AddressedWire): AddressedWire; + teardown(): void; +} +class Unsupported extends Error {} + +function wellFormed(segment: string): boolean { + for (let i = 0; i < segment.length; i++) { + const unit = segment.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const low = segment.charCodeAt(++i); + if (!(low >= 0xdc00 && low <= 0xdfff)) return false; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return false; + } + return true; +} + +// ---- Reference realization: a test-only interpreter of ADR0006 ---- +class Reference implements Realization { + private readonly retained = new Map }>(); + compose(own: Origin, entries: readonly Entry[]): AddressedWire { + if (!own) throw new Error('A composite requires its own value'); + const children = new Map(); + for (const [key, child] of entries) { + if (!wellFormed(key)) throw new Error('Segment outside the exact UTF-8 key image'); + if (!child) throw new Error('Missing child access'); + if (children.has(key)) throw new Error('Conflicting child segment'); + children.set(key, child); + } + const send = (path: Path, message: Message): void => { + if (path.length === 0) return own.handle(message); + const child = children.get(path[0]!); + if (!child) throw new MissingPathError(); + child.send(path.slice(1), message); + }; + const access: AddressedWire = { send(path, message) { + if (!path.every(wellFormed)) throw new InvalidPathError(); + send(path, message); + } }; + this.retained.set(access, { own, children }); + return access; + } + parts(w: AddressedWire): [Origin, Entry[]] | undefined { + const found = this.retained.get(w); + return found && [found.own, [...found.children]]; + } + expose(w: AddressedWire): AddressedWire { return w; } + teardown(): void {} +} + +// ---- Production realization: bitruntime's child-only addressed mount ---- +class Production implements Realization { + private readonly retained = new Map(); + private readonly mounts: Endpoint[] = []; + compose(own: Origin, entries: readonly Entry[]): AddressedWire { + if (own !== refuse) throw new Unsupported('origin-bearing composite'); + const routes = new Map(); + for (const [key, child] of entries) { + const endpoint = child && 'receive' in child && 'close' in child ? child as Endpoint + : child && { send: (path: Path, message: Message) => child.send(path, message), + receive(): () => void { throw new Error('Send-only child'); }, close() {} }; + routes.set(key, endpoint as Endpoint); + } + const mounted = mount(routes); + routes.clear(); // A retained description must not depend on the caller's map. + this.retained.set(mounted, [...entries]); + this.mounts.push(mounted); + return mounted; + } + parts(w: AddressedWire): [Origin, Entry[]] | undefined { + const found = this.retained.get(w); + return found && [refuse, [...found]]; + } + expose(w: AddressedWire): AddressedWire { return at(w, []); } + teardown(): void { for (const mounted of this.mounts) mounted.close(CODE_NORMAL, 'released'); } +} + +// ---- Instrumented child access and interception used by the fixtures ---- +interface Forwarded { path: string[]; count: number } +class Env { + expected?: Message; + readonly contexts = new Map(); + readonly marker = {}; + unchanged = true; + last?: Forwarded; + readonly trace: unknown[] = []; + private readonly instances = new Map(); + readonly sender: AddressedWire; + constructor(sender: AddressedWire) { this.sender = sender; } + verify(message: Message): void { + this.unchanged &&= !!this.expected && isDeepStrictEqual(message.frame, this.expected.frame) + && message.return === this.expected.return && !!message.return && this.contexts.get(message.return) === this.marker; + } + next(name: string): number { + const value = (this.instances.get(name) ?? 0) + 1; + this.instances.set(name, value); + return value; + } + newOrigin(name: string): Origin { + let count = 0; + return { name, instance: this.next(name), handle: message => { + this.verify(message); + this.last = { path: [name], count: ++count }; + at(this.sender, [name]).send([], message); + } }; + } + newAccess(name: string): Access { return new Access(name, this.next(name), this); } +} +/** Complete, stateful child access with its own instance counter. */ +class Access implements AddressedWire { + count = 0; + readonly name: string; + readonly instance: number; + private readonly env: Env; + constructor(name: string, instance: number, env: Env) { this.name = name; this.instance = instance; this.env = env; } + send(path: Path, message: Message): void { + this.env.verify(message); + this.env.last = { path: [this.name, ...path], count: ++this.count }; + at(this.env.sender, [this.name]).send(path, message); + } +} +interface Policy { id: string; instance: number; limit: number; remaining: number } +/** Interception composed around access; it is not a node value. */ +class Guard implements AddressedWire { + readonly policy: Policy; + readonly inner: AddressedWire; + private readonly env: Env; + constructor(policy: Policy, inner: AddressedWire, env: Env) { this.policy = policy; this.inner = inner; this.env = env; } + send(path: Path, message: Message): void { + this.env.trace.push(['check', this.policy.id, [...path]]); + if (this.policy.remaining === 0) throw new Error('Guard refused'); + if (this.policy.remaining > 0) this.policy.remaining--; + this.inner.send(path, message); + } +} + +// ---- Fixture interpretation ---- +interface Declaration { id: string; origin?: string | null; children?: [string, string][]; access?: string } +interface Step { + op: string; path?: string[]; keep?: string[][]; selections?: string[][]; via?: string; + key?: string; to?: string; node?: string; mode?: string; id?: string; origin?: string | null; limit?: number; +} +interface Case { id: string; kind?: string; root: string; fault?: string; steps?: Step[]; relay?: boolean; mount?: boolean } +interface Fixture { declarations: Declaration[]; cases: Case[] } + +const utf8 = new TextEncoder(); +function byteOrder(a: string, b: string): number { + const x = utf8.encode(a), y = utf8.encode(b); + for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i]! - y[i]!; + return x.length - y.length; +} +function sameEntries(got: readonly Entry[], want: readonly Entry[]): boolean { + if (got.length !== want.length) return false; + const index = new Map(want); + for (const [key, child] of got) { + if (!index.has(key) || index.get(key) !== child) return false; + index.delete(key); + } + return index.size === 0; +} + +class Harness { + readonly env: Env; + private readonly declarations = new Map(); + private readonly built = new Map(); + private readonly origins = new Map(); + root!: AddressedWire; + view?: AddressedWire; + partsExact = true; + readonly R: Realization; + constructor(R: Realization, fixture: Fixture, sender: AddressedWire) { + this.R = R; + this.env = new Env(sender); + for (const d of fixture.declarations) { + if (this.declarations.has(d.id)) throw new Error('Duplicate declaration'); + this.declarations.set(d.id, d); + } + } + /** Records R1 and mutates the caller's input afterward: the composite keeps its own copy. */ + construct(own: Origin, entries: readonly Entry[]): AddressedWire { + const input: Entry[] = [...entries]; + const w = this.R.compose(own, input); + input.fill(['mutated', undefined]); + let found = this.R.parts(w); + let exact = !!found && found[0] === own && sameEntries(found[1], entries); + if (exact && found![1].length) { + found![1][0] = ['mutated', undefined]; // Returned parts are a copy of the retained description. + found = this.R.parts(w); + exact = !!found && found[0] === own && sameEntries(found[1], entries); + } + this.partsExact &&= exact; + return w; + } + private origin(name: string): Origin { + let found = this.origins.get(name); + if (!found) this.origins.set(name, found = this.env.newOrigin(name)); + return found; + } + build(id: string, fault = '', rootID = '', visiting = new Set()): AddressedWire { + const found = this.built.get(id); + if (found) return found; + const d = this.declarations.get(id); + if (!d) throw new Error('Missing declaration'); + if (d.access) { + const w = this.env.newAccess(d.access); + this.built.set(id, w); + return w; + } + if (visiting.has(id)) throw new Error('Cyclic declaration'); + visiting.add(id); + const children = [...(d.children ?? [])]; + if (id === rootID && fault === 'cycle') children.push(['loop', rootID]); + const entries: Entry[] = children.map(([key, child]) => [key, this.build(child, fault, rootID, visiting)]); + if (id === rootID && fault === 'duplicate') entries.push(['a', this.build('leaf')]); + if (id === rootID && fault === 'invalidKey') entries.push(['\ud800', this.build('leaf')]); + if (id === rootID && fault === 'missingChild') entries.push(['hole', undefined]); + const w = this.construct(d.origin == null ? refuse : this.origin(d.origin), entries); + visiting.delete(id); + this.built.set(id, w); + return w; + } + caller(): AddressedWire { return this.root instanceof Guard ? this.root : this.R.expose(this.root); } + render(w: AddressedWire): unknown { + const found = this.R.parts(w); + if (found) { + const [own, entries] = found; + return { + origin: own === refuse ? null : [own.name, own.instance], + children: entries.sort(([a], [b]) => byteOrder(a, b)).map(([key, child]) => [key, this.render(child!)]), + }; + } + if (w instanceof Access) return { access: [w.name, w.instance] }; + if (w instanceof Guard) return { guard: [w.policy.id, w.policy.instance], inner: this.render(w.inner) }; + return 'opaque'; + } + structure(w: AddressedWire, path: readonly string[]): unknown { + if (!path.length) return this.render(w); + const found = this.R.parts(w); + if (!found) throw new Error('Structure path leaves the declared composites'); + const child = found[1].find(([key]) => key === path[0]); + return child ? this.structure(child[1]!, path.slice(1)) : 'missing'; + } + /** Rebuilds declared ancestors from retained parts; a guard keeps its policy instance. */ + replaceAt(w: AddressedWire, path: readonly string[], f: (w: AddressedWire) => AddressedWire): AddressedWire { + if (w instanceof Guard && path.length) return new Guard(w.policy, this.replaceAt(w.inner, path, f), this.env); + if (!path.length) return f(w); + const found = this.R.parts(w); + if (!found) throw new Error('Edit path leaves the declared composites'); + return this.construct(found[0], found[1].map(([key, child]) => [key, key === path[0] ? this.replaceAt(child!, path.slice(1), f) : child])); + } + /** One complete cut: kept subtrees are reused whole; other declared composites are rebuilt from parts. */ + rebuild(w: AddressedWire, where: readonly string[], keep: readonly (readonly string[])[]): AddressedWire { + if (keep.some(path => isDeepStrictEqual([...path], [...where]))) return w; + if (w instanceof Guard) return new Guard(w.policy, this.rebuild(w.inner, where, keep), this.env); + const found = this.R.parts(w); + if (!found) return w; // Opaque child access is retained whole. + return this.construct(found[0], found[1].map(([key, child]) => [key, this.rebuild(child!, [...where, key], keep)])); + } + copy(w: AddressedWire): AddressedWire { + if (w instanceof Access) return this.env.newAccess(w.name); + const found = this.R.parts(w); + if (!found) throw new Error('Cannot copy opaque access'); + const own = found[0] === refuse ? refuse : this.env.newOrigin(found[0].name); + return this.construct(own, found[1].map(([key, child]) => [key, this.copy(child!)])); + } +} + +function mailbox() { + const values: T[] = []; + let pending: ((value: T) => void) | undefined; + return { + put(value: T) { + if (pending) { const resolve = pending; pending = undefined; resolve(value); } + else values.push(value); + }, + async take(): Promise { + if (values.length) return values.shift()!; + if (pending) throw new Error('Concurrent mailbox reads'); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending = undefined; reject(new Error('Delivery deadline exceeded')); }, 5000); + pending = value => { clearTimeout(timer); resolve(value); }; + }); + }, + }; +} +type Deliveries = ReturnType>; +class Scope { + cleanups: (() => void)[] = []; + close(): void { for (const cleanup of this.cleanups.reverse()) cleanup(); } + pair(): Promise<[Endpoint, Endpoint]> { + return connected(release => this.cleanups.push(release)); + } + async carriers(test: Case): Promise<[Endpoint, AddressedWire, Endpoint]> { + const [source, first] = await this.pair(); + let sender: AddressedWire = source, receiver = first; + if (test.mount) { + const mounted = mount(new Map([['mounted', source]])); + sender = at(mounted, ['mounted']); + this.cleanups.push(() => mounted.close(CODE_NORMAL, 'done')); + } + if (test.relay) { + const [outgoing, target] = await this.pair(); + this.cleanups.push(forward(receiver, outgoing)); + receiver = target; + } + return [source, sender, receiver]; + } +} +const event = (value: string): Message => ({ frame: { version: 1, kind: 'event', data: value } }); +const refuseWire: AddressedWire = { send() { throw new MissingPathError(); } }; + +async function send(h: Harness, w: AddressedWire, path: Path, message: Message, deliveries: Deliveries): Promise { + h.env.expected = message; + h.env.last = undefined; + try { w.send(path, message); } catch { + if (h.env.last) throw new Error('A destination accepted a refused send'); + h.env.trace.push(['refused']); + return; + } + const got = await deliveries.take(); + if (!isDeepStrictEqual(got.message.frame, message.frame)) throw new Error('Message changed or unexpected delivery'); + const last = h.env.last as Forwarded | undefined; + if (!last || !isDeepStrictEqual([...got.path], last.path)) throw new Error('Delivery does not match its declared destination'); + h.env.trace.push(['delivered', [...got.path], last.count]); +} +function marked(h: Harness, value: string): Message { + const message: Message = { ...event(value), return: { wire: refuseWire } }; + h.env.contexts.set(message.return!, h.env.marker); + return message; +} +async function borrowed(sender: AddressedWire, deliveries: Deliveries): Promise { + try { sender.send(['borrowed'], event('borrowed')); } catch { return false; } + const got = await deliveries.take(); + return isDeepStrictEqual(got.path, ['borrowed']) && got.message.frame.kind === 'event' && got.message.frame.data === 'borrowed'; +} +function refusal(error: unknown): unknown { + return error instanceof Unsupported ? { unsupported: 'originBearingComposite' } : { construction: 'refused' }; +} + +async function apply(h: Harness, test: Case, index: number, s: Step, deliveries: Deliveries): Promise { + const edit = (f: (own: Origin, entries: Entry[]) => AddressedWire) => (w: AddressedWire): AddressedWire => { + const found = h.R.parts(w); + if (!found) throw new Error('Edit of opaque access'); + return f(found[0], found[1]); + }; + const policy = (id: string, limit: number): Policy => ({ id, instance: h.env.next(`policy:${id}`), limit, remaining: limit }); + switch (s.op) { + case 'send': + case 'invalidPath': { + let w = h.caller(); + if (s.via === 'view') { + if (!h.view) throw new Error('No captured view'); + w = h.view; + } + for (const prefix of s.selections ?? []) w = at(w, prefix); + await send(h, w, s.op === 'invalidPath' ? ['\ud800'] : s.path!, marked(h, `${test.id}:${index}`), deliveries); + break; + } + case 'direct': await send(h, h.build(s.node!), s.path!, marked(h, `${test.id}:${index}`), deliveries); break; + case 'structure': { + const root = h.root instanceof Guard && s.path!.length ? h.root.inner : h.root; + h.env.trace.push(['structure', h.structure(root, s.path!)]); + break; + } + case 'rebuild': h.root = h.rebuild(h.root, [], s.keep!); break; + case 'origin': + h.root = h.replaceAt(h.root, s.path!, edit((own, entries) => + h.construct(s.origin == null ? refuse : h.env.newOrigin(own.name), entries))); + break; + case 'substitute': + h.root = h.replaceAt(h.root, s.path!, w => s.mode === 'copy' ? h.copy(w) : h.rebuild(w, [], [])); + break; + case 'omit': + case 'rename': + case 'add': + h.root = h.replaceAt(h.root, s.path!, edit((own, entries) => { + const next: Entry[] = entries.filter(([key]) => !(key === s.key && s.op === 'omit')) + .map(([key, child]) => [key === s.key && s.op === 'rename' ? s.to! : key, child]); + if (s.op === 'add') next.push([s.key!, h.build(s.node!)]); + return h.construct(own, next); + })); + break; + case 'replace': h.root = h.replaceAt(h.root, s.path!, () => h.build(s.node!)); break; + case 'view': + h.view = h.caller(); + for (const prefix of s.selections ?? []) h.view = at(h.view, prefix); + break; + case 'guard': h.root = new Guard(policy(s.id!, s.limit!), h.root, h.env); break; + case 'freshGuard': { + const g = h.root as Guard; + h.root = new Guard(policy(g.policy.id, g.policy.limit), g.inner, h.env); + break; + } + case 'guardChild': + h.root = h.replaceAt(h.root, [...s.path!, s.key!], w => new Guard(policy(s.id!, s.limit!), w, h.env)); + break; + case 'rebuildFromViews': { + const g = h.root as Guard; + const found = h.R.parts(g.inner); + if (!found) throw new Error('Guarded access is not a composite'); + h.root = new Guard(g.policy, h.construct(found[0], found[1].map(([key]) => [key, at(g, [key])])), h.env); + break; + } + case 'teardown': h.R.teardown(); break; + default: throw new Error('Unknown step: ' + s.op); + } +} + +async function observe(R: Realization, fixture: Fixture, test: Case): Promise { + const scope = new Scope(); + try { + const [source, sender, receiver] = await scope.carriers(test); + const deliveries: Deliveries = mailbox(); + scope.cleanups.push(receiver.receive({ message(path, message) { deliveries.put({ path: [...path], message }); } })); + const h = new Harness(R, fixture, sender); + try { h.root = h.build(test.root, test.fault, test.root); } catch (error) { return refusal(error); } + for (const [index, step] of (test.steps ?? []).entries()) await apply(h, test, index, step, deliveries); + const caller = h.caller(); + const sendOnly = !('receive' in caller) && !('close' in caller); + R.teardown(); + return { trace: h.env.trace, partsExact: h.partsExact, unchanged: h.env.unchanged, sendOnly, borrowedUsable: await borrowed(source, deliveries) }; + } finally { scope.close(); } +} + +/** Admits a real request, then rebuilds, rebinds and tears down before its late reply and captured cancel. */ +async function pending(R: Realization, fixture: Fixture, test: Case): Promise { + const scope = new Scope(); + try { + const [source, sender, receiver] = await scope.carriers(test); + const captured = mailbox(), cancelled = mailbox(), replies = mailbox(); + const deliveries: Deliveries = mailbox(); + scope.cleanups.push(receiver.receive({ message(path, message) { + if (message.frame.kind === 'event') { deliveries.put({ path: [...path], message }); return; } + const lifecycle = message.return!.wire; + lifecycle.send(['invocation.capture', 'old'], { + frame: { version: 1, kind: 'event', data: null }, + return: { wire: { send(path, message) { + if (path.length || message.frame.kind !== 'cancel') throw new Error('Invalid captured control'); + cancelled.put('old'); + } } }, + }); + lifecycle.send(['invocation.ready', 'old'], event('ready')); + lifecycle.send(['invocation.begin', 'old'], event('begin')); + captured.put(message); + } })); + const h = new Harness(R, fixture, sender); + try { h.root = h.build(test.root, '', test.root); } catch (error) { return refusal(error); } + const original: ReturnAddress = { wire: { send(path, message) { + if (path.length || message.frame.kind !== 'response' || message.frame.error || typeof message.frame.result !== 'string') throw new Error('Unexpected reply'); + replies.put(message.frame.result); + } } }; + const request: Message = { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: original }; + h.env.contexts.set(original, h.env.marker); + h.env.expected = request; + at(at(h.caller(), ['a']), ['b']).send([], request); + const old = await captured.take(); + if (old.frame.kind !== 'request') throw new Error('Expected request'); + h.root = h.rebuild(h.root, [], []); + for (const path of [['a', 'b'], ['alias']]) await apply(h, test, 0, { op: 'replace', path, node: 'replacement' }, deliveries); + await send(h, at(h.caller(), ['alias']), [], marked(h, 'new'), deliveries); + R.teardown(); + old.return!.wire.send([], { frame: { version: 1, kind: 'response', id: old.frame.id, result: 'old' } }); + const lateReply = await replies.take(); + old.return!.wire.send(['invocation.control'], { frame: { version: 1, kind: 'cancel', id: old.frame.id } }); + const controls = [await cancelled.take()]; + old.return!.wire.send(['invocation.release', 'old'], event('release')); + old.return!.wire.send(['invocation.done', 'old'], event('done')); + return { trace: h.env.trace, lateReply, cancelled: controls, unchanged: h.env.unchanged, borrowedUsable: await borrowed(source, deliveries) }; + } finally { scope.close(); } +} + +kind(); +const [realization, inputPath] = process.argv.slice(2); +if (!['reference', 'production'].includes(realization ?? '') || !inputPath) throw new Error('Usage: declared.ts reference|production inputs.json'); +const fixture = JSON.parse(readFileSync(inputPath, 'utf8')) as Fixture; +const output = []; +for (const test of fixture.cases) { + const R = realization === 'production' ? new Production() : new Reference(); + output.push({ id: test.id, observations: await (test.kind === 'pending' ? pending(R, fixture, test) : observe(R, fixture, test)) }); +} +process.stdout.write(JSON.stringify(output) + '\n'); diff --git a/conformance/runtime/ts/lifecycle.ts b/conformance/runtime/ts/lifecycle.ts new file mode 100644 index 0000000..6d0638c --- /dev/null +++ b/conformance/runtime/ts/lifecycle.ts @@ -0,0 +1,71 @@ +// Records public invocation lifecycle observations from bitruntime's +// Invocation. A port of ../../current/ts/lifecycle.ts, which drives nightseam +// v0.6.0's Invocation with the same operations. Expectations are read only by +// bitwire's runner; this driver has no replacement ledger or routing machinery. +import { readFileSync } from 'node:fs'; +import type { AddressedWire, Message } from '@bitspark/bitwire'; +import { Invocation, type InvocationLimits } from '@bitspark/bitruntime/core'; + +interface Step { op: string; id?: string; label?: string } +interface Case { id: string; limits: InvocationLimits; steps: Step[] } + +function observe(test: Case): unknown[] { + let retirements = 0; + const controls: string[] = []; + const create = () => new Invocation(test.limits, () => { retirements++; }); + let invocation = create(); + // An opaque send-only facade: participants cannot discover the Invocation. + const facade = (owner: Invocation): AddressedWire => ({ send: (path, message) => owner.deliver(path, message) }); + let access = facade(invocation); + let previous: AddressedWire | undefined; + const rows: unknown[] = []; + const cancel: Message = { frame: { version: 1, kind: 'cancel', id: 'c:1' } }; + for (const action of test.steps) { + if (action.op === 'snapshot') { + rows.push({ label: action.label, retired: invocation.retired, retirements, controls: [...controls].sort() }); + continue; + } + let deliver: (() => void) | undefined; + const event: Message = { frame: { version: 1, kind: 'event', data: null } }; + switch (action.op) { + case 'replace': previous = access; invocation = create(); access = facade(invocation); break; + case 'settle': invocation.settle(); break; + case 'dispatchDone': invocation.dispatchDone(); break; + case 'cancel': deliver = () => access.send(['invocation.control'], cancel); break; + case 'oldCancel': { + if (!previous) throw new Error('oldCancel without a previous invocation'); + const old = previous; + deliver = () => old.send(['invocation.control'], cancel); + break; + } + case 'unknown': deliver = () => access.send(['unsupported.operation', 'participant'], event); break; + case 'wrongKind': deliver = () => access.send(['invocation.capture', 'participant'], cancel); break; + case 'missingSink': deliver = () => access.send(['invocation.capture', 'participant'], event); break; + case 'capture': case 'ready': case 'release': case 'begin': case 'done': { + if (action.id === undefined) throw new Error('missing participant identifier'); + const id = action.id; + const message: Message = action.op === 'capture' ? { + ...event, + return: { wire: { send(path, control) { + if (path.length !== 0 || control.frame.kind !== 'cancel') throw new Error('invalid control delivery'); + controls.push(`${id}:${control.frame.id}`); + } } }, + } : event; + deliver = () => access.send([`invocation.${action.op}`, id], message); + break; + } + default: throw new Error(`Unknown fixture operation: ${action.op}`); + } + let admitted = true; + try { deliver?.(); } catch (error) { + if (!action.label) throw error; + admitted = false; + } + if (action.label) rows.push({ label: action.label, admitted }); + } + return rows; +} + +if (process.argv.length !== 3) throw new Error('Usage: lifecycle.ts inputs.json'); +const fixture = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { cases: Case[] }; +process.stdout.write(`${JSON.stringify(fixture.cases.map(test => ({ id: test.id, observations: observe(test) })))}\n`); diff --git a/conformance/runtime/ts/package-lock.json b/conformance/runtime/ts/package-lock.json new file mode 100644 index 0000000..c7c170d --- /dev/null +++ b/conformance/runtime/ts/package-lock.json @@ -0,0 +1,467 @@ +{ + "name": "bitwire-conformance-runtime-ts", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bitwire-conformance-runtime-ts", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@bitspark/bitruntime": "https://github.com/Bitspark/bitruntime/releases/download/v0.2.0/bitspark-bitruntime-0.2.0.tgz", + "@bitspark/bitwire": "0.3.0", + "ws": "8.21.3" + }, + "devDependencies": { + "@types/node": "24.19.0", + "@types/ws": "8.18.1", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@bitspark/bitruntime": { + "version": "0.2.0", + "resolved": "https://github.com/Bitspark/bitruntime/releases/download/v0.2.0/bitspark-bitruntime-0.2.0.tgz", + "integrity": "sha512-1S62Lft9WdC9eX9lrEWYWgLMkeiEhZh1QTU/Ybd9NjleDAxYizi/n9D98w/sBGorKI2lkUmMBvTO1xFYul7lYQ==", + "license": "Apache-2.0", + "dependencies": { + "@bitspark/bitwire": "0.3.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@bitspark/bitwire": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.3.0.tgz", + "integrity": "sha512-6tqme+2nfJAp2xwmYIrO7hSSh+6TrJ7gPIb8SZX4dQAoZZHs/iqPPsZFv8vJsMUBejzgBxwNtOoC3PjI0KMPRw==", + "license": "Apache-2.0" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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/conformance/runtime/ts/package.json b/conformance/runtime/ts/package.json new file mode 100644 index 0000000..9235838 --- /dev/null +++ b/conformance/runtime/ts/package.json @@ -0,0 +1,19 @@ +{ + "name": "bitwire-conformance-runtime-ts", + "version": "0.0.0", + "private": true, + "description": "Test-only: runs Bitwire's independent cases against bitruntime's TypeScript package. Never published.", + "license": "Apache-2.0", + "type": "module", + "engines": { "node": ">=24.0.0" }, + "dependencies": { + "@bitspark/bitruntime": "https://github.com/Bitspark/bitruntime/releases/download/v0.2.0/bitspark-bitruntime-0.2.0.tgz", + "@bitspark/bitwire": "0.3.0", + "ws": "8.21.3" + }, + "devDependencies": { + "@types/node": "24.19.0", + "@types/ws": "8.18.1", + "typescript": "7.0.2" + } +} diff --git a/conformance/runtime/ts/trees.ts b/conformance/runtime/ts/trees.ts new file mode 100644 index 0000000..9b828ad --- /dev/null +++ b/conformance/runtime/ts/trees.ts @@ -0,0 +1,63 @@ +// Produces the observation object of ../../trees/ts/main.ts through +// bitruntime's production tree operations instead of the test-only +// interpreter: compose constructs every node, select and the node's at select, +// and send is the derived sending. Where a send is refused and its path lies in +// the UTF-8 image, asAddressed must refuse it too; that strengthens an +// observation without adding one. Expectations stay in ../../trees/expected.json +// and are compared only by bitwire's runner. The same operations as ../go/trees. +import type { Child, Key, Message, TreePath, Wire, WireTree } from '@bitspark/bitwire'; +import { asAddressed, compose, select, send } from '@bitspark/bitruntime/core'; + +const hex = (key: Key): string => [...key].map(b => b.toString(16).padStart(2, '0')).join(''); +const key = (...bytes: number[]): Key => new Uint8Array(bytes); +const utf8 = (text: string): Key => new TextEncoder().encode(text); +function refused(action: () => void): boolean { + try { action(); return false; } catch { return true; } +} + +const admissions: string[] = []; +const names = new Map(); +const node = (name: string, children: Child[] = []): WireTree => { + const own: Wire = { send(message: Message) { admissions.push(`${name}:${message.frame.kind}`); } }; + names.set(own, name); + return compose(own, children); +}; +const leaf = node('leaf'); +const refusing = compose({ send() { throw new Error('refused'); } }); +const tree = node('root', [ + [key(), node('empty')], [key(255), node('binary')], + [utf8('a/b'), refusing], [utf8('a'), node('branch', [[utf8('b'), leaf]])], +]); +const at = (path: TreePath): WireTree => { + const found = select(tree, path); + if (!found) throw new Error('missing'); + return found; +}; +const label = (path: TreePath) => names.get(at(path).own()); +const parts = tree.decompose(); +const rebuilt = compose(parts.own, parts.children); +const exposed = tree.children(); +exposed[1]![0][0] = 0; +const event: Message = { frame: { version: 1, kind: 'event', data: null } }; +send(tree, [key(255)], event); +const refusingSend = refused(() => send(tree, [utf8('a/b')], event)) + && refused(() => asAddressed(tree).send(['a/b'], event)); +const missing = select(tree, [key(0)]) === undefined + && refused(() => send(tree, [key(0)], event)) + && refused(() => asAddressed(tree).send(['\x00'], event)); +console.log(JSON.stringify({ + self: select(tree, []) === tree && tree.at([]) === tree, + binary: label([key(255)]), + emptyKey: label([key()]), + missing, + nested: label([utf8('a'), utf8('b')]), + nestedLaw: at([utf8('a')]).at([utf8('b')]) === at([utf8('a'), utf8('b')]), + children: tree.children().map(([k]) => hex(k)).sort(), + slashIsLiteral: at([utf8('a/b')]) !== at([utf8('a'), utf8('b')]), + partsIdentity: parts.own === tree.own() && parts.children[1]![1] === at([key(255)]), + rebuildIdentity: select(rebuilt, [utf8('a'), utf8('b')])?.own() === leaf.own(), + keyCopy: label([key(255)]) === 'binary', + refusingExists: select(tree, [utf8('a/b')]) !== undefined, + refusingSend, + admissions, +})); diff --git a/conformance/runtime/ts/tsconfig.json b/conformance/runtime/ts/tsconfig.json new file mode 100644 index 0000000..538cb5a --- /dev/null +++ b/conformance/runtime/ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/scripts/conformance-results.test.mjs b/scripts/conformance-results.test.mjs index ac07b00..815d4eb 100644 --- a/scripts/conformance-results.test.mjs +++ b/scripts/conformance-results.test.mjs @@ -104,10 +104,13 @@ test('production differs from the oracle only by exactly recorded gaps', () => { test('bitruntime keeps its own gap ledger, recorded only for the languages it runs', () => { const runtime = JSON.parse(readFileSync(new URL('../conformance/runtime/production-gaps.json', import.meta.url))); - const gaps = new Map(runtime.gaps.flatMap(gap => gap.cases.map(id => [id, gap.observed.go]))); - const rows = expectedRows().map(row => gaps.has(row.id) ? { id: row.id, observations: structuredClone(gaps.get(row.id)) } : row); - const result = compareProduction(declared, rows, runtime, 'go', 'bitruntime'); - assert.equal(result.gaps.length, gaps.size); - // No TypeScript observation is claimed before a TypeScript driver runs. - assert.throws(() => compareProduction(declared, rows, runtime, 'ts', 'bitruntime/ts'), /no ts observation/); + for (const language of ['go', 'ts']) { + const gaps = new Map(runtime.gaps.flatMap(gap => gap.cases.map(id => [id, gap.observed[language]]))); + const rows = expectedRows().map(row => gaps.has(row.id) ? { id: row.id, observations: structuredClone(gaps.get(row.id)) } : row); + const result = compareProduction(declared, rows, runtime, language, `bitruntime/${language}`); + assert.equal(result.gaps.length, gaps.size); + } + // No observation is claimed for a language whose driver does not run. + assert.deepEqual([...new Set(runtime.gaps.flatMap(gap => Object.keys(gap.observed)))].sort(), ['go', 'ts']); + assert.throws(() => compareProduction(declared, expectedRows(), runtime, 'rust', 'bitruntime/rust'), /no rust observation/); }); diff --git a/scripts/conformance-runtime.mjs b/scripts/conformance-runtime.mjs index dddc7ba..7fe0237 100644 --- a/scripts/conformance-runtime.mjs +++ b/scripts/conformance-runtime.mjs @@ -1,11 +1,12 @@ -// Runs Bitwire's existing independent cases against bitruntime's Go -// implementation. Expectations come only from Bitwire's case files; drivers -// receive inputs without them. bitruntime is consumed as a pinned public module -// with no replacement, never as a local checkout. +// Runs bitwire's existing independent cases against bitruntime's Go and +// TypeScript implementations. Expectations come only from bitwire's case files; +// drivers receive inputs without them. bitruntime is consumed as a pinned public +// Go module with no replacement and as a pinned public release tarball, never as +// a local checkout. import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,6 +14,7 @@ import { compareCases, compareProduction, declaredInputs, lifecycleInputs, night const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const module = join(root, 'conformance/runtime/go'); +const tsModule = join(root, 'conformance/runtime/ts'); const bytes = path => readFileSync(join(root, path)); const read = path => JSON.parse(bytes(path)); const pin = read('conformance/runtime/bitruntime.json'); @@ -20,10 +22,21 @@ assert.equal(pin.repository, 'https://github.com/Bitspark/bitruntime'); assert.equal(pin.module, 'github.com/Bitspark/bitruntime'); assert.match(pin.revision, /^[a-f0-9]{40}$/); assert.ok(pin.version.endsWith(`-${pin.revision.slice(0, 12)}`) || /^v\d+\.\d+\.\d+$/.test(pin.version), 'version names the pinned revision'); +assert.equal(pin.npm.package, '@bitspark/bitruntime'); +assert.match(pin.npm.version, /^\d+\.\d+\.\d+$/); +assert.equal(pin.npm.tag, `v${pin.npm.version}`); +assert.match(pin.npm.revision, /^[a-f0-9]{40}$/); +assert.equal(pin.npm.tarball, `${pin.repository}/releases/download/${pin.npm.tag}/bitspark-bitruntime-${pin.npm.version}.tgz`, 'bitruntime is the public release asset'); +assert.match(pin.npm.sha256, /^[a-f0-9]{64}$/); +assert.match(pin.npm.integrity, /^sha512-[A-Za-z0-9+/]{86}==$/); +assert.equal(`v${pin.npm.bitwireVersion}`, pin.bitwireVersion, 'Go and TypeScript run against the same contract'); const args = process.argv.slice(2); -if (args.some(arg => !['--keep-scratch'].includes(arg))) { - throw new Error('Usage: node scripts/conformance-runtime.mjs [--keep-scratch]'); +if (args.some(arg => !['--language=go', '--language=ts', '--keep-scratch'].includes(arg))) { + throw new Error('Usage: node scripts/conformance-runtime.mjs [--language=go|ts] [--keep-scratch]'); } +const choice = args.find(arg => arg.startsWith('--language='))?.split('=')[1]; +const languages = choice ? [choice] : ['go', 'ts']; +if (languages.includes('ts') && !process.features.typescript) throw new Error('Node 24 or later with type stripping is required'); // The oracle files, exactly as this checkout holds them. const files = { @@ -33,10 +46,11 @@ const files = { trees: 'conformance/trees/expected.json', gaps: 'conformance/runtime/production-gaps.json', }; -const sha256 = path => createHash('sha256').update(bytes(path)).digest('hex'); +const digest = (algorithm, data, encoding = 'hex') => createHash(algorithm).update(data).digest(encoding); +const sha256 = path => digest('sha256', bytes(path)); const lifecycle = read(files.lifecycle); const declared = read(files.declared); -// bitwire/1 admits only role-prefixed decimal request IDs, as Nightseam v0.6.0's +// bitwire/1 admits only role-prefixed decimal request IDs, as nightseam v0.6.0's // profile does; the two echoed placeholders are instantiated as c:1 there too. const composition = nightseamCompositionExpected(read(files.composition)); const trees = read(files.trees); @@ -65,7 +79,7 @@ function verifyModules() { for (const [path, , replacement] of graph) assert.equal(replacement ?? '', '', `${path} must not be replaced`); const version = path => graph.find(([name]) => name === path)?.[1]; assert.equal(version(pin.module), pin.version, 'bitruntime is not the pinned candidate'); - assert.equal(version('github.com/Bitspark/bitwire'), pin.bitwireVersion, 'Bitwire is not the pinned contract'); + assert.equal(version('github.com/Bitspark/bitwire'), pin.bitwireVersion, 'bitwire is not the pinned contract'); for (const [path, version_, revision, sum] of [ [pin.module, pin.version, pin.revision, pin.sum], ['github.com/Bitspark/bitwire', pin.bitwireVersion, pin.bitwireRevision, pin.bitwireSum], @@ -84,8 +98,62 @@ function build(name) { } const carrierEnv = (carrier, reverse) => ({ BITRUNTIME_CARRIER: carrier, BITRUNTIME_REVERSE: reverse }); -const race = run('go', ['env', 'CGO_ENABLED']).trim() === '1'; -if (!race && process.env.CI) throw new Error('CI must run the bitruntime drivers under the race detector'); +// npm ships beside node; running its CLI through node needs no shell on Windows. +function npm(arguments_, cwd) { + const cli = [join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), + join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')].find(existsSync); + if (cli) return run(process.execPath, [cli, ...arguments_], {}, cwd); + if (process.platform === 'win32') throw new Error('Cannot find npm beside node.'); + return run('npm', arguments_, {}, cwd); +} + +// The TypeScript module is installed in scratch from its own manifest, +// lockfile and registry configuration; the checkout gains no node_modules. +async function installTypeScript() { + const target = join(scratch, 'ts'); + mkdirSync(target); + for (const name of readdirSync(tsModule)) { + if (['package.json', 'package-lock.json', '.npmrc', 'tsconfig.json'].includes(name) || name.endsWith('.ts')) { + cpSync(join(tsModule, name), join(target, name)); + } + } + // The release asset itself, as go mod download checks a module's origin and sum. + const response = await fetch(pin.npm.tarball, { redirect: 'follow', signal: AbortSignal.timeout(120_000) }); + assert.equal(response.status, 200, `cannot fetch ${pin.npm.tarball}`); + const tarball = Buffer.from(await response.arrayBuffer()); + assert.equal(digest('sha256', tarball), pin.npm.sha256, `${pin.npm.tarball} changed`); + assert.equal(`sha512-${digest('sha512', tarball, 'base64')}`, pin.npm.integrity, `${pin.npm.tarball} changed`); + const tag = run('git', ['ls-remote', pin.repository, `refs/tags/${pin.npm.tag}^{}`], {}, root).trim().split('\t')[0]; + assert.equal(tag, pin.npm.revision, `${pin.npm.tag} is not ${pin.npm.revision}`); + + // The manifest and lockfile name exactly the pinned public artifacts. + const manifest = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')); + assert.equal(manifest.private, true); + assert.equal(manifest.dependencies[pin.npm.package], pin.npm.tarball, 'package.json does not pin the bitruntime release'); + assert.equal(manifest.dependencies['@bitspark/bitwire'], pin.npm.bitwireVersion, 'package.json does not pin the bitwire contract'); + const lock = JSON.parse(readFileSync(join(target, 'package-lock.json'), 'utf8')); + assert.equal(lock.lockfileVersion, 3); + for (const [path, entry] of Object.entries(lock.packages)) { + if (path === '') continue; + assert.ok(!entry.link && /^https:\/\//.test(entry.resolved ?? '') && entry.integrity, `${path} is not a public, integrity-pinned artifact`); + assert.ok(path === 'node_modules/@bitspark/bitwire' || !path.endsWith('/@bitspark/bitwire'), 'a second bitwire copy is installed'); + } + const entry = path => { const { version, resolved, integrity } = lock.packages[path]; return { version, resolved, integrity }; }; + assert.deepEqual(entry(`node_modules/${pin.npm.package}`), { version: pin.npm.version, resolved: pin.npm.tarball, integrity: pin.npm.integrity }); + assert.deepEqual(entry('node_modules/@bitspark/bitwire'), { version: pin.npm.bitwireVersion, resolved: pin.npm.bitwireTarball, integrity: pin.npm.bitwireIntegrity }); + + npm(['ci', '--ignore-scripts', '--no-audit', '--no-fund'], target); + npm(['ls', '--all'], target); + const installed = name => JSON.parse(readFileSync(join(target, 'node_modules', name, 'package.json'), 'utf8')); + const runtime = installed(pin.npm.package); + assert.deepEqual([runtime.name, runtime.version], [pin.npm.package, pin.npm.version], 'bitruntime is not the pinned release'); + assert.equal(runtime.dependencies['@bitspark/bitwire'], pin.npm.bitwireVersion); + assert.equal(installed('@bitspark/bitwire').version, pin.npm.bitwireVersion, 'bitwire is not the pinned contract'); + return target; +} + +const race = languages.includes('go') && run('go', ['env', 'CGO_ENABLED']).trim() === '1'; +if (languages.includes('go') && !race && process.env.CI) throw new Error('CI must run the bitruntime drivers under the race detector'); const results = []; function pass(label, detail) { results.push(`${label}: ${detail}`); @@ -94,53 +162,85 @@ function pass(label, detail) { const checkout = run('git', ['rev-parse', 'HEAD'], {}, root).trim(); const modified = run('git', ['status', '--porcelain', '--', ...Object.values(files)], {}, root).trim() !== ''; -console.log(`bitruntime ${pin.version} (${pin.revision}) against Bitwire ${pin.bitwireVersion}; scratch ${scratch}`); +console.log(`bitruntime ${pin.version} (${pin.revision}) and ${pin.npm.package} ${pin.npm.version} (${pin.npm.revision}) against bitwire ${pin.bitwireVersion}; scratch ${scratch}`); try { - verifyModules(); - const programs = Object.fromEntries(['lifecycle', 'composition', 'declared', 'trees'].map(name => [name, build(name)])); - const lifecycleInput = join(scratch, 'lifecycle-inputs.json'); writeFileSync(lifecycleInput, JSON.stringify(lifecycleInputs(lifecycle))); - compareCases(lifecycle, JSON.parse(run(programs.lifecycle, [lifecycleInput])), 'go/lifecycle'); - pass('go/lifecycle', `${lifecycle.cases.length}/${lifecycle.cases.length} independent cases through core.Invocation`); - - for (const [carrier, reverse] of carriers) { - const label = `go/composition/${carrier}/${reverse}`; - assert.deepEqual(JSON.parse(run(programs.composition, [], carrierEnv(carrier, reverse))), composition, `${label}: composition differs from Bitwire's oracle`); - pass(label, `${Object.keys(composition).length}/${Object.keys(composition).length} composition groups through NewPair/peers, dispatch, At, Mount and Forward`); - } - - // The reference interpreter must meet every expectation. Production runs - // bitruntime's addressed Mount and may differ only by a gap in its own ledger. const declaredInput = join(scratch, 'declared-inputs.json'); writeFileSync(declaredInput, JSON.stringify(declaredInputs(declared))); const total = declared.cases.length; - for (const realization of ['reference', 'production']) { + const toolchain = []; + + // Each language runs the same families on the same carriers with the same + // withheld inputs; only the invocation of a driver differs. + for (const language of languages) { + let driver, facilities; + if (language === 'go') { + verifyModules(); + const programs = Object.fromEntries(['lifecycle', 'composition', 'declared', 'trees'].map(name => [name, build(name)])); + driver = (name, arguments_ = [], env = {}) => run(programs[name], arguments_, env); + facilities = { + lifecycle: 'core.Invocation', composition: 'NewPair/peers, dispatch, At, Mount and Forward', + production: 'core.Mount/At/Forward', trees: 'core.Compose/Select/Send/AsAddressed', + }; + toolchain.push(`${run('go', ['env', 'GOVERSION']).trim()}${race ? ' with race detector' : ' (race detector unavailable locally)'}`); + } else { + const target = await installTypeScript(); + const compiler = join(target, 'node_modules/typescript/bin/tsc'); + run(process.execPath, [compiler, '-p', 'tsconfig.json'], {}, target); + driver = (name, arguments_ = [], env = {}) => run(process.execPath, [`${name}.ts`, ...arguments_], env, target); + facilities = { + lifecycle: 'Invocation', composition: 'pair/Peer, createDispatcher, at, mount and forward', + production: 'mount/at/forward', trees: 'compose/select/send/asAddressed', + }; + const typescript = run(process.execPath, [compiler, '--version'], {}, target).trim().replace(/^Version /, ''); + toolchain.push(`node ${process.version} with type stripping, checked by TypeScript ${typescript}`); + } + + compareCases(lifecycle, JSON.parse(driver('lifecycle', [lifecycleInput])), `${language}/lifecycle`); + pass(`${language}/lifecycle`, `${lifecycle.cases.length}/${lifecycle.cases.length} independent cases through ${facilities.lifecycle}`); + for (const [carrier, reverse] of carriers) { - const label = `go/declared/${realization}/${carrier}/${reverse}`; - const actual = JSON.parse(run(programs.declared, [realization, declaredInput], carrierEnv(carrier, reverse))); - if (realization === 'reference') { - compareCases(declared, actual, label); - pass(label, `${total}/${total} cases (test-only reference interpreter over bitruntime carriers)`); - } else { - const result = compareProduction(declared, actual, gaps, 'go', label); - pass(label, `${result.conforming.length}/${total} conform through core.Mount/At/Forward; ${result.gaps.length} match bitruntime's recorded gaps`); + const label = `${language}/composition/${carrier}/${reverse}`; + assert.deepEqual(JSON.parse(driver('composition', [], carrierEnv(carrier, reverse))), composition, `${label}: composition differs from bitwire's oracle`); + pass(label, `${Object.keys(composition).length}/${Object.keys(composition).length} composition groups through ${facilities.composition}`); + } + + // The reference interpreter must meet every expectation. Production runs + // bitruntime's addressed mount and may differ only by a gap in its own ledger. + for (const realization of ['reference', 'production']) { + for (const [carrier, reverse] of carriers) { + const label = `${language}/declared/${realization}/${carrier}/${reverse}`; + const actual = JSON.parse(driver('declared', [realization, declaredInput], carrierEnv(carrier, reverse))); + if (realization === 'reference') { + compareCases(declared, actual, label); + pass(label, `${total}/${total} cases (test-only reference interpreter over bitruntime carriers)`); + } else { + const result = compareProduction(declared, actual, gaps, language, label); + pass(label, `${result.conforming.length}/${total} conform through ${facilities.production}; ${result.gaps.length} match bitruntime's recorded gaps`); + } } } - } - assert.deepEqual(JSON.parse(run(programs.trees, [])), trees, 'go/trees: full tree observations differ from Bitwire\'s oracle'); - pass('go/trees', `${Object.keys(trees).length}/${Object.keys(trees).length} observations through core.Compose/Select/Send/AsAddressed`); + assert.deepEqual(JSON.parse(driver('trees')), trees, `${language}/trees: full tree observations differ from bitwire's oracle`); + pass(`${language}/trees`, `${Object.keys(trees).length}/${Object.keys(trees).length} observations through ${facilities.trees}`); + } console.log('\nReport'); - console.log(` Bitwire contract: ${pin.bitwireVersion} (github.com/Bitspark/bitwire at ${pin.bitwireRevision})`); - console.log(` Bitwire cases: checkout ${checkout}${modified ? ' with local changes to the case files' : ''}`); + console.log(` bitwire contract: ${pin.bitwireVersion} (github.com/Bitspark/bitwire at ${pin.bitwireRevision}; npm @bitspark/bitwire ${pin.npm.bitwireVersion})`); + console.log(` bitwire cases: checkout ${checkout}${modified ? ' with local changes to the case files' : ''}`); for (const [name, path] of Object.entries(files)) console.log(` ${sha256(path)} ${path}${name === 'gaps' ? ' (bitruntime gap ledger)' : ''}`); - console.log(` Implementation: ${pin.module} ${pin.version} (${pin.revision}), ${pin.profile}, Go only`); - console.log(` Toolchain: ${run('go', ['env', 'GOVERSION']).trim()}${race ? ' with race detector' : ' (race detector unavailable locally)'}`); - console.log(' Carriers: local = core.NewPair; peer/0 = WebSocket client sends; peer/1 = WebSocket server sends (engine/websocket)'); + if (languages.includes('go')) console.log(` Go: ${pin.module} ${pin.version} (${pin.revision}), ${pin.profile}`); + if (languages.includes('ts')) { + console.log(` TypeScript: ${pin.npm.package} ${pin.npm.version} (${pin.npm.tag} at ${pin.npm.revision}), ${pin.profile}`); + console.log(` ${pin.npm.sha256} ${pin.npm.tarball}`); + console.log(` ${sha256('conformance/runtime/ts/package-lock.json')} conformance/runtime/ts/package-lock.json`); + } + console.log(` Toolchain: ${toolchain.join('; ')}`); + console.log(' Carriers: local = the local pair (Go core.NewPair, TypeScript pair); peer/0 = WebSocket client sends;'); + console.log(' peer/1 = WebSocket server sends (Go engine/websocket; TypeScript engine Peer over ws)'); for (const line of results) console.log(` ${line}`); - console.log('Historical 0.2 addressed evidence (lifecycle, declared, composition) and 0.3 structural evidence (trees). TypeScript follows bitruntime\'s TS package.'); + console.log('Historical 0.2 addressed evidence (lifecycle, declared, composition) and 0.3 structural evidence (trees).'); } finally { if (args.includes('--keep-scratch')) console.log(`Retained scratch: ${scratch}`); else { From b3e24ca7f33189ba588cfe1e1ebb5a2586af3532 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 13:27:27 +0200 Subject: [PATCH 6/7] docs: describe the TypeScript half of the bitruntime runtime conformance Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/README.md | 71 ++++++++++++++++++++++++++++--------------- scripts/README.md | 3 +- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index fe574f1..7cccd6b 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -7,42 +7,63 @@ not establish the new structural contract. See [decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md). **Status: current released 0.2 composition and scoped lifecycle evidence, a -test-only reference, bitruntime Go candidate evidence against the same cases, -and a preserved historical 0.1.0 runtime baseline.** +test-only reference, bitruntime Go candidate and TypeScript release evidence +against the same cases, and a preserved historical 0.1.0 runtime baseline.** ## bitruntime runtime conformance -Run `node scripts/conformance-runtime.mjs`. The test-only Go module -[`runtime/go`](runtime/go/go.mod) runs bitwire's existing independent cases -against bitruntime, as the current baseline runs them against nightseam v0.6.0. -It requires bitwire v0.3.0 and the public bitruntime module pinned in -[bitruntime.json](runtime/bitruntime.json), with no replacement or local path. -The runner refuses any other module graph, revision or sum, runs every driver -with `GOWORK=off` under the race detector (required in CI), withholds every -expectation from the drivers, and reports the case files' SHA-256s. - -| Family | Driver and oracle | Carriers | Result at the pin | -| --- | --- | --- | --- | -| Lifecycle | [Cases](current/lifecycle.json) through `core.Invocation` | none, public state | 5/5 | -| Composition | The six [reference](reference/expected.json) groups; driver ported from nightseam's upstream one | local pair, WebSocket client and server sending | 6/6 on each | -| Declared, reference | The 39 [declared](declared/cases.json) cases through the test-only interpreter over bitruntime's carriers | the same three | 39/39 on each | -| Declared, production | The same cases through bitruntime's child-only addressed `core.Mount`, `At` and `Forward` | the same three | 20 conform; 19 match bitruntime's own [gap ledger](runtime/production-gaps.json) | -| Trees | The [0.3 observations](trees/expected.json) through `core.Compose`, `Select`, `Send` and `AsAddressed` | none, structural | 14/14 | +Run `node scripts/conformance-runtime.mjs`; `--language=go` or `--language=ts` +selects one half, and `--keep-scratch` retains the build and install tree. It +runs bitwire's existing independent cases against bitruntime's Go module and +TypeScript package, as the current baseline runs them against nightseam v0.6.0. +Both halves require bitwire v0.3.0 and the bitruntime artifacts pinned in +[bitruntime.json](runtime/bitruntime.json), never a local checkout or path. Each +driver receives its inputs with every expectation withheld, and one report gives +the case files' SHA-256s and every family's result in each language. + +- **Go.** The test-only module [`runtime/go`](runtime/go/go.mod) requires the + public bitruntime module at a pinned pre-release revision. The runner refuses + any other module graph, revision or sum, and runs every driver with + `GOWORK=off` under the race detector (required in CI). +- **TypeScript.** The private, test-only package + [`runtime/ts`](runtime/ts/package.json) depends on bitruntime's v0.2.0 + release asset, `@bitspark/bitwire` 0.3.0 and `ws` 8.21.3, and its + `.npmrc` takes the `@bitspark` scope from the public npm registry. The + runner downloads the release asset and checks its SHA-256 and integrity and + that tag v0.2.0 names the pinned revision. It refuses a lockfile entry that is + not a public, integrity-pinned artifact or that names another bitruntime or + bitwire, installs with `npm ci` in scratch, type-checks the drivers with + TypeScript and runs them with Node 24's type stripping. + +| Family | Driver and oracle | Carriers | Go at the pin | TypeScript at the pin | +| --- | --- | --- | --- | --- | +| Lifecycle | [Cases](current/lifecycle.json) through `Invocation` | none, public state | 5/5 | 5/5 | +| Composition | The six [reference](reference/expected.json) groups; drivers ported from nightseam's upstream ones | local pair, WebSocket client and server sending | 6/6 on each | 6/6 on each | +| Declared, reference | The 39 [declared](declared/cases.json) cases through the test-only interpreter over bitruntime's carriers | the same three | 39/39 on each | 39/39 on each | +| Declared, production | The same cases through bitruntime's child-only addressed mount, with its selection and forwarding | the same three | 20 conform; 19 match bitruntime's own [gap ledger](runtime/production-gaps.json) | the same | +| Trees | The [0.3 observations](trees/expected.json) through bitruntime's tree construction, selection, sending and addressed bridge | none, structural | 14/14 | 14/14 | + +The Go drivers use `core.Invocation`, `core.NewPair`, the WebSocket engine, +`dispatch`, `core.At`, `core.Mount`, `core.Forward`, `core.Compose`, +`core.Select`, `core.Send` and `core.AsAddressed`. The TypeScript drivers +use the same facilities from `@bitspark/bitruntime`: `Invocation`, `pair`, +`at`, `mount`, `forward`, `compose`, `select`, `send` and +`asAddressed` from `/core`, `createDispatcher` from `/dispatch`, and +`Peer` from `/engine` over `webSocketConnection` from `/transports`. As in the nightseam baseline, the two echoed `same-id` placeholders are instantiated as `c:1`: bitruntime's `bitwire/1` is nightseam v0.6.0's profile and refuses `same-id` as a request identifier on both carriers. Nothing else in any oracle changes. bitruntime keeps its own gap ledger so a gap it closes is -removed there, never from nightseam's. Its observations equal nightseam's: -`core.Mount` is a renamed port, and bitruntime deliberately omits the unreleased -declared-composition API that decision 0012 supersedes. No case reaches -bitruntime's documented forwarding and disconnection changes, because every -refusal in these fixtures happens before a carrier or forwarder. +removed there, never from nightseam's. Its observations equal nightseam's in +both languages: each mount is a renamed port, and bitruntime deliberately omits +the unreleased declared-composition API that decision 0012 supersedes. No case +reaches bitruntime's documented forwarding and disconnection changes, because +every refusal in these fixtures happens before a carrier or forwarder. Lifecycle, composition and declared results remain evidence about the 0.2 addressed contract (`AddressedWire` in 0.3); trees is the 0.3 structural -contract. The module is Go only; TypeScript follows once bitruntime publishes -its TypeScript package. +contract. ## Current released runtime baseline diff --git a/scripts/README.md b/scripts/README.md index 527150d..fb0d2b7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,8 @@ cases through pinned public Nightseam implementations. composition and scoped lifecycle baseline, described in [current conformance](../conformance/current/README.md). Both run in the required conformance CI job. `node scripts/conformance-runtime.mjs` runs the same -independent cases against the pinned bitruntime Go module in its own CI job; see +independent cases against the pinned bitruntime Go module and TypeScript release +package in its own CI job; see [bitruntime runtime conformance](../conformance/README.md#bitruntime-runtime-conformance). `node scripts/smoke-packed.mjs` installs npm and Go artifacts outside the checkout. These are separate from declaration checks. From e5240d8fce366df10690fe4c24931eb9d4d0eeb8 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 15:47:38 +0200 Subject: [PATCH 7/7] conformance: pin the Go half to bitruntime's v0.2.0 release Both halves now run against the tag v0.2.0 at e28bb1a: the Go module by its release version and the TypeScript package by its release asset. The gap ledger cites the tagged sources, whose cited lines are unchanged from the candidate's. Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/runtime/bitruntime.json | 8 ++++---- conformance/runtime/go/go.mod | 2 +- conformance/runtime/go/go.sum | 4 ++-- conformance/runtime/production-gaps.json | 10 +++++----- scripts/conformance-runtime.mjs | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/conformance/runtime/bitruntime.json b/conformance/runtime/bitruntime.json index 93a34da..7fc5634 100644 --- a/conformance/runtime/bitruntime.json +++ b/conformance/runtime/bitruntime.json @@ -1,10 +1,10 @@ { "repository": "https://github.com/Bitspark/bitruntime", "module": "github.com/Bitspark/bitruntime", - "version": "v0.1.1-0.20260926101907-9c93bb77b9b8", - "revision": "9c93bb77b9b8a592b364962c0fc90414e715b229", - "sum": "h1:/kiJpZfuDA7uBQiCpVtRpDXPR7LEEvNySioBdMud9LY=", - "status": "unreleased-source", + "version": "v0.2.0", + "revision": "e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2", + "sum": "h1:6sSEqVB9sRzxZPLcVcca9RtfRNYy+8+E5dvVmDpyFbQ=", + "status": "release", "profile": "bitwire/1", "bitwireVersion": "v0.3.0", "bitwireRevision": "f825f3f4a79135646b775e25dcd770656546b4a1", diff --git a/conformance/runtime/go/go.mod b/conformance/runtime/go/go.mod index ec7caab..9f8dafc 100644 --- a/conformance/runtime/go/go.mod +++ b/conformance/runtime/go/go.mod @@ -3,7 +3,7 @@ module bitwire.conformance/runtime go 1.26.0 require ( - github.com/Bitspark/bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 + github.com/Bitspark/bitruntime v0.2.0 github.com/Bitspark/bitwire v0.3.0 ) diff --git a/conformance/runtime/go/go.sum b/conformance/runtime/go/go.sum index 9af02a9..79d7871 100644 --- a/conformance/runtime/go/go.sum +++ b/conformance/runtime/go/go.sum @@ -1,5 +1,5 @@ -github.com/Bitspark/bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 h1:/kiJpZfuDA7uBQiCpVtRpDXPR7LEEvNySioBdMud9LY= -github.com/Bitspark/bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8/go.mod h1:2Jtfp03Neyk5cCKP/WizMw+w+5ABT4yt/Dzq/yeKsMY= +github.com/Bitspark/bitruntime v0.2.0 h1:6sSEqVB9sRzxZPLcVcca9RtfRNYy+8+E5dvVmDpyFbQ= +github.com/Bitspark/bitruntime v0.2.0/go.mod h1:2Jtfp03Neyk5cCKP/WizMw+w+5ABT4yt/Dzq/yeKsMY= github.com/Bitspark/bitwire v0.3.0 h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA= github.com/Bitspark/bitwire v0.3.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= diff --git a/conformance/runtime/production-gaps.json b/conformance/runtime/production-gaps.json index c469333..7099606 100644 --- a/conformance/runtime/production-gaps.json +++ b/conformance/runtime/production-gaps.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "implementation": "bitruntime v0.1.1-0.20260926101907-9c93bb77b9b8 at 9c93bb77b9b8a592b364962c0fc90414e715b229: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go); @bitspark/bitruntime 0.2.0, tag v0.2.0 at e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2: at, mount, forward, pair and engine Peers over WebSockets (TypeScript)", + "implementation": "bitruntime v0.2.0, tag v0.2.0 at e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2: core.At, core.Mount, core.Forward, core.NewPair and engine WebSocket peers (Go); @bitspark/bitruntime 0.2.0, tag v0.2.0 at e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2: at, mount, forward, pair and engine Peers over WebSockets (TypeScript)", "gaps": [ { "id": "origin-bearing-construction", "requirement": "ADR0006: compose(origin, children) admits a composite whose own value is not the refusing origin.", - "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L56-L84 Its port deliberately omits Nightseam's unreleased declared-composition API, which Bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree. The TypeScript mount is the same specialization: it takes only a map of children and refuses [] with MissingPathError. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L29-L48 compose builds a WireTree of addressless Wires there too.", + "evidence": "bitruntime has no addressed constructor taking an origin. core.Mount is the child-only specialization: it takes only children and refuses [] as missing. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/go/addressed.go#L56-L84 Its port deliberately omits nightseam's unreleased declared-composition API, which bitwire decision 0012 supersedes: https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/docs/port-from-nightseam.md core.Compose builds a WireTree of addressless Wires; the trees family exercises it, and no inverse turns an opaque AddressedWire child into a tree. The TypeScript mount is the same specialization: it takes only a map of children and refuses [] with MissingPathError. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L29-L48 compose builds a WireTree of addressless Wires there too.", "cases": [ "origin-and-descendants", "nested-selection-agrees", @@ -32,7 +32,7 @@ { "id": "conflicting-segments-accepted", "requirement": "ADR0006: construction refuses conflicting child segments before a native map can discard one.", - "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L67 The TypeScript mount likewise accepts only an already-built Map, with the same result. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L38", + "evidence": "core.Mount accepts only an already-built Go map. The later entry replaced the earlier one before Mount ran, so [\"a\"] reached the replacing child and the retained description no longer matches the routes. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/go/addressed.go#L61-L67 The TypeScript mount likewise accepts only an already-built Map, with the same result. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L38", "cases": ["conflicting-children"], "observed": { "go": {"trace": [["delivered", ["leaf"], 1]], "partsExact": false, "unchanged": true, "sendOnly": true, "borrowedUsable": true}, @@ -42,7 +42,7 @@ { "id": "invalid-segments-accepted", "requirement": "ADR0006: construction refuses a segment outside the exact UTF-8 key image.", - "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L78 The TypeScript mount copies a lone-surrogate key without validation; sending validates the path later (InvalidPathError). https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L47", + "evidence": "core.Mount copies the key without validation. The child becomes unreachable because sending validates the path later (core.ErrInvalidPath). https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/go/addressed.go#L61-L78 The TypeScript mount copies a lone-surrogate key without validation; sending validates the path later (InvalidPathError). https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L47", "cases": ["invalid-key"], "observed": { "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true}, @@ -52,7 +52,7 @@ { "id": "missing-children-accepted", "requirement": "ADR0006: every child is complete Wire access; construction refuses a missing child value.", - "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/9c93bb77b9b8a592b364962c0fc90414e715b229/core/go/addressed.go#L61-L84 The TypeScript mount accepts an undefined child and later refuses sends to that key with MissingPathError. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L47", + "evidence": "core.Mount accepts a nil child and later refuses sends to that key as missing (core.ErrMissingPath). https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/go/addressed.go#L61-L84 The TypeScript mount accepts an undefined child and later refuses sends to that key with MissingPathError. https://github.com/Bitspark/bitruntime/blob/e28bb1adefbc8a5cb1b03f172b4e7212584bd7a2/core/ts/src/addressed.ts#L37-L47", "cases": ["missing-child-value"], "observed": { "go": {"trace": [["refused"]], "partsExact": true, "unchanged": true, "sendOnly": true, "borrowedUsable": true}, diff --git a/scripts/conformance-runtime.mjs b/scripts/conformance-runtime.mjs index 7fe0237..48f69c4 100644 --- a/scripts/conformance-runtime.mjs +++ b/scripts/conformance-runtime.mjs @@ -78,7 +78,7 @@ function verifyModules() { assert.deepEqual(graph[0].slice(0, 1), ['bitwire.conformance/runtime']); for (const [path, , replacement] of graph) assert.equal(replacement ?? '', '', `${path} must not be replaced`); const version = path => graph.find(([name]) => name === path)?.[1]; - assert.equal(version(pin.module), pin.version, 'bitruntime is not the pinned candidate'); + assert.equal(version(pin.module), pin.version, 'bitruntime is not the pinned version'); assert.equal(version('github.com/Bitspark/bitwire'), pin.bitwireVersion, 'bitwire is not the pinned contract'); for (const [path, version_, revision, sum] of [ [pin.module, pin.version, pin.revision, pin.sum],