From b166bebf7cb6f05b579ec6945d568de1c9ef6329 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 13:59:16 +0200 Subject: [PATCH 01/21] hydrated: Go evidence for bitwire decision 0019 (not for merge) Realizes the proposed first edition of the hydrated wire protocol (bitwire#83 at 20a6b6b) in Go, to test its rules before acceptance: scopes with random tokens, frames to owner paths, bare-atom tagged bodies, proxies recognized namespace-wide, exports bound to a local Endpoint's lifetime, liveness judged on send, received context beside the value, bounds and refusals. The tests run the record's observations 1-13 around an opaque middle router, over local pairs and WebSocket: 200 long-lived calls stay at one live export; a Wire shared with a second holder ends only with its owner; forwarding registers nothing. Vectors are pinned by sha256. Co-Authored-By: Claude Opus 5.5 --- hydrated/README.md | 14 + hydrated/go/hydrated.go | 624 +++++++++++++++++++ hydrated/go/hydrated_test.go | 689 +++++++++++++++++++++ hydrated/go/testdata/hydrated-vectors.json | 53 ++ 4 files changed, 1380 insertions(+) create mode 100644 hydrated/README.md create mode 100644 hydrated/go/hydrated.go create mode 100644 hydrated/go/hydrated_test.go create mode 100644 hydrated/go/testdata/hydrated-vectors.json diff --git a/hydrated/README.md b/hydrated/README.md new file mode 100644 index 0000000..e8c7399 --- /dev/null +++ b/hydrated/README.md @@ -0,0 +1,14 @@ +# Hydrated wires (evidence for bitwire decision 0019) + +**Status, 7 October 2026:** evidence for the proposed +[bitwire decision 0019](https://github.com/Bitspark/bitwire/pull/83), the first +edition of the hydrated wire protocol. This is not a released API, and this +branch is not proposed for merge until the record is accepted. The +[package](go/hydrated.go) realizes the record's rules in Go. [Its tests](go/hydrated_test.go) +run the record's observations 1-13 around an opaque middle router, over local +pairs and WebSocket where the carrier matters. + +The record's vectors are copied from bitwire#83 at `20a6b6b` into +[testdata](go/testdata/hydrated-vectors.json), pinned by sha256. Observation 14 +(Go and TypeScript peers with fresh published dependencies) needs a TypeScript +realization and is not run here. diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go new file mode 100644 index 0000000..11cb7bf --- /dev/null +++ b/hydrated/go/hydrated.go @@ -0,0 +1,624 @@ +// Package hydrated is evidence for bitwire decision 0019 (proposed): the first +// edition of the hydrated wire protocol, bitwire/hydrated/1, realized in Go. It +// is not a released API; the record decides, this package demonstrates. +// +// A Scope is one participant's hydration state for one incarnation. Its +// dispatcher owns the participant's addressed receive slot and hands the Scope +// every value addressed to the participant's own path, with the received context +// the composition establishes. Domain code sees only Values, Wires and Endpoints. +package hydrated + +import ( + "crypto/rand" + "errors" + "strconv" + "sync" + + ontos "github.com/Bitspark/bitwire/ontos/go/core" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// Value is a hydrated value: a ground ontos value, a Tuple or a Wire. +type Value any + +// Wire sends one hydrated value. Only a local Endpoint's sending face or a proxy +// imported in the same namespace can travel inside a hydrated value (D7). +type Wire interface{ Send(Value) error } + +// Context is what the composition establishes about an arrival, for example a +// checked origin. It is never part of a value (D4). +type Context any + +// Local is the context of a send that never left the process. +type Local struct{} + +// Refusal names a refusal. At the owner it is a host diagnostic; at the sender +// it refuses the send before admission. It is never an outcome (D8). +type Refusal string + +func (r Refusal) Error() string { return string(r) } + +const ( + MalformedFrame Refusal = "malformed-frame" + StaleScope Refusal = "stale-scope" + UnknownExport Refusal = "unknown-export" + Limit Refusal = "limit" + TargetRefused Refusal = "target-refused" + Unexportable Refusal = "unexportable" + ForeignNamespace Refusal = "foreign-namespace" + ScopeEnded Refusal = "scope-ended" +) + +var ( + header = ontos.NewAtom([]byte("bitwire/hydrated/1")) + tupleTag = ontos.NewAtom([]byte{1}) + wireTag = ontos.NewAtom([]byte{2}) +) + +// Tuple is a hydrated tuple with at least one Wire beneath it. NewTuple returns +// the ground tuple when there is none (D1). +type Tuple struct{ items []Value } + +// NewTuple captures items. Each must be an ontos value, a Tuple or a Wire. +func NewTuple(items ...Value) (Value, error) { + captured := append([]Value(nil), items...) + ground := make([]ontos.Value, 0, len(captured)) + live := false + for _, item := range captured { + switch x := item.(type) { + case ontos.Atom: + ground = append(ground, x) + case ontos.Tuple: + ground = append(ground, x) + case Tuple, Wire: + live = true + default: + return nil, errors.New("hydrated: not a value") + } + } + if live { + return Tuple{captured}, nil + } + return ontos.NewTuple(ground...), nil +} + +// Items returns the tuple's items. +func (t Tuple) Items() []Value { return append([]Value(nil), t.items...) } + +// Items returns any tuple's items, ground or hydrated. +func Items(v Value) ([]Value, bool) { + switch x := v.(type) { + case Tuple: + return x.Items(), true + case ontos.Tuple: + out := make([]Value, x.Len()) + for i, item := range x.Items() { + out[i] = item + } + return out, true + } + return nil, false +} + +// Namespace identifies one set of participants addressed by absolute paths. +// Proxies remember theirs, so recognition is runtime-wide within it (D6). +type Namespace struct{ name string } + +// NewNamespace creates a namespace. +func NewNamespace(name string) *Namespace { return &Namespace{name} } + +// Reference is the ground form of a live Wire (D2, D3). +type Reference struct { + Path wire.Path + Scope, ID ontos.Atom +} + +func (r Reference) value() ontos.Value { + segments := make([]ontos.Value, len(r.Path)) + for i, a := range r.Path { + segments[i] = a + } + return ontos.NewTuple(wireTag, ontos.NewTuple(ontos.NewTuple(segments...), r.Scope, r.ID)) +} + +func (r Reference) key() string { + k := strconv.Itoa(len(r.Path)) + for _, a := range r.Path { + k += "/" + strconv.Itoa(a.Len()) + ":" + string(a.Bytes()) + } + return k + "|" + string(r.Scope.Bytes()) + "|" + string(r.ID.Bytes()) +} + +// Endpoint is a local HydratedEndpoint: one receiver, ordered non-inline +// dispatch, a bounded queue and an owner lifetime. Its sending face is what a +// value carries; closing it ends its export in every scope (D7). +type Endpoint struct { + mu sync.Mutex + queue []delivery + limit int + handler func(Value, Context) + closed bool + done chan struct{} + wake chan struct{} + face *face + exports map[*Scope]ontos.Atom +} + +type delivery struct { + value Value + context Context +} + +// face is an Endpoint's sending face. It grants sending only. +type face struct{ e *Endpoint } + +func (f *face) Send(v Value) error { return f.e.admit(v, Local{}) } + +// NewEndpoint creates an endpoint whose queue admits at most limit values. +func NewEndpoint(limit int) *Endpoint { + if limit <= 0 { + limit = 1 + } + e := &Endpoint{limit: limit, done: make(chan struct{}), wake: make(chan struct{}, 1), exports: map[*Scope]ontos.Atom{}} + e.face = &face{e} + go e.dispatch() + return e +} + +// Wire is the endpoint's sending face. +func (e *Endpoint) Wire() Wire { return e.face } + +// Send admits a value locally, as its sending face does. +func (e *Endpoint) Send(v Value) error { return e.admit(v, Local{}) } + +// Receive attaches the one receiver. Values are dispatched in admission order, +// never inline with the admitting send. +func (e *Endpoint) Receive(handler func(Value, Context)) (detach func(), err error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.handler != nil { + return nil, errors.New("hydrated: receiver already attached") + } + e.handler = handler + e.signal() + return func() { + e.mu.Lock() + e.handler = nil + e.mu.Unlock() + }, nil +} + +// Closed is closed when the endpoint closes. +func (e *Endpoint) Closed() <-chan struct{} { return e.done } + +// Close ends the endpoint and withdraws its export from every scope. +func (e *Endpoint) Close() error { + e.mu.Lock() + if e.closed { + e.mu.Unlock() + return nil + } + e.closed = true + exports := e.exports + e.exports = map[*Scope]ontos.Atom{} + e.queue = nil + close(e.done) + e.mu.Unlock() + for s, id := range exports { + s.withdraw(e, id) + } + return nil +} + +func (e *Endpoint) admit(v Value, ctx Context) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed || len(e.queue) >= e.limit { + return TargetRefused + } + e.queue = append(e.queue, delivery{v, ctx}) + e.signal() + return nil +} + +func (e *Endpoint) signal() { + select { + case e.wake <- struct{}{}: + default: + } +} + +func (e *Endpoint) dispatch() { + for { + select { + case <-e.done: + return + case <-e.wake: + } + for { + e.mu.Lock() + if e.closed || e.handler == nil || len(e.queue) == 0 { + e.mu.Unlock() + break + } + d, h := e.queue[0], e.handler + e.queue = e.queue[1:] + e.mu.Unlock() + h(d.value, d.context) + } + } +} + +// register records an export; it refuses a closed endpoint. +func (e *Endpoint) register(s *Scope, id ontos.Atom) bool { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return false + } + e.exports[s] = id + return true +} + +func (e *Endpoint) unregister(s *Scope) { + e.mu.Lock() + delete(e.exports, s) + e.mu.Unlock() +} + +func (e *Endpoint) isClosed() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.closed +} + +// proxy is an imported Wire. It carries its reference and namespace, so any +// participant in the namespace forwards it unchanged (D6). A proxy with a dead +// reason is this participant's own stale or withdrawn reference: its sends are +// refused locally (D7, returning home). +type proxy struct { + ref Reference + ns *Namespace + via *Scope + dead Refusal +} + +func (p *proxy) Send(v Value) error { + if p.dead != "" { + return p.dead + } + return p.via.send(p.ref, v) +} + +// Limits bounds a scope (D8). +type Limits struct{ Exports, Nodes, Depth, Bytes int } + +// DefaultLimits are finite defaults for evidence; production configures its own. +var DefaultLimits = Limits{Exports: 64, Nodes: 4096, Depth: 64, Bytes: 1 << 20} + +// Scope is one participant's hydration state for one incarnation (Terms). +type Scope struct { + mu sync.Mutex + path wire.Path + token ontos.Atom + ns *Namespace + sender wire.AddressedWire + limits Limits + next uint64 + byID map[string]*Endpoint + byEndpoint map[*Endpoint]ontos.Atom + closed bool +} + +// NewScope creates a scope at path in ns with a fresh random token. sender +// carries frames to owner paths; the composition supplies its routing. +func NewScope(ns *Namespace, path wire.Path, sender wire.AddressedWire, limits Limits) (*Scope, error) { + if limits.Exports <= 0 || limits.Nodes <= 0 || limits.Depth <= 0 || limits.Bytes <= 0 { + return nil, errors.New("hydrated: limits must be positive") + } + token := make([]byte, 16) + if _, err := rand.Read(token); err != nil { + return nil, err + } + return &Scope{ + path: append(wire.Path(nil), path...), token: ontos.NewAtom(token), ns: ns, sender: sender, + limits: limits, byID: map[string]*Endpoint{}, byEndpoint: map[*Endpoint]ontos.Atom{}, + }, nil +} + +// Token is the scope token. +func (s *Scope) Token() ontos.Atom { return s.token } + +// Live reports the number of live exports. +func (s *Scope) Live() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.byEndpoint) +} + +// Expose exports an endpoint's face for composition bootstrap, such as a +// service's well-known reference. Domain adapters never call it. +func (s *Scope) Expose(e *Endpoint) (Reference, error) { + staged := map[*Endpoint]ontos.Atom{} + id, err := s.exportID(e, staged) + if err != nil { + return Reference{}, err + } + if err := s.commit(staged); err != nil { + return Reference{}, err + } + return Reference{s.path, s.token, id}, nil +} + +// Connect returns a proxy for a reference, for composition bootstrap. +func (s *Scope) Connect(r Reference) Wire { return s.importRef(r, map[string]Wire{}) } + +// Close ends the scope: its exports end and its references go stale. It closes +// no domain endpoint. +func (s *Scope) Close() { + s.mu.Lock() + s.closed = true + endpoints := s.byEndpoint + s.byEndpoint, s.byID = map[*Endpoint]ontos.Atom{}, map[string]*Endpoint{} + s.mu.Unlock() + for e := range endpoints { + e.unregister(s) + } +} + +func (s *Scope) withdraw(e *Endpoint, id ontos.Atom) { + s.mu.Lock() + if s.byID[string(id.Bytes())] == e { + delete(s.byID, string(id.Bytes())) + delete(s.byEndpoint, e) + } + s.mu.Unlock() +} + +// exportID returns e's export id in this scope, staging a new one if needed. +// Ids are never reused, even when a staged export is never committed. +func (s *Scope) exportID(e *Endpoint, staged map[*Endpoint]ontos.Atom) (ontos.Atom, error) { + if e.isClosed() { + return ontos.Atom{}, Unexportable + } + if id, ok := staged[e]; ok { + return id, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ontos.Atom{}, ScopeEnded + } + if id, ok := s.byEndpoint[e]; ok { + return id, nil + } + if len(s.byEndpoint)+len(staged) >= s.limits.Exports { + return ontos.Atom{}, Limit + } + s.next++ + id := ontos.NewAtom([]byte(strconv.FormatUint(s.next, 10))) + staged[e] = id + return id, nil +} + +// commit registers staged exports before the frame is admitted (D5). An +// endpoint closed meanwhile is not registered; its reference is simply dead. +func (s *Scope) commit(staged map[*Endpoint]ontos.Atom) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ScopeEnded + } + for e, id := range staged { + if _, ok := s.byEndpoint[e]; ok { + continue + } + if e.register(s, id) { + s.byEndpoint[e] = id + s.byID[string(id.Bytes())] = e + } + } + return nil +} + +type budget struct{ nodes, bytes int } + +func (s *Scope) spend(b *budget, depth, n int) error { + b.nodes++ + b.bytes += n + if b.nodes > s.limits.Nodes || depth > s.limits.Depth || b.bytes > s.limits.Bytes { + return Limit + } + return nil +} + +// send encodes v atomically, registers its new exports, then admits one frame +// to the owner path (D5). +func (s *Scope) send(dest Reference, v Value) error { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return ScopeEnded + } + staged := map[*Endpoint]ontos.Atom{} + body, err := s.encode(v, 0, staged, &budget{}) + if err != nil { + return err + } + frame := ontos.NewTuple(header, dest.Scope, dest.ID, body) + if _, err := wire.EncodeMessage(frame, s.limits.Bytes); err != nil { + return Limit + } + if err := s.commit(staged); err != nil { + return err + } + return s.sender.Send(dest.Path, frame) +} + +func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (ontos.Value, error) { + switch x := v.(type) { + case ontos.Atom: + return x, s.spend(b, depth, x.Len()) + case ontos.Tuple, Tuple: + if err := s.spend(b, depth, 0); err != nil { + return nil, err + } + items, _ := Items(x) + out := make([]ontos.Value, len(items)) + for i, item := range items { + var err error + if out[i], err = s.encode(item, depth+1, staged, b); err != nil { + return nil, err + } + } + return ontos.NewTuple(tupleTag, ontos.NewTuple(out...)), nil + case *Endpoint: + return s.encode(x.face, depth, staged, b) + case *face: + if err := s.spend(b, depth, 0); err != nil { + return nil, err + } + id, err := s.exportID(x.e, staged) + if err != nil { + return nil, err + } + return Reference{s.path, s.token, id}.value(), nil + case *proxy: + if x.ns != s.ns { + return nil, ForeignNamespace + } + return x.ref.value(), s.spend(b, depth, 0) + case Wire: + return nil, Unexportable + } + return nil, errors.New("hydrated: not a value") +} + +// Deliver decides one value addressed to this participant's own path (D4). The +// returned refusal is a host diagnostic; nothing is sent back. +func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx Context) error { + if !wire.PathEqual(path, s.path) { + return MalformedFrame + } + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return ScopeEnded + } + f, ok := message.(ontos.Tuple) + if !ok || f.Len() != 4 || !header.Equal(f.At(0)) { + return MalformedFrame + } + token, ok1 := f.At(1).(ontos.Atom) + id, ok2 := f.At(2).(ontos.Atom) + if !ok1 || !ok2 || token.Len() != 16 || id.Len() < 1 || id.Len() > 16 { + return MalformedFrame + } + if !token.Equal(s.token) { + return StaleScope + } + s.mu.Lock() + target := s.byID[string(id.Bytes())] + s.mu.Unlock() + if target == nil { + return UnknownExport + } + value, err := s.decode(f.At(3), 0, map[string]Wire{}, &budget{}) + if err != nil { + return err + } + return target.admit(value, ctx) +} + +// decode reads a body. Occurrences of one reference within the value share one +// proxy; nothing is retained across messages (D7). +func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *budget) (Value, error) { + if a, ok := v.(ontos.Atom); ok { + return a, s.spend(b, depth, a.Len()) + } + t, ok := v.(ontos.Tuple) + if !ok || t.Len() != 2 { + return nil, MalformedFrame + } + tag, ok := t.At(0).(ontos.Atom) + if !ok || tag.Len() != 1 { + return nil, MalformedFrame + } + if err := s.spend(b, depth, 0); err != nil { + return nil, err + } + switch { + case tag.Equal(tupleTag): + children, ok := t.At(1).(ontos.Tuple) + if !ok { + return nil, MalformedFrame + } + items := make([]Value, children.Len()) + for i, c := range children.Items() { + var err error + if items[i], err = s.decode(c, depth+1, interned, b); err != nil { + return nil, err + } + } + return NewTuple(items...) + case tag.Equal(wireTag): + r, err := readReference(t.At(1), s.limits.Depth) + if err != nil { + return nil, err + } + return s.importRef(r, interned), nil + } + return nil, MalformedFrame +} + +func readReference(v ontos.Value, maxDepth int) (Reference, error) { + t, ok := v.(ontos.Tuple) + if !ok || t.Len() != 3 { + return Reference{}, MalformedFrame + } + p, ok := t.At(0).(ontos.Tuple) + if !ok || p.Len() > maxDepth { + return Reference{}, MalformedFrame + } + path := make(wire.Path, p.Len()) + for i, segment := range p.Items() { + a, ok := segment.(ontos.Atom) + if !ok { + return Reference{}, MalformedFrame + } + path[i] = a + } + scope, ok1 := t.At(1).(ontos.Atom) + id, ok2 := t.At(2).(ontos.Atom) + if !ok1 || !ok2 || scope.Len() != 16 || id.Len() < 1 || id.Len() > 16 { + return Reference{}, MalformedFrame + } + return Reference{path, scope, id}, nil +} + +// importRef turns a reference into a Wire. A reference to this participant in +// its current scope returns home to the original face; a stale or withdrawn one +// becomes a Wire whose sends are refused. Liveness is judged on send (D7). +func (s *Scope) importRef(r Reference, interned map[string]Wire) Wire { + if wire.PathEqual(r.Path, s.path) { + if !r.Scope.Equal(s.token) { + return &proxy{ref: r, ns: s.ns, via: s, dead: StaleScope} + } + s.mu.Lock() + e := s.byID[string(r.ID.Bytes())] + s.mu.Unlock() + if e == nil { + return &proxy{ref: r, ns: s.ns, via: s, dead: UnknownExport} + } + return e.face + } + k := r.key() + if w, ok := interned[k]; ok { + return w + } + p := &proxy{ref: r, ns: s.ns, via: s} + interned[k] = p + return p +} diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go new file mode 100644 index 0000000..7b39943 --- /dev/null +++ b/hydrated/go/hydrated_test.go @@ -0,0 +1,689 @@ +package hydrated + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + websocket "github.com/Bitspark/bitruntime/websocket/go" + ontos "github.com/Bitspark/bitwire/ontos/go/core" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func text(s string) ontos.Atom { return ontos.NewAtom([]byte(s)) } + +// pending reads an endpoint's queue length under its lock. +func (e *Endpoint) pending() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.queue) +} + +func tuple(t *testing.T, items ...Value) Value { + t.Helper() + v, err := NewTuple(items...) + if err != nil { + t.Fatal(err) + } + return v +} + +func items(t *testing.T, v Value) []Value { + t.Helper() + xs, ok := Items(v) + if !ok { + t.Fatalf("not a tuple: %#v", v) + } + return xs +} + +func eventually(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal(what) + } + time.Sleep(2 * time.Millisecond) + } +} + +// --- vectors (bitwire#83 at 20a6b6b, pinned by hash) --- + +type jsonValue map[string]json.RawMessage + +func ground(v jsonValue) ontos.Value { + if raw, ok := v["atom"]; ok { + var h string + _ = json.Unmarshal(raw, &h) + b, _ := hex.DecodeString(h) + return ontos.NewAtom(b) + } + var xs []jsonValue + _ = json.Unmarshal(v["tuple"], &xs) + out := make([]ontos.Value, len(xs)) + for i, x := range xs { + out[i] = ground(x) + } + return ontos.NewTuple(out...) +} + +func wires(v Value) int { + switch x := v.(type) { + case Wire: + return 1 + case Tuple: + n := 0 + for _, item := range x.items { + n += wires(item) + } + return n + } + return 0 +} + +func TestObservation1VectorsRoundTrip(t *testing.T) { + raw, err := os.ReadFile("testdata/hydrated-vectors.json") + if err != nil { + t.Fatal(err) + } + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != "621325467a19fa5d5ad34a1805e38feadb5609ad13fe5e71e991cf87b4d77278" { + t.Fatal("vectors changed; repin to the reviewed bitwire record") + } + var v struct { + Encode []struct { + Name, Live, Hex string + Body jsonValue + } + RejectBody, RejectFrame []struct { + Name string + Value jsonValue + } + } + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + // A scope at a path no vector names: every reference imports as a proxy. + s, _ := NewScope(NewNamespace("vectors"), wire.Path{text("vectors")}, nil, DefaultLimits) + for _, c := range v.Encode { + body := ground(c.Body) + live, err := s.decode(body, 0, map[string]Wire{}, &budget{}) + if err != nil { + t.Fatalf("%s: %v", c.Name, err) + } + if got, want := wires(live), strings.Count(c.Live, "wire{"); got != want { + t.Fatalf("%s: %d wire leaves, want %d", c.Name, got, want) + } + again, err := s.encode(live, 0, map[*Endpoint]ontos.Atom{}, &budget{}) + if err != nil || !again.Equal(body) { + t.Fatalf("%s: re-encoding changed the body: %v", c.Name, err) + } + b, _ := wire.EncodeMessage(again, wire.DefaultMaxMessageBytes) + if hex.EncodeToString(b) != c.Hex { + t.Fatalf("%s: bytes", c.Name) + } + } + for _, c := range v.RejectBody { + if _, err := s.decode(ground(c.Value), 0, map[string]Wire{}, &budget{}); !errors.Is(err, MalformedFrame) { + t.Fatalf("%s: %v", c.Name, err) + } + } + // Frame rejection with a live target at the vectors' scope S and id "1". + s.token = ontos.NewAtom([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) + target := NewEndpoint(8) + s.byID["1"], s.byEndpoint[target] = target, text("1") + for _, c := range v.RejectFrame { + if err := s.Deliver(s.path, ground(c.Value), nil); !errors.Is(err, MalformedFrame) { + t.Fatalf("%s: %v", c.Name, err) + } + } + if target.pending() != 0 { + t.Fatal("a rejected frame was delivered") + } +} + +// --- a namespace of participants around an opaque middle router --- + +var paths = map[string]wire.Path{ + "client": {text("client42")}, + "provider": {text("a"), text("b"), text("service2")}, + "third": {text("c")}, +} + +type diagnostic struct { + who string + err error +} + +type participant struct { + name string + ep wire.AddressedEndpoint + mu sync.Mutex + sc *Scope +} + +func (p *participant) scope() *Scope { + p.mu.Lock() + defer p.mu.Unlock() + return p.sc +} + +type tree struct { + ns *Namespace + mu sync.Mutex + routes map[string]wire.AddressedWire + trace []ontos.Value + diags []diagnostic + parts map[string]*participant +} + +func key(p wire.Path) string { return Reference{Path: p}.key() } + +func (tr *tree) diagnostics() []diagnostic { + tr.mu.Lock() + defer tr.mu.Unlock() + return append([]diagnostic(nil), tr.diags...) +} + +func (tr *tree) frames() int { + tr.mu.Lock() + defer tr.mu.Unlock() + return len(tr.trace) +} + +// links returns n pairs of (participant side, middle side) endpoints. +func links(t *testing.T, carrier string, n int) [][2]wire.Endpoint { + out := make([][2]wire.Endpoint, n) + if carrier == "pair" { + for i := range out { + a, b, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + out[i] = [2]wire.Endpoint{a, b} + t.Cleanup(func() { _ = a.Close() }) + } + return out + } + accepted := make(chan *websocket.Endpoint, n) + server, err := websocket.Listen("127.0.0.1:0", websocket.Options{}, func(e *websocket.Endpoint, _ *http.Request) { accepted <- e }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + for i := range out { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + c, err := websocket.Dial(ctx, server.URL(), websocket.Options{}) + cancel() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + out[i] = [2]wire.Endpoint{c, <-accepted} + } + return out +} + +func newTree(t *testing.T, carrier string, names ...string) *tree { + tr := &tree{ns: NewNamespace("test"), routes: map[string]wire.AddressedWire{}, parts: map[string]*participant{}} + ls := links(t, carrier, len(names)) + for i, name := range names { + mine, mid := core.Addressed(ls[i][0]), core.Addressed(ls[i][1]) + tr.routes[key(paths[name])] = mid + if _, err := mid.Receive(func(p wire.Path, v ontos.Value) { + tr.mu.Lock() + tr.trace = append(tr.trace, v) + dest := tr.routes[key(p)] + tr.mu.Unlock() + if dest != nil { // the middle knows routes and ground values only + _ = dest.Send(p, v) + } + }); err != nil { + t.Fatal(err) + } + sc, err := NewScope(tr.ns, paths[name], mine, DefaultLimits) + if err != nil { + t.Fatal(err) + } + p := &participant{name: name, ep: mine, sc: sc} + tr.parts[name] = p + if _, err := mine.Receive(func(path wire.Path, v ontos.Value) { + // The composition's received context: here, the link it arrived on. + if err := p.scope().Deliver(path, v, name+"-link"); err != nil { + tr.mu.Lock() + tr.diags = append(tr.diags, diagnostic{name, err}) + tr.mu.Unlock() + } + }); err != nil { + t.Fatal(err) + } + } + return tr +} + +func (tr *tree) replace(t *testing.T, name string) *Scope { + p := tr.parts[name] + sc, err := NewScope(tr.ns, paths[name], p.ep, DefaultLimits) + if err != nil { + t.Fatal(err) + } + p.mu.Lock() + old := p.sc + p.sc = sc + p.mu.Unlock() + old.Close() + return sc +} + +// serve exposes a domain endpoint at a participant: the composition bootstrap. +func serve(t *testing.T, s *Scope, handler func(Value, Context)) (*Endpoint, Reference) { + e := NewEndpoint(64) + if _, err := e.Receive(handler); err != nil { + t.Fatal(err) + } + ref, err := s.Expose(e) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = e.Close() }) + return e, ref +} + +type received struct { + value Value + context Context +} + +// call is a domain adapter: it sends (op, args..., reply) and closes its reply +// endpoint after the reply. It knows no reference, table or route. +func call(t *testing.T, target Wire, op string, args ...Value) received { + t.Helper() + reply := NewEndpoint(1) + defer reply.Close() + got := make(chan received, 1) + if _, err := reply.Receive(func(v Value, c Context) { got <- received{v, c} }); err != nil { + t.Fatal(err) + } + if err := target.Send(tuple(t, append(append([]Value{text(op)}, args...), reply)...)); err != nil { + t.Fatal(err) + } + select { + case r := <-got: + return r + case <-time.After(5 * time.Second): + t.Fatal("no reply") + } + return received{} +} + +// echo is a domain provider: (op, arg, reply). "echo" replies (arg); "continue" +// replies (arg, continuation), where the continuation forwards one value to the +// Wire it carries and then closes. +func echo(t *testing.T) func(Value, Context) { + return func(v Value, _ Context) { + xs, _ := Items(v) + op, arg, reply := xs[0].(ontos.Atom), xs[1], xs[len(xs)-1].(Wire) + switch string(op.Bytes()) { + case "echo": + _ = reply.Send(arg) + case "continue": + cont := NewEndpoint(1) + _, _ = cont.Receive(func(next Value, _ Context) { + ys, _ := Items(next) + _ = ys[1].(Wire).Send(ys[0]) + _ = cont.Close() + }) + r, _ := NewTuple(arg, cont) + _ = reply.Send(r) + } + } +} + +func carriers(t *testing.T, f func(t *testing.T, carrier string)) { + for _, c := range []string{"pair", "websocket"} { + t.Run(c, func(t *testing.T) { f(t, c) }) + } +} + +func TestObservation2RecursionThroughAnOpaqueRouter(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + _, svc := serve(t, tr.parts["provider"].scope(), echo(t)) + target := tr.parts["client"].scope().Connect(svc) + r := call(t, target, "continue", text("hello")) + xs := items(t, r.value) + if !xs[0].(ontos.Atom).Equal(text("hello")) { + t.Fatal("wrong reply") + } + third := NewEndpoint(1) + got := make(chan Value, 1) + _, _ = third.Receive(func(v Value, _ Context) { got <- v }) + if err := xs[1].(Wire).Send(tuple(t, text("third Wire"), third)); err != nil { + t.Fatal(err) + } + select { + case v := <-got: + if !v.(ontos.Atom).Equal(text("third Wire")) { + t.Fatal("wrong third value") + } + case <-time.After(5 * time.Second): + t.Fatal("third Wire not reached") + } + if n := tr.frames(); n != 4 { + t.Fatalf("%d frames crossed the middle, want 4", n) + } + if d := tr.diagnostics(); len(d) != 0 { + t.Fatal(d) + } + }) +} + +func TestObservation3LongLivedCallsStayBounded(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + client, provider := tr.parts["client"].scope(), tr.parts["provider"].scope() + _, svc := serve(t, provider, echo(t)) + target := client.Connect(svc) + for i := 0; i < 200; i++ { + r := call(t, target, "echo", text(fmt.Sprint(i))) + if !r.value.(ontos.Atom).Equal(text(fmt.Sprint(i))) { + t.Fatalf("call %d: wrong reply", i) + } + if client.Live() > 1 || provider.Live() != 1 { + t.Fatalf("call %d: live exports client %d provider %d", i, client.Live(), provider.Live()) + } + } + eventually(t, "the last reply export did not end", func() bool { return client.Live() == 0 }) + if d := tr.diagnostics(); len(d) != 0 { + t.Fatal(d) + } + }) +} + +func TestObservation4ASharedWireEndsOnlyWithItsOwner(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider", "third") + a, b, c := tr.parts["client"].scope(), tr.parts["provider"].scope(), tr.parts["third"].scope() + var mu sync.Mutex + var atB, atC Wire + _, refC := serve(t, c, func(v Value, _ Context) { mu.Lock(); atC = v.(Wire); mu.Unlock() }) + toC := b.Connect(refC) + _, refB := serve(t, b, func(v Value, _ Context) { + mu.Lock() + atB = v.(Wire) + mu.Unlock() + _ = toC.Send(v) // B forwards the Wire it received + }) + w := NewEndpoint(8) + var seen []Context + _, _ = w.Receive(func(_ Value, ctx Context) { mu.Lock(); seen = append(seen, ctx); mu.Unlock() }) + if err := a.Connect(refB).Send(w); err != nil { + t.Fatal(err) + } + eventually(t, "C did not receive the Wire", func() bool { mu.Lock(); defer mu.Unlock(); return atC != nil }) + if b.Live() != 1 || c.Live() != 1 { + t.Fatalf("forwarding registered state: B %d C %d", b.Live(), c.Live()) + } + _ = atC.Send(text("from C")) + eventually(t, "C's send did not reach W", func() bool { mu.Lock(); defer mu.Unlock(); return len(seen) == 1 }) + _ = w.Close() + mu.Lock() + fromB := atB + mu.Unlock() + _ = fromB.Send(text("late B")) + _ = atC.Send(text("late C")) + eventually(t, "late sends were not refused at A", func() bool { + n := 0 + for _, d := range tr.diagnostics() { + if d.who == "client" && errors.Is(d.err, UnknownExport) { + n++ + } + } + return n == 2 + }) + mu.Lock() + defer mu.Unlock() + if len(seen) != 1 { + t.Fatal("a closed Wire received") + } + }) +} + +func TestObservation5NoProxyOfAProxy(t *testing.T) { + tr := newTree(t, "pair", "client", "provider", "third") + a, b, c := tr.parts["client"].scope(), tr.parts["provider"].scope(), tr.parts["third"].scope() + w := NewEndpoint(1) + ref, _ := a.Expose(w) + imported := b.Connect(ref) + for _, s := range []*Scope{b, c} { // a second local participant encodes it too + got, err := s.encode(imported, 0, map[*Endpoint]ontos.Atom{}, &budget{}) + if err != nil || !got.Equal(ref.value()) { + t.Fatalf("%v: reference changed", err) + } + } + if b.Live() != 0 || c.Live() != 0 { + t.Fatal("a proxy was re-exported") + } +} + +type funcWire func(Value) error + +func (f funcWire) Send(v Value) error { return f(v) } + +func TestObservation6RefusalsBeforeAdmission(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() + _, svc := serve(t, b, func(Value, Context) {}) + target := a.Connect(svc) + kept := NewEndpoint(1) + if err := target.Send(tuple(t, kept, funcWire(func(Value) error { return nil }))); !errors.Is(err, Unexportable) { + t.Fatalf("arbitrary Wire: %v", err) + } + other, _ := NewScope(NewNamespace("other"), wire.Path{text("x")}, nil, DefaultLimits) + foreign := other.Connect(Reference{wire.Path{text("y")}, ontos.NewAtom(make([]byte, 16)), text("1")}) + if err := target.Send(tuple(t, kept, foreign)); !errors.Is(err, ForeignNamespace) { + t.Fatalf("foreign proxy: %v", err) + } + closed := NewEndpoint(1) + _ = closed.Close() + if err := target.Send(closed); !errors.Is(err, Unexportable) { + t.Fatalf("closed endpoint: %v", err) + } + if a.Live() != 0 || tr.frames() != 0 { + t.Fatalf("a refused send left state: %d exports, %d frames", a.Live(), tr.frames()) + } +} + +func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + _, svc := serve(t, tr.parts["provider"].scope(), func(Value, Context) {}) + old := tr.parts["client"].scope().Connect(svc) + fresh := tr.replace(t, "provider") + var mu sync.Mutex + hit := false + _, _ = serve(t, fresh, func(Value, Context) { mu.Lock(); hit = true; mu.Unlock() }) + _ = old.Send(text("stale")) + eventually(t, "not refused stale-scope", func() bool { + d := tr.diagnostics() + return len(d) == 1 && d[0].who == "provider" && errors.Is(d[0].err, StaleScope) + }) + mu.Lock() + defer mu.Unlock() + if hit { + t.Fatal("the replacement received a stale frame") + } + }) +} + +func TestObservation8ReturningHome(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() + var back Wire + var mu sync.Mutex + proceed := make(chan struct{}) + _, svc := serve(t, b, func(v Value, _ Context) { + <-proceed + xs, _ := Items(v) + r, _ := NewTuple(text("data"), xs[0], xs[1]) // send both Wires home + _ = xs[2].(Wire).Send(r) + }) + home, gone := NewEndpoint(1), NewEndpoint(1) + reply := NewEndpoint(1) + got := make(chan Value, 1) + _, _ = reply.Receive(func(v Value, _ Context) { got <- v }) + if err := a.Connect(svc).Send(tuple(t, home, gone, reply)); err != nil { + t.Fatal(err) + } + // gone closes before its reference comes back. + eventually(t, "export not registered", func() bool { return a.Live() == 3 }) + _ = gone.Close() + close(proceed) + select { + case v := <-got: + xs := items(t, v) + mu.Lock() + back = xs[1].(Wire) + mu.Unlock() + if !xs[0].(ontos.Atom).Equal(text("data")) { + t.Fatal("the rest of the message was lost") + } + if back != home.Wire() { + t.Fatal("own reference did not return to the original face") + } + if err := xs[2].(Wire).Send(text("x")); !errors.Is(err, UnknownExport) { + t.Fatalf("a withdrawn own reference: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("no reply") + } +} + +func TestObservation9ReceivedContextIsNotData(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + got := make(chan received, 1) + _, svc := serve(t, tr.parts["provider"].scope(), func(v Value, c Context) { got <- received{v, c} }) + forged := ontos.NewTuple(text("context"), text("client-link")) + if err := tr.parts["client"].scope().Connect(svc).Send(forged); err != nil { + t.Fatal(err) + } + r := <-got + if r.context != "provider-link" || !forged.Equal(r.value.(ontos.Value)) { + t.Fatalf("context %v, value %v", r.context, r.value) + } +} + +func TestObservation10AtomicDecoding(t *testing.T) { + tr := newTree(t, "pair", "client", "provider") + b := tr.parts["provider"].scope() + var mu sync.Mutex + delivered := 0 + _, svc := serve(t, b, func(Value, Context) { mu.Lock(); delivered++; mu.Unlock() }) + w := NewEndpoint(1) + ref, _ := tr.parts["client"].scope().Expose(w) + bad := ontos.NewTuple(tupleTag, ontos.NewTuple(ref.value(), ontos.NewTuple(tupleTag))) + if err := b.Deliver(svc.Path, ontos.NewTuple(header, svc.Scope, svc.ID, bad), nil); !errors.Is(err, MalformedFrame) { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if delivered != 0 { + t.Fatal("a malformed frame was delivered") + } +} + +type refusingSender struct{} + +func (refusingSender) Send(wire.Path, ontos.Value) error { return errors.New("carrier refused") } + +func TestObservation11RegistrationBeforeAdmission(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, DefaultLimits) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) + w := NewEndpoint(1) + if err := target.Send(tuple(t, w)); err == nil { + t.Fatal("the carrier's refusal was hidden") + } + if s.Live() != 1 { + t.Fatal("a refused admission withdrew or never registered the export") + } + if err := target.Send(tuple(t, w, w)); err == nil || s.Live() != 1 { + t.Fatal("a second attempt added an export for the same face") + } + _ = w.Close() + if s.Live() != 0 { + t.Fatal("closing the endpoint did not end its export") + } +} + +func TestObservation12Ordering(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider") + var mu sync.Mutex + var seen []string + _, svc := serve(t, tr.parts["provider"].scope(), func(v Value, _ Context) { + mu.Lock() + seen = append(seen, string(v.(ontos.Atom).Bytes())) + mu.Unlock() + }) + target := tr.parts["client"].scope().Connect(svc) + for i := 0; i < 50; i++ { + if err := target.Send(text(fmt.Sprint(i))); err != nil { + t.Fatal(err) + } + } + eventually(t, "not all delivered", func() bool { mu.Lock(); defer mu.Unlock(); return len(seen) == 50 }) + for i, s := range seen { + if s != fmt.Sprint(i) { + t.Fatalf("position %d holds %s", i, s) + } + } + }) +} + +func TestObservation13Bounds(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, Limits{Exports: 2, Nodes: 8, Depth: 3, Bytes: 64}) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) + deep := Value(text("x")) + for i := 0; i < 5; i++ { + deep = tuple(t, deep) + } + wide := make([]Value, 9) + for i := range wide { + wide[i] = text("y") + } + for name, v := range map[string]Value{ + "depth": deep, + "nodes": tuple(t, wide...), + "bytes": ontos.NewAtom(make([]byte, 65)), + "exports": tuple(t, NewEndpoint(1), NewEndpoint(1), NewEndpoint(1)), + } { + if err := target.Send(v); !errors.Is(err, Limit) { + t.Fatalf("%s: %v", name, err) + } + } + if s.Live() != 0 { + t.Fatal("a refused send registered exports") + } + // A target that does not admit a delivery: queue of one, no receiver. + tr := newTree(t, "pair", "client", "provider") + full := NewEndpoint(1) + svc, _ := tr.parts["provider"].scope().Expose(full) + to := tr.parts["client"].scope().Connect(svc) + _ = to.Send(text("1")) + _ = to.Send(text("2")) + eventually(t, "not refused target-refused", func() bool { + d := tr.diagnostics() + return len(d) == 1 && errors.Is(d[0].err, TargetRefused) + }) +} diff --git a/hydrated/go/testdata/hydrated-vectors.json b/hydrated/go/testdata/hydrated-vectors.json new file mode 100644 index 0000000..05340c6 --- /dev/null +++ b/hydrated/go/testdata/hydrated-vectors.json @@ -0,0 +1,53 @@ +{ + "basis": "bitwire decision 0019 (proposed), hydrated frame edition bitwire/hydrated/1. Encodings are hand-derived from ontos-codec-v1 (Atom = 00 uvarint(length) bytes; Tuple = 01 uvarint(arity) items), then checked against the released bitwire 0.5.0 encoder. Atoms are hex. Tags: 01 tuple, 02 wire; 00 and every other value are reserved. A reference is (02, ((path segments...), scope, id)) with a 16-octet scope and a 1-to-16-octet id. \"live\" describes the hydrated value with wire{path, scope, id} for a wire leaf; S and S2 are the two fixed scopes below.", + "scopes": {"S":"000102030405060708090a0b0c0d0e0f","S2":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"}, + "encode": [ + {"name":"bare atom","live":"hello","body":{"atom":"68656c6c6f"},"hex":"000568656c6c6f"}, + {"name":"empty atom","live":"\"\"","body":{"atom":""},"hex":"0000"}, + {"name":"an atom equal to the tuple tag stays an atom","live":"0x01","body":{"atom":"01"},"hex":"000101"}, + {"name":"an atom equal to the wire tag stays an atom","live":"0x02","body":{"atom":"02"},"hex":"000102"}, + {"name":"empty tuple","live":"()","body":{"tuple":[{"atom":"01"},{"tuple":[]}]},"hex":"01020001010100"}, + {"name":"tuple of two atoms","live":"(a, b)","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"61"},{"atom":"62"}]}]},"hex":"01020001010102000161000162"}, + {"name":"wire leaf","live":"wire{[client42], S, \"1\"}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]},"hex":"0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"wire at the root path","live":"wire{[], S, \"1\"}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]},"hex":"0102000102010301000010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"wire with exact binary path segments","live":"wire{[\"\", ff2f], S2, \"7\"}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":""},{"atom":"ff2f"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"37"}]}]},"hex":"01020001020103010200000002ff2f0010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000137"}, + {"name":"echo request with a reply wire","live":"(echo, hello, wire{[client42], S, \"1\"})","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"0102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"nested wires in a nested tuple","live":"(hello, (wire{[a,b,service2], S2, \"7\"}, wire{[client42], S, \"2\"}))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"37"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"32"}]}]}]}]}]}]},"hex":"01020001010102000568656c6c6f01020001010102010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0001370102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000132"}, + {"name":"one wire twice encodes identical references","live":"(wire{[client42], S, \"1\"}, wire{[client42], S, \"1\"})","body":{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"010200010101020102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0001310102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"data shaped like an encoded wire stays data","live":"(0x02, ((client42), S, \"1\"))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"02"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"},{"tuple":[{"atom":"636c69656e743432"}]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"0102000101010200010201020001010103010200010101010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"data shaped like an encoded tuple stays data","live":"(0x01, ())","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"01"},{"tuple":[{"atom":"01"},{"tuple":[]}]}]}]},"hex":"0102000101010200010101020001010100"} + ], + "frames": [ + {"name":"echo request to the provider","path":["61","62","7365727669636532"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"37","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101030001610001620008736572766963653201040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0001370102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"reply to the caller carrying a further wire","path":["636c69656e743432"],"scope":"000102030405060708090a0b0c0d0e0f","id":"31","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"38"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101010008636c69656e74343201040012626974776972652f68796472617465642f310010000102030405060708090a0b0c0d0e0f00013101020001010102000568656c6c6f010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000138"}, + {"name":"ground data only","path":["","ff2f"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"37","body":{"atom":"70696e67"},"hex":"01030013626974776972652f6164647265737365642f31010200000002ff2f01040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000137000470696e67"} + ], + "rejectBody": [ + {"name":"a tag with no payload","value":{"tuple":[{"atom":"01"}]}}, + {"name":"a tag with two payloads","value":{"tuple":[{"atom":"01"},{"tuple":[]},{"tuple":[]}]}}, + {"name":"a tag that is not an atom","value":{"tuple":[{"tuple":[]},{"tuple":[]}]}}, + {"name":"tag 0x00 is reserved","value":{"tuple":[{"atom":"00"},{"tuple":[]}]}}, + {"name":"unknown tag 0x03","value":{"tuple":[{"atom":"03"},{"tuple":[]}]}}, + {"name":"a two-octet tag","value":{"tuple":[{"atom":"0101"},{"tuple":[]}]}}, + {"name":"tuple payload that is an atom","value":{"tuple":[{"atom":"01"},{"atom":"78"}]}}, + {"name":"wire payload that is an atom","value":{"tuple":[{"atom":"02"},{"atom":"78"}]}}, + {"name":"reference without an id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"70"}]},{"atom":"000102030405060708090a0b0c0d0e0f"}]}]}}, + {"name":"reference path that is an atom","value":{"tuple":[{"atom":"02"},{"tuple":[{"atom":"70"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}}, + {"name":"reference path segment that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"tuple":[]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}}, + {"name":"15-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"31"}]}]}}, + {"name":"17-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f10"},{"atom":"31"}]}]}}, + {"name":"scope that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"tuple":[]},{"atom":"31"}]}]}}, + {"name":"empty id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":""}]}]}}, + {"name":"17-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"3131313131313131313131313131313131"}]}]}}, + {"name":"a malformed node deep inside a valid tuple","value":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6f6b"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"}]}]}]}]}]}} + ], + "rejectFrame": [ + {"name":"another version","value":{"tuple":[{"atom":"626974776972652f68796472617465642f32"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"atom":"78"}]}}, + {"name":"three items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}}, + {"name":"five items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"atom":"78"},{"atom":"79"}]}}, + {"name":"15-octet target scope","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"31"},{"atom":"78"}]}}, + {"name":"empty target id","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":""},{"atom":"78"}]}}, + {"name":"a bare atom","value":{"atom":"626974776972652f68796472617465642f31"}}, + {"name":"a malformed body","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"tuple":[{"atom":"01"}]}]}} + ] +} From 5ea0bc28aec20ffb4c2fc80e954b928a244a108a Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 14:47:05 +0200 Subject: [PATCH 02/21] hydrated: TypeScript mirror and Go/TypeScript interop for 0019 (not for merge) Adds the TypeScript realization of the proposed bitwire decision 0019, mirroring hydrated/go, and observation 14: Go and TypeScript peers exchange bitwire/hydrated/1 frames over a real WebSocket in both roles, carrying a reply Wire, a continuation Wire and a third Wire back. TypeScript repeats observations 1-4 and 6-9 (12 tests, 2/3/4/7 over local pairs and WebSocket) and replays the pinned vectors. Peers build from this repository; fresh published dependencies remain unqualified. Co-Authored-By: Claude Opus 5.5 --- conformance/interop/hydrated/main.go | 167 +++++++++++++ hydrated/README.md | 12 +- hydrated/go/hydrated.go | 9 + hydrated/ts/src/index.ts | 357 +++++++++++++++++++++++++++ hydrated/ts/test/hydrated.test.mjs | 218 ++++++++++++++++ package.json | 6 +- scripts/hydrated-interop.mjs | 89 +++++++ tsconfig.json | 3 +- 8 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 conformance/interop/hydrated/main.go create mode 100644 hydrated/ts/src/index.ts create mode 100644 hydrated/ts/test/hydrated.test.mjs create mode 100644 scripts/hydrated-interop.mjs diff --git a/conformance/interop/hydrated/main.go b/conformance/interop/hydrated/main.go new file mode 100644 index 0000000..ef4b9ab --- /dev/null +++ b/conformance/interop/hydrated/main.go @@ -0,0 +1,167 @@ +// Command hydrated is the Go peer of the hydrated interoperability check +// (scripts/hydrated-interop.mjs), evidence for bitwire decision 0019. Test-only. +// +// hydrated serve listen at participant [s]; on connection, expose an echo +// endpoint and send its reference payload at ["bootstrap"]; +// print the URL; exit when stdin closes +// hydrated client participant [c]: call "continue" with a reply Wire, send a +// third Wire through the returned continuation, print "ok" +// when the third Wire receives the value +// +// The echo: (op, arg, reply) replies (arg, continuation); the continuation sends +// the value it receives to the Wire it carries, then closes. +package main + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + hydrated "github.com/Bitspark/bitruntime/hydrated/go" + websocket "github.com/Bitspark/bitruntime/websocket/go" + ontos "github.com/Bitspark/bitwire/ontos/go/core" + wire "github.com/Bitspark/bitwire/wire/go" +) + +var ( + ns = hydrated.NewNamespace("interop") + bootstrap = wire.Path{ontos.NewAtom([]byte("bootstrap"))} +) + +func text(s string) ontos.Atom { return ontos.NewAtom([]byte(s)) } + +func fail(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +// participant composes a scope at path over an addressed link; bootstrap values +// go to onBootstrap, everything else to the scope with the link as context. +func participant(link wire.AddressedEndpoint, path wire.Path, onBootstrap func(ontos.Value)) *hydrated.Scope { + scope, err := hydrated.NewScope(ns, path, link, hydrated.DefaultLimits) + if err != nil { + fail(err) + } + if _, err := link.Receive(func(p wire.Path, v ontos.Value) { + if wire.PathEqual(p, bootstrap) { + onBootstrap(v) + return + } + if err := scope.Deliver(p, v, "link"); err != nil { + fmt.Fprintln(os.Stderr, "diagnostic:", err) + } + }); err != nil { + fail(err) + } + return scope +} + +func echo(v hydrated.Value, _ hydrated.Context) { + xs, _ := hydrated.Items(v) + cont := hydrated.NewEndpoint(1) + _, _ = cont.Receive(func(next hydrated.Value, _ hydrated.Context) { + ys, _ := hydrated.Items(next) + _ = ys[1].(hydrated.Wire).Send(ys[0]) + _ = cont.Close() + }) + r, _ := hydrated.NewTuple(xs[1], cont) + _ = xs[2].(hydrated.Wire).Send(r) +} + +func serve() { + server, err := websocket.Listen("127.0.0.1:0", websocket.Options{}, func(e *websocket.Endpoint, _ *http.Request) { + link := core.Addressed(e) + scope := participant(link, wire.Path{text("s")}, func(ontos.Value) {}) + service := hydrated.NewEndpoint(16) + _, _ = service.Receive(echo) + ref, err := scope.Expose(service) + if err != nil { + fail(err) + } + if err := link.Send(bootstrap, ref.Payload()); err != nil { + fail(err) + } + }) + if err != nil { + fail(err) + } + fmt.Println(server.URL()) + _, _ = bufio.NewReader(os.Stdin).ReadString(0) + _ = server.Close() +} + +func client(url string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + e, err := websocket.Dial(ctx, url, websocket.Options{}) + if err != nil { + fail(err) + } + refs := make(chan ontos.Value, 1) + scope := participant(core.Addressed(e), wire.Path{text("c")}, func(v ontos.Value) { refs <- v }) + var payload ontos.Value + select { + case payload = <-refs: + case <-time.After(10 * time.Second): + fail(fmt.Errorf("no bootstrap reference")) + } + ref, err := hydrated.ReadReference(payload) + if err != nil { + fail(err) + } + reply := hydrated.NewEndpoint(1) + replies := make(chan hydrated.Value, 1) + _, _ = reply.Receive(func(v hydrated.Value, _ hydrated.Context) { replies <- v }) + req, _ := hydrated.NewTuple(text("continue"), text("hello"), reply) + if err := scope.Connect(ref).Send(req); err != nil { + fail(err) + } + var r hydrated.Value + select { + case r = <-replies: + case <-time.After(10 * time.Second): + fail(fmt.Errorf("no reply")) + } + xs, _ := hydrated.Items(r) + if !xs[0].(ontos.Atom).Equal(text("hello")) { + fail(fmt.Errorf("wrong reply")) + } + third := hydrated.NewEndpoint(1) + thirds := make(chan hydrated.Value, 1) + _, _ = third.Receive(func(v hydrated.Value, _ hydrated.Context) { thirds <- v }) + next, _ := hydrated.NewTuple(text("third Wire"), third) + if err := xs[1].(hydrated.Wire).Send(next); err != nil { + fail(err) + } + select { + case v := <-thirds: + if !v.(ontos.Atom).Equal(text("third Wire")) { + fail(fmt.Errorf("wrong third value")) + } + case <-time.After(10 * time.Second): + fail(fmt.Errorf("third Wire not reached")) + } + fmt.Println("ok") + _ = e.Close() +} + +func main() { + if len(os.Args) < 2 { + fail(fmt.Errorf("usage: hydrated serve | client ")) + } + switch os.Args[1] { + case "serve": + serve() + case "client": + if len(os.Args) < 3 { + fail(fmt.Errorf("client needs a url")) + } + client(os.Args[2]) + default: + fail(fmt.Errorf("unknown mode %q", os.Args[1])) + } +} diff --git a/hydrated/README.md b/hydrated/README.md index e8c7399..b83ed8c 100644 --- a/hydrated/README.md +++ b/hydrated/README.md @@ -8,7 +8,13 @@ branch is not proposed for merge until the record is accepted. The run the record's observations 1-13 around an opaque middle router, over local pairs and WebSocket where the carrier matters. +The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4 and 6-9 +([tests](ts/test/hydrated.test.mjs)), with 2, 3, 4 and 7 over both carriers. +[scripts/hydrated-interop.mjs](../scripts/hydrated-interop.mjs) runs observation +14 over a real WebSocket in both roles, Go serving TypeScript and TypeScript +serving Go. Both peers build from this repository, not from fresh published +dependencies, so they show the grammar interoperates, not package delivery. + The record's vectors are copied from bitwire#83 at `20a6b6b` into -[testdata](go/testdata/hydrated-vectors.json), pinned by sha256. Observation 14 -(Go and TypeScript peers with fresh published dependencies) needs a TypeScript -realization and is not run here. +[testdata](go/testdata/hydrated-vectors.json), pinned by sha256; both languages +replay them. diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index 11cb7bf..b310840 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -121,6 +121,15 @@ func (r Reference) value() ontos.Value { return ontos.NewTuple(wireTag, ontos.NewTuple(ontos.NewTuple(segments...), r.Scope, r.ID)) } +// Payload is the reference's ground payload ((path...), scope, id), for +// composition bootstrap over ground data. +func (r Reference) Payload() ontos.Value { + return r.value().(ontos.Tuple).At(1) +} + +// ReadReference reads a reference payload. +func ReadReference(v ontos.Value) (Reference, error) { return readReference(v, DefaultLimits.Depth) } + func (r Reference) key() string { k := strconv.Itoa(len(r.Path)) for _, a := range r.Path { diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts new file mode 100644 index 0000000..05413f8 --- /dev/null +++ b/hydrated/ts/src/index.ts @@ -0,0 +1,357 @@ +// Evidence for bitwire decision 0019 (proposed): the first edition of the +// hydrated wire protocol, bitwire/hydrated/1, realized in TypeScript. It mirrors +// hydrated/go and is not a released API. +import { Atom, Tuple, atom, tuple, encodeMessage, pathEqual } from '@bitspark/bitwire'; +import type { Path, AddressedWire } from '@bitspark/bitwire'; + +/** A hydrated value: a ground ontos value, a HydratedTuple or a Wire. */ +export type Value = Atom | Tuple | HydratedTuple | Wire; + +/** Sends one hydrated value. Only an Endpoint's face or a proxy can travel (D7). */ +export interface Wire { send(value: Value): Promise } + +/** What the composition establishes about an arrival; never part of a value (D4). */ +export type Context = unknown; + +/** The context of a send that never left the process. */ +export const LOCAL: unique symbol = Symbol('local'); + +export const MALFORMED_FRAME = 'malformed-frame'; +export const STALE_SCOPE = 'stale-scope'; +export const UNKNOWN_EXPORT = 'unknown-export'; +export const LIMIT = 'limit'; +export const TARGET_REFUSED = 'target-refused'; +export const UNEXPORTABLE = 'unexportable'; +export const FOREIGN_NAMESPACE = 'foreign-namespace'; +export const SCOPE_ENDED = 'scope-ended'; + +/** A refusal: a host diagnostic at the owner, or a refusal before admission at the sender (D8). */ +export class Refusal extends Error { + readonly reason: string; + constructor(reason: string) { + super(reason); + this.reason = reason; + } +} + +const HEADER = atom(new TextEncoder().encode('bitwire/hydrated/1')); +const TUPLE_TAG = atom([1]); +const WIRE_TAG = atom([2]); + +/** A tuple with at least one Wire beneath it; see hydratedTuple (D1). */ +export class HydratedTuple { + readonly items: readonly Value[]; + constructor(items: readonly Value[]) { + this.items = Object.freeze([...items]); + Object.freeze(this); + } +} + +// Any Wire may sit in a tuple; encoding refuses one the runtime does not own (D7). +const isWire = (v: unknown): v is Wire => typeof (v as Wire | undefined)?.send === 'function'; + +/** Captures items; returns the ground tuple when no Wire is beneath them. */ +export function hydratedTuple(items: readonly Value[]): Value { + const captured = [...items]; + let live = false; + for (const item of captured) { + if (item instanceof HydratedTuple || isWire(item)) live = true; + else if (!(item instanceof Atom) && !(item instanceof Tuple)) throw new TypeError('hydrated: not a value'); + } + return live ? new HydratedTuple(captured) : tuple(captured as (Atom | Tuple)[]); +} + +/** Any tuple's items, ground or hydrated. */ +export function items(v: Value): readonly Value[] | undefined { + if (v instanceof HydratedTuple) return v.items; + if (v instanceof Tuple) return v.items(); + return undefined; +} + +/** One set of participants addressed by absolute paths (D6). */ +export class Namespace { + readonly name: string; + constructor(name: string) { this.name = name; } +} + +/** The ground form of a live Wire (D2, D3). */ +export interface Reference { readonly path: Path; readonly scope: Atom; readonly id: Atom } + +const hex = (a: Atom) => Array.from(a.bytes(), (b) => b.toString(16).padStart(2, '0')).join(''); +const refKey = (r: Reference) => `${r.path.map(hex).join('/')}|${r.path.length}|${hex(r.scope)}|${hex(r.id)}`; +const refValue = (r: Reference): Tuple => tuple([WIRE_TAG, tuple([tuple(r.path), r.scope, r.id])]); + +/** + * A local HydratedEndpoint: one receiver, ordered dispatch never inline with the + * admitting send, a bounded queue and an owner lifetime. Its face is what a value + * carries; closing it ends its export in every scope (D7). + */ +export class Endpoint implements Wire { + readonly wire: Wire; + readonly closed: Promise; + #queue: { value: Value; context: Context }[] = []; + #limit: number; + #handler: ((value: Value, context: Context) => void) | undefined; + #isClosed = false; + #draining = false; + #resolveClosed!: () => void; + readonly #exports = new Map(); + + constructor(limit = 1) { + this.#limit = Math.max(1, limit); + this.wire = new Face(this); + this.closed = new Promise((resolve) => { this.#resolveClosed = resolve; }); + } + + send(value: Value): Promise { return this.wire.send(value); } + + receive(handler: (value: Value, context: Context) => void): () => void { + if (this.#handler) throw new Error('hydrated: receiver already attached'); + this.#handler = handler; + this.#drain(); + return () => { this.#handler = undefined; }; + } + + async close(): Promise { + if (this.#isClosed) return; + this.#isClosed = true; + this.#queue = []; + const exports = [...this.#exports]; + this.#exports.clear(); + for (const [scope, id] of exports) scope._withdraw(this, id); + this.#resolveClosed(); + } + + get isClosed(): boolean { return this.#isClosed; } + + /** @internal */ + _admit(value: Value, context: Context): void { + if (this.#isClosed || this.#queue.length >= this.#limit) throw new Refusal(TARGET_REFUSED); + this.#queue.push({ value, context }); + this.#drain(); + } + + /** @internal */ + _register(scope: Scope, id: Atom): boolean { + if (this.#isClosed) return false; + this.#exports.set(scope, id); + return true; + } + + /** @internal */ + _unregister(scope: Scope): void { this.#exports.delete(scope); } + + #drain(): void { + if (this.#draining) return; + this.#draining = true; + setImmediate(() => { + this.#draining = false; + while (!this.#isClosed && this.#handler && this.#queue.length) { + const d = this.#queue.shift()!; + this.#handler(d.value, d.context); + } + }); + } +} + +/** An Endpoint's sending face. It grants sending only. */ +class Face implements Wire { + readonly #endpoint: Endpoint; + constructor(endpoint: Endpoint) { this.#endpoint = endpoint; Object.freeze(this); } + async send(value: Value): Promise { this.#endpoint._admit(value, LOCAL); } + /** @internal */ + get _endpoint(): Endpoint { return this.#endpoint; } +} + +/** + * An imported Wire. It carries its reference and namespace, so any participant + * in the namespace forwards it unchanged (D6). A dead reason marks this + * participant's own stale or withdrawn reference (D7, returning home). + */ +class Proxy implements Wire { + readonly ref: Reference; + readonly ns: Namespace; + readonly #via: Scope; + readonly #dead: string | undefined; + constructor(ref: Reference, ns: Namespace, via: Scope, dead?: string) { + this.ref = ref; this.ns = ns; this.#via = via; this.#dead = dead; + Object.freeze(this); + } + async send(value: Value): Promise { + if (this.#dead) throw new Refusal(this.#dead); + return this.#via._send(this.ref, value); + } +} + +/** Bounds of a scope (D8). */ +export interface Limits { exports: number; nodes: number; depth: number; bytes: number } +export const DEFAULT_LIMITS: Limits = Object.freeze({ exports: 64, nodes: 4096, depth: 64, bytes: 1 << 20 }); + +interface Budget { nodes: number; bytes: number } + +/** One participant's hydration state for one incarnation (Terms). */ +export class Scope { + readonly path: Path; + readonly token: Atom; + readonly ns: Namespace; + readonly #sender: AddressedWire; + readonly #limits: Limits; + #next = 0n; + readonly #byId = new Map(); + readonly #byEndpoint = new Map(); + #closed = false; + + constructor(ns: Namespace, path: Path, sender: AddressedWire, limits: Limits = DEFAULT_LIMITS) { + for (const n of Object.values(limits)) if (!Number.isSafeInteger(n) || n < 1) throw new RangeError('hydrated: limits must be positive'); + this.ns = ns; + this.path = Object.freeze([...path]); + this.#sender = sender; + this.#limits = { ...limits }; + this.token = atom(crypto.getRandomValues(new Uint8Array(16))); + } + + get live(): number { return this.#byEndpoint.size; } + + /** Composition bootstrap: export an endpoint's face. Domain adapters never call it. */ + expose(endpoint: Endpoint): Reference { + const staged = new Map(); + const id = this.#exportId(endpoint, staged); + this.#commit(staged); + return { path: this.path, scope: this.token, id }; + } + + /** Composition bootstrap: a proxy for a reference. */ + connect(ref: Reference): Wire { return this.#import(ref, new Map()); } + + /** Ends the scope: its exports end and its references go stale. Closes no endpoint. */ + close(): void { + this.#closed = true; + for (const endpoint of this.#byEndpoint.keys()) endpoint._unregister(this); + this.#byEndpoint.clear(); + this.#byId.clear(); + } + + /** @internal */ + _withdraw(endpoint: Endpoint, id: Atom): void { + if (this.#byId.get(hex(id)) === endpoint) { + this.#byId.delete(hex(id)); + this.#byEndpoint.delete(endpoint); + } + } + + #exportId(endpoint: Endpoint, staged: Map): Atom { + if (endpoint.isClosed) throw new Refusal(UNEXPORTABLE); + if (this.#closed) throw new Refusal(SCOPE_ENDED); + const known = staged.get(endpoint) ?? this.#byEndpoint.get(endpoint); + if (known) return known; + if (this.#byEndpoint.size + staged.size >= this.#limits.exports) throw new Refusal(LIMIT); + const id = atom(new TextEncoder().encode(String(++this.#next))); + staged.set(endpoint, id); + return id; + } + + #commit(staged: Map): void { + if (this.#closed) throw new Refusal(SCOPE_ENDED); + for (const [endpoint, id] of staged) { + if (this.#byEndpoint.has(endpoint)) continue; + if (endpoint._register(this, id)) { + this.#byEndpoint.set(endpoint, id); + this.#byId.set(hex(id), endpoint); + } + } + } + + #spend(b: Budget, depth: number, bytes: number): void { + b.nodes++; b.bytes += bytes; + if (b.nodes > this.#limits.nodes || depth > this.#limits.depth || b.bytes > this.#limits.bytes) throw new Refusal(LIMIT); + } + + /** Encodes atomically, registers new exports, then admits one frame (D5). */ + /** @internal */ + async _send(dest: Reference, value: Value): Promise { + if (this.#closed) throw new Refusal(SCOPE_ENDED); + const staged = new Map(); + const body = this._encode(value, 0, staged, { nodes: 0, bytes: 0 }); + const frame = tuple([HEADER, dest.scope, dest.id, body]); + try { encodeMessage(frame, this.#limits.bytes); } catch { throw new Refusal(LIMIT); } + this.#commit(staged); + await this.#sender.send(dest.path, frame); + } + + /** @internal */ + _encode(v: Value, depth: number, staged: Map, b: Budget): Atom | Tuple { + if (v instanceof Atom) { this.#spend(b, depth, v.length); return v; } + if (v instanceof Tuple || v instanceof HydratedTuple) { + this.#spend(b, depth, 0); + return tuple([TUPLE_TAG, tuple(items(v)!.map((c) => this._encode(c, depth + 1, staged, b)))]); + } + if (v instanceof Endpoint) return this._encode(v.wire, depth, staged, b); + if (v instanceof Face) { + this.#spend(b, depth, 0); + return refValue({ path: this.path, scope: this.token, id: this.#exportId(v._endpoint, staged) }); + } + if (v instanceof Proxy) { + if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); + this.#spend(b, depth, 0); + return refValue(v.ref); + } + if (v && typeof (v as Wire).send === 'function') throw new Refusal(UNEXPORTABLE); + throw new TypeError('hydrated: not a value'); + } + + /** Decides one value addressed to this participant's own path (D4); throws a Refusal as a host diagnostic. */ + deliver(path: Path, message: Atom | Tuple, context: Context): void { + if (!pathEqual(path, this.path)) throw new Refusal(MALFORMED_FRAME); + if (this.#closed) throw new Refusal(SCOPE_ENDED); + if (!(message instanceof Tuple) || message.length !== 4 || !HEADER.equals(message.at(0)!)) throw new Refusal(MALFORMED_FRAME); + const [, token, id, body] = message.items(); + if (!(token instanceof Atom) || !(id instanceof Atom) || token.length !== 16 || id.length < 1 || id.length > 16) throw new Refusal(MALFORMED_FRAME); + if (!token.equals(this.token)) throw new Refusal(STALE_SCOPE); + const target = this.#byId.get(hex(id)); + if (!target) throw new Refusal(UNKNOWN_EXPORT); + const value = this._decode(body!, 0, new Map(), { nodes: 0, bytes: 0 }); + target._admit(value, context); + } + + /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). */ + /** @internal */ + _decode(v: Atom | Tuple, depth: number, interned: Map, b: Budget): Value { + if (v instanceof Atom) { this.#spend(b, depth, v.length); return v; } + if (v.length !== 2) throw new Refusal(MALFORMED_FRAME); + const [tag, payload] = v.items(); + if (!(tag instanceof Atom) || tag.length !== 1) throw new Refusal(MALFORMED_FRAME); + this.#spend(b, depth, 0); + if (tag.equals(TUPLE_TAG)) { + if (!(payload instanceof Tuple)) throw new Refusal(MALFORMED_FRAME); + return hydratedTuple(payload.items().map((c) => this._decode(c, depth + 1, interned, b))); + } + if (tag.equals(WIRE_TAG)) return this.#import(readReference(payload!, this.#limits.depth), interned); + throw new Refusal(MALFORMED_FRAME); + } + + /** A reference home in the current scope returns the original face; stale or withdrawn, a Wire that refuses (D7). */ + #import(ref: Reference, interned: Map): Wire { + if (pathEqual(ref.path, this.path)) { + if (!ref.scope.equals(this.token)) return new Proxy(ref, this.ns, this, STALE_SCOPE); + const endpoint = this.#byId.get(hex(ref.id)); + return endpoint ? endpoint.wire : new Proxy(ref, this.ns, this, UNKNOWN_EXPORT); + } + const k = refKey(ref); + let w = interned.get(k); + if (!w) { w = new Proxy(ref, this.ns, this); interned.set(k, w); } + return w; + } +} + +/** Reads a reference payload ((path...), scope, id). */ +export function readReference(v: Atom | Tuple, maxDepth: number = DEFAULT_LIMITS.depth): Reference { + if (!(v instanceof Tuple) || v.length !== 3) throw new Refusal(MALFORMED_FRAME); + const [p, scope, id] = v.items(); + if (!(p instanceof Tuple) || p.length > maxDepth) throw new Refusal(MALFORMED_FRAME); + const path = p.items().map((s) => { if (!(s instanceof Atom)) throw new Refusal(MALFORMED_FRAME); return s; }); + if (!(scope instanceof Atom) || !(id instanceof Atom) || scope.length !== 16 || id.length < 1 || id.length > 16) throw new Refusal(MALFORMED_FRAME); + return Object.freeze({ path: Object.freeze(path), scope, id }); +} + +/** The reference payload of a reference, for composition bootstrap over ground data. */ +export function referenceValue(ref: Reference): Tuple { return tuple([tuple(ref.path), ref.scope, ref.id]); } diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs new file mode 100644 index 0000000..04bfa38 --- /dev/null +++ b/hydrated/ts/test/hydrated.test.mjs @@ -0,0 +1,218 @@ +// Observations of bitwire decision 0019 (proposed) for the TypeScript evidence; +// hydrated/go runs all thirteen, this suite the ones a second language must repeat. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { Atom, Tuple, atom, tuple, encodeMessage } from '@bitspark/bitwire'; +import { pair, addressed } from '../../../dist/core/ts/src/index.js'; +import { listenWebSocket, connectWebSocket } from '../../../dist/websocket/ts/src/index.js'; +import { + Endpoint, Namespace, Scope, Refusal, hydratedTuple, items, + UNKNOWN_EXPORT, STALE_SCOPE, UNEXPORTABLE, FOREIGN_NAMESPACE, MALFORMED_FRAME, +} from '../../../dist/hydrated/ts/src/index.js'; + +const text = (s) => atom(new TextEncoder().encode(s)); +const str = (a) => new TextDecoder().decode(a.bytes()); +const tick = () => new Promise((r) => setTimeout(r, 5)); +async function eventually(cond, what) { + for (let i = 0; i < 1000; i++) { if (cond()) return; await tick(); } + assert.fail(what); +} + +test('observation 1: vectors round-trip (bitwire#83 at 20a6b6b, pinned)', () => { + const raw = readFileSync(new URL('../../go/testdata/hydrated-vectors.json', import.meta.url)); + assert.equal(createHash('sha256').update(raw).digest('hex'), '621325467a19fa5d5ad34a1805e38feadb5609ad13fe5e71e991cf87b4d77278'); + const v = JSON.parse(raw); + const from = (x) => ('atom' in x ? atom(Buffer.from(x.atom, 'hex')) : tuple(x.tuple.map(from))); + const wires = (x) => (x instanceof Atom || x instanceof Tuple ? 0 : (items(x)?.reduce((n, c) => n + wires(c), 0) ?? 1)); + const s = new Scope(new Namespace('vectors'), [text('vectors')], { send: async () => {} }); + for (const c of v.encode) { + const body = from(c.body); + const live = s._decode(body, 0, new Map(), { nodes: 0, bytes: 0 }); + assert.equal(wires(live), (c.live.match(/wire\{/g) ?? []).length, c.name); + const again = s._encode(live, 0, new Map(), { nodes: 0, bytes: 0 }); + assert.equal(Buffer.from(encodeMessage(again)).toString('hex'), c.hex, c.name); + } + for (const c of v.rejectBody) { + assert.throws(() => s._decode(from(c.value), 0, new Map(), { nodes: 0, bytes: 0 }), (e) => e.reason === MALFORMED_FRAME, c.name); + } +}); + +const PATHS = { client: [text('client42')], provider: [text('a'), text('b'), text('service2')], third: [text('c')] }; +const key = (p) => p.map((a) => Buffer.from(a.bytes()).toString('hex')).join('/') + `#${p.length}`; + +async function links(t, carrier, n) { + if (carrier === 'pair') return Array.from({ length: n }, () => { const ends = pair(); t.after(() => ends[0].close()); return ends; }); + const accepted = []; + let wake; + const listener = await listenWebSocket({ host: '127.0.0.1' }, (e) => { accepted.push(e); wake?.(); }); + t.after(() => listener.close()); + const out = []; + for (let i = 0; i < n; i++) { + const c = await connectWebSocket(listener.url); + t.after(() => c.close()); + while (accepted.length <= i) await new Promise((r) => { wake = r; }); + out.push([c, accepted[i]]); + } + return out; +} + +// Participants around an opaque middle that knows routes and ground values only. +async function tree(t, carrier, ...names) { + const ns = new Namespace('test'); + const routes = new Map(), trace = [], diags = [], parts = {}; + const ls = await links(t, carrier, names.length); + names.forEach((name, i) => { + const mine = addressed(ls[i][0]), mid = addressed(ls[i][1]); + routes.set(key(PATHS[name]), mid); + mid.receive((p, v) => { trace.push(v); void routes.get(key(p))?.send(p, v); }); + const part = { name, ep: mine, scope: new Scope(ns, PATHS[name], mine) }; + mine.receive((p, v) => { + try { part.scope.deliver(p, v, `${name}-link`); } catch (e) { diags.push({ who: name, reason: e.reason ?? e.message }); } + }); + parts[name] = part; + }); + return { ns, trace, diags, parts }; +} + +function serve(t, scope, handler) { + const e = new Endpoint(64); + e.receive(handler); + t.after(() => e.close()); + return [e, scope.expose(e)]; +} + +async function call(target, op, ...args) { + const reply = new Endpoint(1); + const got = new Promise((resolve) => reply.receive((value, context) => resolve({ value, context }))); + await target.send(hydratedTuple([text(op), ...args, reply])); + const r = await got; + await reply.close(); + return r; +} + +function echo(value) { + const xs = items(value); + const op = str(xs[0]), arg = xs[1], reply = xs.at(-1); + if (op === 'echo') return void reply.send(arg); + const cont = new Endpoint(1); + cont.receive((next) => { const [v, w] = items(next); void w.send(v); void cont.close(); }); + void reply.send(hydratedTuple([arg, cont])); +} + +for (const carrier of ['pair', 'websocket']) { + test(`${carrier}: observation 2, recursion through an opaque router`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider'); + const [, svc] = serve(t, n.parts.provider.scope, echo); + const r = await call(n.parts.client.scope.connect(svc), 'continue', text('hello')); + const [arg, continuation] = items(r.value); + assert.equal(str(arg), 'hello'); + const third = new Endpoint(1); + const got = new Promise((resolve) => third.receive(resolve)); + await continuation.send(hydratedTuple([text('third Wire'), third])); + assert.equal(str(await got), 'third Wire'); + assert.equal(n.trace.length, 4); + assert.deepEqual(n.diags, []); + }); + + test(`${carrier}: observation 3, 200 long-lived calls stay bounded`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider'); + const client = n.parts.client.scope, provider = n.parts.provider.scope; + const [, svc] = serve(t, provider, echo); + const target = client.connect(svc); + for (let i = 0; i < 200; i++) { + const r = await call(target, 'echo', text(String(i))); + assert.equal(str(r.value), String(i)); + assert.ok(client.live <= 1 && provider.live === 1, `call ${i}: ${client.live} ${provider.live}`); + } + assert.equal(client.live, 0); + assert.deepEqual(n.diags, []); + }); + + test(`${carrier}: observation 4, a shared Wire ends only with its owner`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider', 'third'); + const { client: { scope: a }, provider: { scope: b }, third: { scope: c } } = n.parts; + let atB, atC; + const [, refC] = serve(t, c, (v) => { atC = v; }); + const toC = b.connect(refC); + const [, refB] = serve(t, b, (v) => { atB = v; void toC.send(v); }); + const w = new Endpoint(8); + const seen = []; + w.receive((v, ctx) => seen.push(ctx)); + await a.connect(refB).send(w); + await eventually(() => atC, 'C did not receive the Wire'); + assert.equal(b.live + c.live, 2, 'forwarding registered state'); + await atC.send(text('from C')); + await eventually(() => seen.length === 1, "C's send did not reach W"); + await w.close(); + await atB.send(text('late B')); + await atC.send(text('late C')); + await eventually(() => n.diags.filter((d) => d.who === 'client' && d.reason === UNKNOWN_EXPORT).length === 2, 'late sends not refused at A'); + assert.equal(seen.length, 1); + }); + + test(`${carrier}: observation 7, a stale scope cannot reach the replacement`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider'); + const [, svc] = serve(t, n.parts.provider.scope, () => {}); + const old = n.parts.client.scope.connect(svc); + n.parts.provider.scope.close(); + n.parts.provider.scope = new Scope(n.ns, PATHS.provider, n.parts.provider.ep); + let hit = false; + serve(t, n.parts.provider.scope, () => { hit = true; }); + await old.send(text('stale')); + await eventually(() => n.diags.length === 1, 'not refused'); + assert.deepEqual(n.diags, [{ who: 'provider', reason: STALE_SCOPE }]); + assert.equal(hit, false); + }); +} + +test('observation 6: refusals before admission register and send nothing', async (t) => { + const n = await tree(t, 'pair', 'client', 'provider'); + const a = n.parts.client.scope; + const [, svc] = serve(t, n.parts.provider.scope, () => {}); + const target = a.connect(svc); + const kept = new Endpoint(1); + await assert.rejects(target.send(hydratedTuple([kept, { send: async () => {} }])), (e) => e.reason === UNEXPORTABLE); + const foreign = new Scope(new Namespace('other'), [text('x')], { send: async () => {} }) + .connect({ path: [text('y')], scope: atom(new Uint8Array(16)), id: text('1') }); + await assert.rejects(target.send(hydratedTuple([kept, foreign])), (e) => e.reason === FOREIGN_NAMESPACE); + const closed = new Endpoint(1); + await closed.close(); + await assert.rejects(target.send(closed), (e) => e.reason === UNEXPORTABLE); + assert.equal(a.live, 0); + assert.equal(n.trace.length, 0); +}); + +test('observation 8: returning home', async (t) => { + const n = await tree(t, 'pair', 'client', 'provider'); + const a = n.parts.client.scope; + let proceed; + const gate = new Promise((r) => { proceed = r; }); + const [, svc] = serve(t, n.parts.provider.scope, async (v) => { + await gate; + const [home, gone, reply] = items(v); + void reply.send(hydratedTuple([text('data'), home, gone])); + }); + const home = new Endpoint(1), gone = new Endpoint(1), reply = new Endpoint(1); + const got = new Promise((resolve) => reply.receive(resolve)); + await a.connect(svc).send(hydratedTuple([home, gone, reply])); + await gone.close(); + proceed(); + const [data, back, dead] = items(await got); + assert.equal(str(data), 'data'); + assert.equal(back, home.wire, 'own reference did not return to the original face'); + await assert.rejects(dead.send(text('x')), (e) => e instanceof Refusal && e.reason === UNKNOWN_EXPORT); +}); + +test('observation 9: received context is not data', async (t) => { + const n = await tree(t, 'pair', 'client', 'provider'); + const target = new Endpoint(1); + t.after(() => target.close()); + const seen = new Promise((resolve) => target.receive((value, context) => resolve({ value, context }))); + const forged = tuple([text('context'), text('client-link')]); + await n.parts.client.scope.connect(n.parts.provider.scope.expose(target)).send(forged); + const r = await seen; + assert.equal(r.context, 'provider-link'); + assert.ok(forged.equals(r.value)); +}); diff --git a/package.json b/package.json index 933e532..42b8c77 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "./websocket": { "types": "./dist/websocket/ts/src/index.d.ts", "import": "./dist/websocket/ts/src/index.js" + }, + "./hydrated": { + "types": "./dist/hydrated/ts/src/index.d.ts", + "import": "./dist/hydrated/ts/src/index.js" } }, "files": [ @@ -32,7 +36,7 @@ "scripts": { "check": "tsc -p tsconfig.check.json", "build": "node scripts/build.mjs", - "test": "npm run build && node --test --test-timeout=30000 \"core/ts/test/*.test.mjs\" \"websocket/ts/test/*.test.mjs\"" + "test": "npm run build && node --test --test-timeout=30000 \"core/ts/test/*.test.mjs\" \"websocket/ts/test/*.test.mjs\" \"hydrated/ts/test/*.test.mjs\"" }, "dependencies": { "@bitspark/bitwire": "0.5.0", diff --git a/scripts/hydrated-interop.mjs b/scripts/hydrated-interop.mjs new file mode 100644 index 0000000..1715194 --- /dev/null +++ b/scripts/hydrated-interop.mjs @@ -0,0 +1,89 @@ +// Observation 14 of bitwire decision 0019 (proposed): Go and TypeScript peers +// exchange bitwire/hydrated/1 frames over a real WebSocket, each in both roles. +// The exchange: a request carrying a reply Wire, a reply carrying a continuation +// Wire, and a third Wire sent back through the continuation. +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { atom, pathEqual } from '@bitspark/bitwire'; +import { addressed } from '../dist/core/ts/src/index.js'; +import { connectWebSocket, listenWebSocket } from '../dist/websocket/ts/src/index.js'; +import { Endpoint, Namespace, Scope, hydratedTuple, items, readReference, referenceValue } from '../dist/hydrated/ts/src/index.js'; + +const directory = mkdtempSync(join(tmpdir(), 'bitruntime-hydrated-')); +const binary = join(directory, process.platform === 'win32' ? 'hydrated.exe' : 'hydrated'); +assert.equal(spawnSync('go', ['build', '-o', binary, './conformance/interop/hydrated'], { stdio: 'inherit' }).status, 0, 'Go peer build failed'); + +const text = (s) => atom(new TextEncoder().encode(s)); +const str = (a) => new TextDecoder().decode(a.bytes()); +const ns = new Namespace('interop'); +const BOOTSTRAP = [text('bootstrap')]; +const within = (promise, what) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(what)), 10000))]); + +function participant(link, path, onBootstrap) { + const scope = new Scope(ns, path, link); + link.receive((p, v) => { + if (pathEqual(p, BOOTSTRAP)) return onBootstrap(v); + try { scope.deliver(p, v, 'link'); } catch (e) { console.error('diagnostic:', e.reason ?? e.message); } + }); + return scope; +} + +function peer(args) { + const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'inherit'] }); + const lines = [], readers = []; + createInterface({ input: child.stdout }).on('line', (line) => { const r = readers.shift(); if (r) r(line); else lines.push(line); }); + const timeout = setTimeout(() => child.kill(), 30000); + const ended = new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code) => { clearTimeout(timeout); resolve(code); }); }); + return { child, ended, line: () => (lines.length ? Promise.resolve(lines.shift()) : new Promise((r) => readers.push(r))) }; +} + +// Go serves, TypeScript calls. +{ + const server = peer(['serve']); + const url = await server.line(); + assert.match(url ?? '', /^ws:/); + const endpoint = await connectWebSocket(url); + let bootstrapped; + const bootstrap = new Promise((r) => { bootstrapped = r; }); + const scope = participant(addressed(endpoint), [text('c')], (v) => bootstrapped(v)); + const target = scope.connect(readReference(await within(bootstrap, 'no bootstrap reference'))); + const reply = new Endpoint(1); + const replied = new Promise((r) => reply.receive(r)); + await target.send(hydratedTuple([text('continue'), text('hello'), reply])); + const [arg, continuation] = items(await within(replied, 'no reply')); + assert.equal(str(arg), 'hello'); + const third = new Endpoint(1); + const reached = new Promise((r) => third.receive(r)); + await continuation.send(hydratedTuple([text('third Wire'), third])); + assert.equal(str(await within(reached, 'third Wire not reached')), 'third Wire'); + await endpoint.close(); + server.child.stdin.end(); + assert.equal(await server.ended, 0, 'Go server failed'); + console.log('Go serves, TypeScript calls: ok'); +} + +// TypeScript serves, Go calls. +{ + const listener = await listenWebSocket({ host: '127.0.0.1' }, (endpoint) => { + const link = addressed(endpoint); + const scope = participant(link, [text('s')], () => {}); + const service = new Endpoint(16); + service.receive((v) => { + const [, arg, replyWire] = items(v); + const cont = new Endpoint(1); + cont.receive((next) => { const [value, wire] = items(next); void wire.send(value); void cont.close(); }); + void replyWire.send(hydratedTuple([arg, cont])); + }); + void link.send(BOOTSTRAP, referenceValue(scope.expose(service))); + }); + const client = peer(['client', listener.url]); + assert.equal(await within(client.line(), 'Go client did not finish'), 'ok'); + assert.equal(await client.ended, 0, 'Go client failed'); + await listener.close(); + console.log('TypeScript serves, Go calls: ok'); +} +process.exit(0); diff --git a/tsconfig.json b/tsconfig.json index 2ee6f53..fd26c2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ }, "include": [ "core/ts/src/**/*.ts", - "websocket/ts/src/**/*.ts" + "websocket/ts/src/**/*.ts", + "hydrated/ts/src/**/*.ts" ] } From 3adb89cae4daef71501b2b2ee88228b39bb42235 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 16:18:06 +0200 Subject: [PATCH 03/21] hydrated(ts): extract the observation harness for consumer suites Moves the namespace-around-an-opaque-middle harness (links, tree, serve, call, and text helpers) out of hydrated.test.mjs into harness.mjs, so a consumer suite such as Fiber Composition's adapter-composition conformance can import it without editing the 0019 observations. No observation changes; 12/12 pass. Co-Authored-By: Claude Opus 5.5 --- hydrated/ts/test/harness.mjs | 70 ++++++++++++++++++++++++++++++ hydrated/ts/test/hydrated.test.mjs | 64 +-------------------------- 2 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 hydrated/ts/test/harness.mjs diff --git a/hydrated/ts/test/harness.mjs b/hydrated/ts/test/harness.mjs new file mode 100644 index 0000000..22130df --- /dev/null +++ b/hydrated/ts/test/harness.mjs @@ -0,0 +1,70 @@ +// Shared harness for the hydrated TypeScript observations and consumer suites: +// participants of one namespace around an opaque middle router that knows only +// routes and ground values, over local pairs or real WebSockets. Consumer suites +// import it rather than editing hydrated.test.mjs. +import assert from 'node:assert/strict'; +import { atom } from '@bitspark/bitwire'; +import { pair, addressed } from '../../../dist/core/ts/src/index.js'; +import { listenWebSocket, connectWebSocket } from '../../../dist/websocket/ts/src/index.js'; +import { Endpoint, Namespace, Scope, hydratedTuple } from '../../../dist/hydrated/ts/src/index.js'; + +export const text = (s) => atom(new TextEncoder().encode(s)); +export const str = (a) => new TextDecoder().decode(a.bytes()); +export const tick = () => new Promise((r) => setTimeout(r, 5)); +export async function eventually(cond, what) { + for (let i = 0; i < 1000; i++) { if (cond()) return; await tick(); } + assert.fail(what); +} + +export const PATHS = { client: [text('client42')], provider: [text('a'), text('b'), text('service2')], third: [text('c')] }; +export const key = (p) => p.map((a) => Buffer.from(a.bytes()).toString('hex')).join('/') + `#${p.length}`; + +export async function links(t, carrier, n) { + if (carrier === 'pair') return Array.from({ length: n }, () => { const ends = pair(); t.after(() => ends[0].close()); return ends; }); + const accepted = []; + let wake; + const listener = await listenWebSocket({ host: '127.0.0.1' }, (e) => { accepted.push(e); wake?.(); }); + t.after(() => listener.close()); + const out = []; + for (let i = 0; i < n; i++) { + const c = await connectWebSocket(listener.url); + t.after(() => c.close()); + while (accepted.length <= i) await new Promise((r) => { wake = r; }); + out.push([c, accepted[i]]); + } + return out; +} + +// Participants around an opaque middle that knows routes and ground values only. +export async function tree(t, carrier, ...names) { + const ns = new Namespace('test'); + const routes = new Map(), trace = [], diags = [], parts = {}; + const ls = await links(t, carrier, names.length); + names.forEach((name, i) => { + const mine = addressed(ls[i][0]), mid = addressed(ls[i][1]); + routes.set(key(PATHS[name]), mid); + mid.receive((p, v) => { trace.push(v); void routes.get(key(p))?.send(p, v); }); + const part = { name, ep: mine, scope: new Scope(ns, PATHS[name], mine) }; + mine.receive((p, v) => { + try { part.scope.deliver(p, v, `${name}-link`); } catch (e) { diags.push({ who: name, reason: e.reason ?? e.message }); } + }); + parts[name] = part; + }); + return { ns, trace, diags, parts }; +} + +export function serve(t, scope, handler) { + const e = new Endpoint(64); + e.receive(handler); + t.after(() => e.close()); + return [e, scope.expose(e)]; +} + +export async function call(target, op, ...args) { + const reply = new Endpoint(1); + const got = new Promise((resolve) => reply.receive((value, context) => resolve({ value, context }))); + await target.send(hydratedTuple([text(op), ...args, reply])); + const r = await got; + await reply.close(); + return r; +} diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 04bfa38..e07d4af 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -5,20 +5,11 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { Atom, Tuple, atom, tuple, encodeMessage } from '@bitspark/bitwire'; -import { pair, addressed } from '../../../dist/core/ts/src/index.js'; -import { listenWebSocket, connectWebSocket } from '../../../dist/websocket/ts/src/index.js'; import { Endpoint, Namespace, Scope, Refusal, hydratedTuple, items, UNKNOWN_EXPORT, STALE_SCOPE, UNEXPORTABLE, FOREIGN_NAMESPACE, MALFORMED_FRAME, } from '../../../dist/hydrated/ts/src/index.js'; - -const text = (s) => atom(new TextEncoder().encode(s)); -const str = (a) => new TextDecoder().decode(a.bytes()); -const tick = () => new Promise((r) => setTimeout(r, 5)); -async function eventually(cond, what) { - for (let i = 0; i < 1000; i++) { if (cond()) return; await tick(); } - assert.fail(what); -} +import { text, str, eventually, PATHS, tree, serve, call } from './harness.mjs'; test('observation 1: vectors round-trip (bitwire#83 at 20a6b6b, pinned)', () => { const raw = readFileSync(new URL('../../go/testdata/hydrated-vectors.json', import.meta.url)); @@ -39,59 +30,6 @@ test('observation 1: vectors round-trip (bitwire#83 at 20a6b6b, pinned)', () => } }); -const PATHS = { client: [text('client42')], provider: [text('a'), text('b'), text('service2')], third: [text('c')] }; -const key = (p) => p.map((a) => Buffer.from(a.bytes()).toString('hex')).join('/') + `#${p.length}`; - -async function links(t, carrier, n) { - if (carrier === 'pair') return Array.from({ length: n }, () => { const ends = pair(); t.after(() => ends[0].close()); return ends; }); - const accepted = []; - let wake; - const listener = await listenWebSocket({ host: '127.0.0.1' }, (e) => { accepted.push(e); wake?.(); }); - t.after(() => listener.close()); - const out = []; - for (let i = 0; i < n; i++) { - const c = await connectWebSocket(listener.url); - t.after(() => c.close()); - while (accepted.length <= i) await new Promise((r) => { wake = r; }); - out.push([c, accepted[i]]); - } - return out; -} - -// Participants around an opaque middle that knows routes and ground values only. -async function tree(t, carrier, ...names) { - const ns = new Namespace('test'); - const routes = new Map(), trace = [], diags = [], parts = {}; - const ls = await links(t, carrier, names.length); - names.forEach((name, i) => { - const mine = addressed(ls[i][0]), mid = addressed(ls[i][1]); - routes.set(key(PATHS[name]), mid); - mid.receive((p, v) => { trace.push(v); void routes.get(key(p))?.send(p, v); }); - const part = { name, ep: mine, scope: new Scope(ns, PATHS[name], mine) }; - mine.receive((p, v) => { - try { part.scope.deliver(p, v, `${name}-link`); } catch (e) { diags.push({ who: name, reason: e.reason ?? e.message }); } - }); - parts[name] = part; - }); - return { ns, trace, diags, parts }; -} - -function serve(t, scope, handler) { - const e = new Endpoint(64); - e.receive(handler); - t.after(() => e.close()); - return [e, scope.expose(e)]; -} - -async function call(target, op, ...args) { - const reply = new Endpoint(1); - const got = new Promise((resolve) => reply.receive((value, context) => resolve({ value, context }))); - await target.send(hydratedTuple([text(op), ...args, reply])); - const r = await got; - await reply.close(); - return r; -} - function echo(value) { const xs = items(value); const op = str(xs[0]), arg = xs[1], reply = xs.at(-1); From b4f842aef490bc25a8aed475a7427fa071356045 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 16:23:10 +0200 Subject: [PATCH 04/21] Specify consumer composition and lifetime acceptance --- hydrated/ts/test/COMPOSITION.md | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 hydrated/ts/test/COMPOSITION.md diff --git a/hydrated/ts/test/COMPOSITION.md b/hydrated/ts/test/COMPOSITION.md new file mode 100644 index 0000000..28736e4 --- /dev/null +++ b/hydrated/ts/test/COMPOSITION.md @@ -0,0 +1,71 @@ +# Consumer adapter composition observations + +**Candidate acceptance, 7 October 2026.** This package tests the composition +proposal in bitwire PR85 against the proposed decision 0019 implementation. +The starting runtime is `3adb89cae4daef71501b2b2ee88228b39bb42235`; the proposed +contract is bitwire PR83 at `20a6b6b6c0b26fe16c8e57a4f88241207119880c`. +Neither being testable nor passing these observations adopts that contract. + +## Boundary and expected observations + +The fixtures define two independent domain APIs: Text with a string-producing +operation, and Counter with stateful numeric operations and a declared failure. +One Cell-of-Wire convention supplies get/set. Its bidirectional lifting composes +either inner adapter, then composes itself for Cell>. Test direct and +adapted executions from corresponding initial state using expected results and +provider traces; construction must invoke no provider operation. + +Run each transport observation using the existing opaque-middle harness over +both local pairs and real loopback WebSockets. Domain fixtures import only the +hydrated endpoint/value API and ground atoms, never Scope, references or routes. + +1. Reuse the same outer Cell adapter for Text and Counter. Reads observe values + replaced after construction, and writes carry caller-provided services to the + provider and back. Preserve results, effects, aliases and invocation counts. +2. Compose Cell> without an extra protocol. Read and replace both the + inner service and inner cell, including capabilities sent in either direction. +3. Compare identity lifting, successive coherent bidirectional conversions and + their composition. Compare provider failures and writes, not just read values. +4. Malformed domain requests with a valid reply capability produce a domain + protocol error and invoke no provider. Declared Counter failures remain + distinguishable from admission refusal and unknown outcomes after loss. +5. Over 200 complete Cell reads and Text calls, close each reply endpoint + and keep live export counts bounded by the open service endpoints. Closing an + adapter owner ends its exports, invalidates service use, settles pending local + calls and does not close the carrier or borrowed endpoints. +6. Lose the caller's attachment after a provider operation starts. The caller + settles with outcome unknown; do not infer rollback or cancel provider work. + +## Domain lifetime and failure convention + +An explicit adapter owner captures the service lifetime. Materialization caches +one owned endpoint per domain object per adapter in that lifetime; proxies map +back to their original sending wire. This is local adapter ownership, with no +transport reference IDs or registry management. A production domain can choose +different end events, but must still name them. Shared runtime close performs +export withdrawal. Tests must not raise the runtime's default limits. + +The test-only request convention carries an operation, argument tuple and owned +reply face. Each invocation closes its reply endpoint on every completion path. +An explicit owner-close signal and a finite fixture deadline settle incomplete +local calls with outcome unknown. They do not cancel remote operations. A valid +reply conveys success, a declared domain failure, or a domain-protocol error. +These conventions belong to these fixtures, not the generic protocol stack. + +## Scope and evidence + +Only new files in `hydrated/ts/test` belong to this package. The shared harness, +runtime source, Go realization, wire format and existing observation suite stay +with their current owners. A failing runtime law is reported as a counterexample +to the owning contract/implementation, not hidden by fixture fallback behavior. + +This is bounded evidence for these APIs, schedules and carriers. It does not +establish a law for every generic type, arbitrary partitions, distributed +collection, cross-namespace gateways or cross-language domain protocols. The +earlier `spikes/hydration` call-64 retention counterexample remains historical +evidence. The owning production change still requires its full Go/TypeScript, +package and interoperability gates and independent review. + +Run `npm ci --ignore-scripts`, `npm run check`, and `npm test` at the repository +root. Record exact reviewed revisions and CI results in the pull request; this +document does not assert that an untested later revision has passed. From 9ce2db40835bb5d82960f44a698ba9f0bbd35eaa Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 16:31:21 +0200 Subject: [PATCH 05/21] Qualify compositional Cell adapters and endpoint lifetimes --- hydrated/ts/test/composition.test.mjs | 290 ++++++++++++++++++++++++++ hydrated/ts/test/fixtures/cell.mjs | 37 ++++ hydrated/ts/test/fixtures/counter.mjs | 22 ++ hydrated/ts/test/fixtures/domain.mjs | 124 +++++++++++ hydrated/ts/test/fixtures/text.mjs | 12 ++ 5 files changed, 485 insertions(+) create mode 100644 hydrated/ts/test/composition.test.mjs create mode 100644 hydrated/ts/test/fixtures/cell.mjs create mode 100644 hydrated/ts/test/fixtures/counter.mjs create mode 100644 hydrated/ts/test/fixtures/domain.mjs create mode 100644 hydrated/ts/test/fixtures/text.mjs diff --git a/hydrated/ts/test/composition.test.mjs b/hydrated/ts/test/composition.test.mjs new file mode 100644 index 0000000..09a4405 --- /dev/null +++ b/hydrated/ts/test/composition.test.mjs @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Endpoint, UNKNOWN_EXPORT, hydratedTuple } from '../../../dist/hydrated/ts/src/index.js'; +import { eventually, tree } from './harness.mjs'; +import { + AdapterOwner, DomainError, OutcomeUnknown, ProtocolError, invoke, text, tupleItems, +} from './fixtures/domain.mjs'; +import { textAdapter } from './fixtures/text.mjs'; +import { counterAdapter } from './fixtures/counter.mjs'; +import { adaptCell, cellAdapter, wireCellAdapter } from './fixtures/cell.mjs'; + +async function setup(t, carrier) { + const network = await tree(t, carrier, 'client', 'provider'); + const client = new AdapterOwner(), provider = new AdapterOwner(); + for (const [name, owner] of [['client', client], ['provider', provider]]) { + const part = network.parts[name]; + // Attachment ownership is explicit test composition, outside the domain adapters. + void part.ep.closed.then(() => owner.close()); + t.after(async () => { await owner.close(); part.scope.close(); }); + } + return { network, client, provider }; +} + +function remoteView(setup, source, clientAdapter, providerAdapter) { + const local = providerAdapter.toWire(source); + const endpoint = setup.provider.ownedEndpoint(local); + assert.ok(endpoint, 'test bootstrap needs the locally owned root endpoint'); + const reference = setup.network.parts.provider.scope.expose(endpoint); + const remote = setup.network.parts.client.scope.connect(reference); + return { view: clientAdapter.fromWire(remote), wire: remote }; +} + +function textValue(label, trace) { + return { render: async prefix => { trace.push(`${label}.render(${prefix})`); return `${prefix}:${label}`; } }; +} +function counterValue(label, initial, trace) { + let count = initial; + return { + read: async () => { trace.push(`${label}.read`); return count; }, + add: async delta => { + trace.push(`${label}.add(${delta})`); + if (count + delta < 0) throw new DomainError('below-zero'); + count += delta; + return count; + }, + }; +} +function cellValue(initial, trace, label = 'cell') { + let selected = initial; + return { + source: { + get: async () => { trace.push(`${label}.get`); return selected; }, + set: async value => { trace.push(`${label}.set`); selected = value; }, + }, + replace: value => { selected = value; }, + }; +} + +async function textScenario(project) { + const trace = []; + const cell = cellValue(textValue('initial', trace), trace); + const view = project(cell.source); + assert.deepEqual(trace, [], 'constructing adapters invoked the provider'); + cell.replace(textValue('late', trace)); + const results = [await (await view.get()).render('one')]; + await view.set(textValue('caller', trace)); + results.push(await (await cell.source.get()).render('two')); + results.push(await (await view.get()).render('three')); + assert.deepEqual(results, ['one:late', 'two:caller', 'three:caller']); + assert.deepEqual(trace, [ + 'cell.get', 'late.render(one)', 'cell.set', 'cell.get', 'caller.render(two)', + 'cell.get', 'caller.render(three)', + ]); + return { results, trace }; +} + +async function counterScenario(project) { + const trace = []; + const cell = cellValue(counterValue('initial', 0, trace), trace); + const view = project(cell.source); + assert.deepEqual(trace, []); + cell.replace(counterValue('late', 10, trace)); + const returned = await view.get(); + const results = [await returned.add(3), await returned.read()]; + await view.set(counterValue('caller', 50, trace)); + results.push(await (await cell.source.get()).add(2)); + const alias = await view.get(); + results.push(await alias.read()); + await assert.rejects(alias.add(-100), error => error instanceof DomainError && error.code === 'below-zero'); + results.push(await alias.read()); + assert.deepEqual(results, [13, 13, 52, 52, 52]); + assert.deepEqual(trace, [ + 'cell.get', 'late.add(3)', 'late.read', 'cell.set', 'cell.get', 'caller.add(2)', + 'cell.get', 'caller.read', 'caller.add(-100)', 'caller.read', + ]); + return { results, trace }; +} + +for (const carrier of ['pair', 'websocket']) { + for (const [name, makeInner, scenario] of [ + ['Text', textAdapter, textScenario], ['Counter', counterAdapter, counterScenario], + ]) { + test(`${carrier}: Cell<${name}> agrees with direct calls, lazy reads and reverse writes`, async t => { + const s = await setup(t, carrier); + const provider = cellAdapter(wireCellAdapter(s.provider), makeInner(s.provider)); + const client = cellAdapter(wireCellAdapter(s.client), makeInner(s.client)); + const direct = await scenario(source => source); + const adapted = await scenario(source => remoteView(s, source, client, provider).view); + assert.deepEqual(adapted, direct); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + } + + test(`${carrier}: Cell> reuses the outer adapter for both nested write directions`, async t => { + const s = await setup(t, carrier), trace = []; + const outerProvider = wireCellAdapter(s.provider), outerClient = wireCellAdapter(s.client); + const leafProvider = cellAdapter(outerProvider, textAdapter(s.provider)); + const leafClient = cellAdapter(outerClient, textAdapter(s.client)); + const nestedProvider = cellAdapter(outerProvider, leafProvider); + const nestedClient = cellAdapter(outerClient, leafClient); + const inner = cellValue(textValue('original', trace), trace, 'inner'); + const root = cellValue(inner.source, trace, 'root'); + const { view } = remoteView(s, root.source, nestedClient, nestedProvider); + assert.deepEqual(trace, []); + inner.replace(textValue('late', trace)); + const first = await view.get(); + assert.equal(await (await first.get()).render('a'), 'a:late'); + await first.set(textValue('caller-leaf', trace)); + assert.equal(await (await inner.source.get()).render('b'), 'b:caller-leaf'); + + const callerInner = cellValue(textValue('caller-inner', trace), trace, 'caller-cell'); + await view.set(callerInner.source); + // Provider calls an inner cell that originated at the caller. + const atProvider = await root.source.get(); + assert.equal(await (await atProvider.get()).render('c'), 'c:caller-inner'); + await atProvider.set(textValue('provider-leaf', trace)); + // Caller calls the provider capability that travelled through its own cell. + assert.equal(await (await (await view.get()).get()).render('d'), 'd:provider-leaf'); + assert.deepEqual(trace, [ + 'root.get', 'inner.get', 'late.render(a)', 'inner.set', 'inner.get', + 'caller-leaf.render(b)', 'root.set', 'root.get', 'caller-cell.get', + 'caller-inner.render(c)', 'caller-cell.set', 'root.get', 'caller-cell.get', + 'provider-leaf.render(d)', + ]); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: malformed domain messages and declared failures stay distinct`, async t => { + const s = await setup(t, carrier), trace = []; + const provider = counterAdapter(s.provider), client = counterAdapter(s.client); + const { view, wire } = remoteView(s, counterValue('counter', 4, trace), client, provider); + await assert.rejects(invoke(s.client, wire, 'add', [text('not an integer')]), ProtocolError); + await assert.rejects(invoke(s.client, wire, 'missing', []), ProtocolError); + assert.deepEqual(trace, [], 'malformed input reached the provider'); + await assert.rejects(view.add(-5), error => error instanceof DomainError && error.code === 'below-zero'); + assert.equal(await view.read(), 4); + assert.deepEqual(trace, ['counter.add(-5)', 'counter.read']); + + const bad = s.provider.endpoint(request => { + const reply = tupleItems(request, 3)[2]; + return reply.send(hydratedTuple([text('unknown status'), text('data')])); + }); + const badProxy = s.network.parts.client.scope.connect(s.network.parts.provider.scope.expose(bad)); + await assert.rejects(invoke(s.client, badProxy, 'anything', []), ProtocolError); + assert.equal(s.client.open, 0, 'failed calls kept reply endpoints'); + assert.equal(s.network.parts.client.scope.live, 0, 'failed calls kept reply exports'); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: Cell.set preserves asynchronous completion and provider failure`, async t => { + const s = await setup(t, carrier), started = Promise.withResolvers(), finish = Promise.withResolvers(); + const trace = []; + const source = { + get: async () => { throw new Error('get must not be invoked'); }, + set: async () => { + trace.push('started'); + started.resolve(); + await finish.promise; + trace.push('refused'); + throw new DomainError('write-denied'); + }, + }; + const provider = cellAdapter(wireCellAdapter(s.provider), textAdapter(s.provider)); + const client = cellAdapter(wireCellAdapter(s.client), textAdapter(s.client)); + const { view } = remoteView(s, source, client, provider); + assert.deepEqual(trace, []); + let settled = false; + const operation = view.set(textValue('borrowed', [])); + const checked = assert.rejects(operation, error => error instanceof DomainError && error.code === 'write-denied'); + void operation.then(() => { settled = true; }, () => { settled = true; }); + await started.promise; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false, 'local admission was mistaken for domain completion'); + finish.resolve(); + await checked; + assert.deepEqual(trace, ['started', 'refused']); + assert.equal(s.client.open, 1, 'reply must close while the offered service remains owned'); + assert.equal(s.network.parts.client.scope.live, 1); + assert.deepEqual(s.network.diags, []); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: 200 Cell reads release replies and keep service exports bounded`, async t => { + const s = await setup(t, carrier), trace = []; + const provider = cellAdapter(wireCellAdapter(s.provider), textAdapter(s.provider)); + const client = cellAdapter(wireCellAdapter(s.client), textAdapter(s.client)); + const source = cellValue(textValue('stable', trace), trace).source; + const { view, wire } = remoteView(s, source, client, provider); + const a = s.network.parts.client.scope, b = s.network.parts.provider.scope; + for (let i = 0; i < 200; i++) { + assert.equal(await (await view.get()).render(String(i)), `${i}:stable`); + assert.equal(a.live, 0, `read ${i}: reply export retained`); + assert.equal(b.live, 2, `read ${i}: service rematerialized`); + assert.equal(s.client.open, 0); + assert.equal(s.provider.open, 2); + } + assert.equal(trace.length, 400, 'extra provider invocations'); + const borrowed = new Endpoint(1), arrived = Promise.withResolvers(); + borrowed.receive(arrived.resolve); + t.after(() => borrowed.close()); + const borrowedProxy = a.connect(b.expose(borrowed)); + await s.provider.close(); + assert.equal(b.live, 1, 'owner closed an independently owned endpoint'); + await wire.send(text('late')); + await eventually(() => s.network.diags.length === 1, 'closed service was not refused'); + assert.deepEqual(s.network.diags, [{ who: 'provider', reason: UNKNOWN_EXPORT }]); + await borrowedProxy.send(text('still alive')); + assert.ok((await arrived.promise).equals(text('still alive'))); + assert.equal(trace.length, 400); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); + + test(`${carrier}: attachment loss settles a pending call without claiming rollback`, async t => { + const s = await setup(t, carrier); + const started = Promise.withResolvers(), finish = Promise.withResolvers(); + let completed = false; + const source = { render: async () => { started.resolve(); await finish.promise; completed = true; return 'effect completed'; } }; + const { view } = remoteView(s, source, textAdapter(s.client), textAdapter(s.provider)); + const pending = assert.rejects(view.render('lost'), OutcomeUnknown); + await started.promise; + await s.network.parts.client.ep.close(); + await pending; + assert.equal(s.client.open, 0); + assert.equal(s.network.parts.client.scope.live, 0); + // End the provider's service lifetime; already-started work can still finish. + await s.provider.close(); + finish.resolve(); + await eventually(() => completed, 'provider effect was incorrectly cancelled'); + assert.deepEqual([...s.client.faults, ...s.provider.faults], []); + }); +} + +test('Cell lifting respects identity and composition for reads, writes and provider failures', async () => { + const r = { to: number => String(number), from: value => Number(value) }; + const s = { to: value => ({ decimal: value }), from: value => value.decimal }; + const composed = { to: value => s.to(r.to(value)), from: value => r.from(s.from(value)) }; + const identity = { to: value => value, from: value => value }; + async function observe(project) { + const trace = []; + let value = 7; + const source = { + get: async () => { trace.push(['get', value]); return value; }, + set: async next => { + trace.push(['set', next]); + if (next < 0) throw new DomainError('negative'); + value = next; + }, + }; + const view = project(source); + assert.deepEqual(trace, []); + const before = await view.get(); + await view.set(19); + await assert.rejects(view.set(-1), error => error instanceof DomainError && error.code === 'negative'); + const after = await view.get(); + assert.equal(before, 7); + assert.equal(after, 19); + assert.deepEqual(trace, [['get', 7], ['set', 19], ['set', -1], ['get', 19]]); + return { before, after, trace }; + } + const direct = await observe(source => source); + assert.deepEqual(await observe(source => adaptCell(source, identity)), direct); + const lower = cell => ({ get: async () => Number((await cell.get()).decimal), set: value => cell.set({ decimal: String(value) }) }); + const successive = await observe(source => lower(adaptCell(adaptCell(source, r), s))); + const together = await observe(source => lower(adaptCell(source, composed))); + assert.deepEqual(successive, direct); + assert.deepEqual(together, direct); +}); diff --git a/hydrated/ts/test/fixtures/cell.mjs b/hydrated/ts/test/fixtures/cell.mjs new file mode 100644 index 0000000..878329f --- /dev/null +++ b/hydrated/ts/test/fixtures/cell.mjs @@ -0,0 +1,37 @@ +import { invoke, memoAdapter, provide, ProtocolError, sendingWire, string, text } from './domain.mjs'; + +// Mixed variance: get uses the forward conversion; set uses the backward one. +export function adaptCell(source, adapters) { + return { + get: async () => adapters.to(await source.get()), + set: async value => source.set(adapters.from(value)), + }; +} + +/** The only Cell operation protocol, independent of the inner domain. */ +export function wireCellAdapter(owner) { + return memoAdapter( + source => provide(owner, async (operation, args) => { + if (operation === 'get' && args.length === 0) return sendingWire(await source.get()); + if (operation === 'set' && args.length === 1) { + await source.set(sendingWire(args[0])); + return text('done'); + } + throw new ProtocolError('invalid Cell request'); + }), + target => ({ + get: () => invoke(owner, target, 'get', [], sendingWire), + set: value => invoke(owner, target, 'set', [value], result => { + if (string(result) !== 'done') throw new ProtocolError('invalid Cell completion'); + }), + }), + ); +} + +/** adapter3 = adapter3_ composed with lift3(adapterT), in both directions. */ +export function cellAdapter(outer, inner) { + return memoAdapter( + source => outer.toWire(adaptCell(source, { to: inner.toWire, from: inner.fromWire })), + target => adaptCell(outer.fromWire(target), { to: inner.fromWire, from: inner.toWire }), + ); +} diff --git a/hydrated/ts/test/fixtures/counter.mjs b/hydrated/ts/test/fixtures/counter.mjs new file mode 100644 index 0000000..81f66ee --- /dev/null +++ b/hydrated/ts/test/fixtures/counter.mjs @@ -0,0 +1,22 @@ +import { invoke, memoAdapter, provide, ProtocolError, string, text } from './domain.mjs'; + +function integer(value) { + const encoded = string(value), number = Number(encoded); + if (!Number.isSafeInteger(number) || String(number) !== encoded) throw new ProtocolError('expected integer'); + return number; +} + +// Counter = { read(): Promise; add(delta: number): Promise }. +export function counterAdapter(owner) { + return memoAdapter( + source => provide(owner, async (operation, args) => { + if (operation === 'read' && args.length === 0) return text(String(await source.read())); + if (operation === 'add' && args.length === 1) return text(String(await source.add(integer(args[0])))); + throw new ProtocolError('invalid Counter request'); + }), + target => ({ + read: () => invoke(owner, target, 'read', [], integer), + add: delta => invoke(owner, target, 'add', [text(String(delta))], integer), + }), + ); +} diff --git a/hydrated/ts/test/fixtures/domain.mjs b/hydrated/ts/test/fixtures/domain.mjs new file mode 100644 index 0000000..0b8b320 --- /dev/null +++ b/hydrated/ts/test/fixtures/domain.mjs @@ -0,0 +1,124 @@ +// Consumer-only request and ownership convention. No Scope, route or reference API. +import { Atom, atom } from '@bitspark/bitwire'; +import { Endpoint, hydratedTuple, items } from '../../../../dist/hydrated/ts/src/index.js'; + +export const text = value => atom(new TextEncoder().encode(value)); +export function string(value) { + if (!(value instanceof Atom)) throw new ProtocolError('expected text atom'); + try { return new TextDecoder('utf-8', { fatal: true }).decode(value.bytes()); } + catch { throw new ProtocolError('invalid UTF-8'); } +} +export function tupleItems(value, length) { + const values = items(value); + if (!values || (length !== undefined && values.length !== length)) { + throw new ProtocolError('invalid domain tuple'); + } + return values; +} +export function sendingWire(value) { + if (!value || typeof value.send !== 'function') throw new ProtocolError('expected Wire'); + return value; +} +export class ProtocolError extends Error { name = 'ProtocolError'; } +export class DomainError extends Error { + name = 'DomainError'; + constructor(code) { super(code); this.code = code; } +} +export class OutcomeUnknown extends Error { + name = 'OutcomeUnknown'; + constructor() { super('domain operation outcome unknown'); } +} + +/** The domain composition owns service endpoints; each call owns its reply. */ +export class AdapterOwner { + #endpoints = new Map(); + #controller = new AbortController(); + faults = []; + get signal() { return this.#controller.signal; } + get open() { return this.#endpoints.size; } + endpoint(handler) { + if (this.signal.aborted) throw new OutcomeUnknown(); + const endpoint = new Endpoint(32); + this.#endpoints.set(endpoint.wire, endpoint); + endpoint.receive((value, context) => { + void Promise.resolve().then(() => handler(value, context)).catch(error => this.faults.push(error)); + }); + return endpoint; + } + // Used only by test assembly to bootstrap an owned root, never by an adapter. + ownedEndpoint(wire) { return this.#endpoints.get(wire); } + async release(endpoint) { + this.#endpoints.delete(endpoint.wire); + await endpoint.close(); + } + async close() { + this.#controller.abort(); + await Promise.all([...this.#endpoints.values()].map(endpoint => this.release(endpoint))); + } +} + +/** Local materialization cache, not a transport reference/export table. */ +export function memoAdapter(toWire, fromWire) { + const provided = new WeakMap(), projected = new WeakMap(); + return { + toWire(value) { + let target = provided.get(value); + if (!target) { target = toWire(value); provided.set(value, target); } + return target; + }, + fromWire(target) { + sendingWire(target); + let value = projected.get(target); + if (!value) { + value = fromWire(target); + projected.set(target, value); + provided.set(value, target); + } + return value; + }, + }; +} + +/** The domain chooses one result per invocation and distinguishes its failures. */ +export function provide(owner, dispatch) { + return owner.endpoint(async message => { + const fields = tupleItems(message); + const reply = sendingWire(fields[2]); + let response; + try { + tupleItems(message, 3); + response = hydratedTuple([text('ok'), await dispatch(string(fields[0]), tupleItems(fields[1]))]); + } catch (error) { + if (error instanceof DomainError) response = hydratedTuple([text('domain'), text(error.code)]); + else if (error instanceof ProtocolError) response = hydratedTuple([text('protocol'), text(error.message)]); + else throw error; + } + // Closing this service lifetime stops replies, but does not cancel started work. + if (!owner.signal.aborted) await reply.send(response); + }).wire; +} + +export async function invoke(owner, target, operation, args, decode = value => value) { + const result = Promise.withResolvers(); + const reply = owner.endpoint(message => { + try { + const [status, value] = tupleItems(message, 2); + switch (string(status)) { + case 'ok': result.resolve(decode(value)); break; + case 'domain': result.reject(new DomainError(string(value))); break; + case 'protocol': result.reject(new ProtocolError(string(value))); break; + default: throw new ProtocolError('unknown response status'); + } + } catch (error) { result.reject(error); } + }); + const end = () => result.reject(new OutcomeUnknown()); + const timer = setTimeout(end, 3000); + owner.signal.addEventListener('abort', end, { once: true }); + void target.send(hydratedTuple([text(operation), hydratedTuple(args), reply.wire])).catch(result.reject); + try { return await result.promise; } + finally { + clearTimeout(timer); + owner.signal.removeEventListener('abort', end); + await owner.release(reply); + } +} diff --git a/hydrated/ts/test/fixtures/text.mjs b/hydrated/ts/test/fixtures/text.mjs new file mode 100644 index 0000000..d472dfa --- /dev/null +++ b/hydrated/ts/test/fixtures/text.mjs @@ -0,0 +1,12 @@ +import { invoke, memoAdapter, provide, ProtocolError, string, text } from './domain.mjs'; + +// Text = { render(prefix: string): Promise }. +export function textAdapter(owner) { + return memoAdapter( + source => provide(owner, async (operation, args) => { + if (operation !== 'render' || args.length !== 1) throw new ProtocolError('invalid Text request'); + return text(await source.render(string(args[0]))); + }), + target => ({ render: prefix => invoke(owner, target, 'render', [text(prefix)], string) }), + ); +} From 04e4c9c86326bbf481a8d870a75eb334db5d38a6 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 16:43:05 +0200 Subject: [PATCH 06/21] hydrated: answer Wire & Runtime's 0019 review in Go and TypeScript (not for merge) Sibling forgery (blocker): export ids were counters under a shared scope token, so one reference let its holder construct its siblings'. Export ids are now 16 octets from a secure source, redrawn on collision with a live or staged id; frames and references require exactly 16 octets. Observation 8 forges ids from a public reference while a private export is held by another participant: every forgery is refused, the private endpoint receives only its legitimate delivery. Go concurrency: encoding, the size check and registration are one step per scope (encodeMu), so concurrent sends carrying one new face agree on one registered id, and distinct new faces never pass the export bound (observation 13; 5x under -race). Further probes: - Incoming frames count whole against the byte bound, reference material included (observation 15). - An Endpoint is captured as its sending face in tuples and on local sends; the TypeScript face keeps its owner in a module-private WeakMap instead of a public getter. - A detach removes only its own receiver; a receiver failure terminates the endpoint and ends its export (observation 17). Vectors repinned to the regenerated set. Go/TypeScript interop passes in both roles; TS 37/37 (16 hydrated), go test -race ./... passes. Co-Authored-By: Claude Opus 5.5 --- hydrated/README.md | 4 +- hydrated/go/hydrated.go | 124 ++++++++++--- hydrated/go/hydrated_test.go | 193 +++++++++++++++++++-- hydrated/go/testdata/hydrated-vectors.json | 48 ++--- hydrated/ts/src/index.ts | 58 +++++-- hydrated/ts/test/hydrated.test.mjs | 66 ++++++- 6 files changed, 418 insertions(+), 75 deletions(-) diff --git a/hydrated/README.md b/hydrated/README.md index b83ed8c..fca9268 100644 --- a/hydrated/README.md +++ b/hydrated/README.md @@ -5,7 +5,7 @@ edition of the hydrated wire protocol. This is not a released API, and this branch is not proposed for merge until the record is accepted. The [package](go/hydrated.go) realizes the record's rules in Go. [Its tests](go/hydrated_test.go) -run the record's observations 1-13 around an opaque middle router, over local +run the record's observations 1-15 and 17 around an opaque middle router, over local pairs and WebSocket where the carrier matters. The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4 and 6-9 @@ -15,6 +15,6 @@ The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4 and 6-9 serving Go. Both peers build from this repository, not from fresh published dependencies, so they show the grammar interoperates, not package delivery. -The record's vectors are copied from bitwire#83 at `20a6b6b` into +The record's vectors are copied from bitwire#83 (revision 2) into [testdata](go/testdata/hydrated-vectors.json), pinned by sha256; both languages replay them. diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index b310840..db12805 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -11,6 +11,7 @@ package hydrated import ( "crypto/rand" "errors" + "fmt" "strconv" "sync" @@ -59,9 +60,14 @@ var ( // the ground tuple when there is none (D1). type Tuple struct{ items []Value } -// NewTuple captures items. Each must be an ontos value, a Tuple or a Wire. +// NewTuple captures items. Each must be an ontos value, a Tuple or a Wire. An +// Endpoint is captured as its sending face: a value never carries receive or +// close authority, on any path (D7). func NewTuple(items ...Value) (Value, error) { - captured := append([]Value(nil), items...) + captured := make([]Value, len(items)) + for i, item := range items { + captured[i] = sendOnly(item) + } ground := make([]ontos.Value, 0, len(captured)) live := false for _, item := range captured { @@ -82,6 +88,14 @@ func NewTuple(items ...Value) (Value, error) { return ontos.NewTuple(ground...), nil } +// sendOnly projects an Endpoint to its sending face. +func sendOnly(v Value) Value { + if e, ok := v.(*Endpoint); ok { + return e.face + } + return v +} + // Items returns the tuple's items. func (t Tuple) Items() []Value { return append([]Value(nil), t.items...) } @@ -146,6 +160,8 @@ type Endpoint struct { queue []delivery limit int handler func(Value, Context) + gen uint64 // receiver generation: a detach removes only its own receiver + failure error // set when a receiver failed and so terminated the endpoint closed bool done chan struct{} wake chan struct{} @@ -161,7 +177,7 @@ type delivery struct { // face is an Endpoint's sending face. It grants sending only. type face struct{ e *Endpoint } -func (f *face) Send(v Value) error { return f.e.admit(v, Local{}) } +func (f *face) Send(v Value) error { return f.e.admit(sendOnly(v), Local{}) } // NewEndpoint creates an endpoint whose queue admits at most limit values. func NewEndpoint(limit int) *Endpoint { @@ -178,7 +194,7 @@ func NewEndpoint(limit int) *Endpoint { func (e *Endpoint) Wire() Wire { return e.face } // Send admits a value locally, as its sending face does. -func (e *Endpoint) Send(v Value) error { return e.admit(v, Local{}) } +func (e *Endpoint) Send(v Value) error { return e.face.Send(v) } // Receive attaches the one receiver. Values are dispatched in admission order, // never inline with the admitting send. @@ -188,15 +204,26 @@ func (e *Endpoint) Receive(handler func(Value, Context)) (detach func(), err err if e.handler != nil { return nil, errors.New("hydrated: receiver already attached") } + e.gen++ + gen := e.gen e.handler = handler e.signal() return func() { e.mu.Lock() - e.handler = nil + if e.gen == gen { + e.handler = nil + } e.mu.Unlock() }, nil } +// Failure reports the receiver failure that terminated the endpoint, if any. +func (e *Endpoint) Failure() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.failure +} + // Closed is closed when the endpoint closes. func (e *Endpoint) Closed() <-chan struct{} { return e.done } @@ -253,11 +280,28 @@ func (e *Endpoint) dispatch() { d, h := e.queue[0], e.handler e.queue = e.queue[1:] e.mu.Unlock() - h(d.value, d.context) + if err := deliverTo(h, d); err != nil { + // A receiver failure terminates the endpoint, which ends its export. + e.mu.Lock() + e.failure = err + e.mu.Unlock() + _ = e.Close() + return + } } } } +func deliverTo(h func(Value, Context), d delivery) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("hydrated: receiver failed: %v", r) + } + }() + h(d.value, d.context) + return nil +} + // register records an export; it refuses a closed endpoint. func (e *Endpoint) register(s *Scope, id ontos.Atom) bool { e.mu.Lock() @@ -307,13 +351,13 @@ var DefaultLimits = Limits{Exports: 64, Nodes: 4096, Depth: 64, Bytes: 1 << 20} // Scope is one participant's hydration state for one incarnation (Terms). type Scope struct { + encodeMu sync.Mutex // encoding and registration are one step per scope (D5) mu sync.Mutex path wire.Path token ontos.Atom ns *Namespace sender wire.AddressedWire limits Limits - next uint64 byID map[string]*Endpoint byEndpoint map[*Endpoint]ontos.Atom closed bool @@ -348,6 +392,8 @@ func (s *Scope) Live() int { // Expose exports an endpoint's face for composition bootstrap, such as a // service's well-known reference. Domain adapters never call it. func (s *Scope) Expose(e *Endpoint) (Reference, error) { + s.encodeMu.Lock() + defer s.encodeMu.Unlock() staged := map[*Endpoint]ontos.Atom{} id, err := s.exportID(e, staged) if err != nil { @@ -384,8 +430,9 @@ func (s *Scope) withdraw(e *Endpoint, id ontos.Atom) { s.mu.Unlock() } -// exportID returns e's export id in this scope, staging a new one if needed. -// Ids are never reused, even when a staged export is never committed. +// exportID returns e's export id in this scope, staging a new one if needed. A +// new id is 16 octets from a cryptographically secure source, redrawn if it +// equals a live or staged id, so no reference reveals another's (D3). func (s *Scope) exportID(e *Endpoint, staged map[*Endpoint]ontos.Atom) (ontos.Atom, error) { if e.isClosed() { return ontos.Atom{}, Unexportable @@ -404,10 +451,26 @@ func (s *Scope) exportID(e *Endpoint, staged map[*Endpoint]ontos.Atom) (ontos.At if len(s.byEndpoint)+len(staged) >= s.limits.Exports { return ontos.Atom{}, Limit } - s.next++ - id := ontos.NewAtom([]byte(strconv.FormatUint(s.next, 10))) - staged[e] = id - return id, nil + for { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return ontos.Atom{}, err + } + id := ontos.NewAtom(b) + if s.byID[string(b)] == nil && !stagedID(staged, id) { + staged[e] = id + return id, nil + } + } +} + +func stagedID(staged map[*Endpoint]ontos.Atom, id ontos.Atom) bool { + for _, other := range staged { + if other.Equal(id) { + return true + } + } + return false } // commit registers staged exports before the frame is admitted (D5). An @@ -441,8 +504,8 @@ func (s *Scope) spend(b *budget, depth, n int) error { return nil } -// send encodes v atomically, registers its new exports, then admits one frame -// to the owner path (D5). +// send encodes v atomically and registers its new exports in one step per +// scope, then admits one frame to the owner path (D5). func (s *Scope) send(dest Reference, v Value) error { s.mu.Lock() closed := s.closed @@ -450,19 +513,26 @@ func (s *Scope) send(dest Reference, v Value) error { if closed { return ScopeEnded } + frame, err := s.stage(dest, v) + if err != nil { + return err + } + return s.sender.Send(dest.Path, frame) +} + +func (s *Scope) stage(dest Reference, v Value) (ontos.Value, error) { + s.encodeMu.Lock() + defer s.encodeMu.Unlock() staged := map[*Endpoint]ontos.Atom{} body, err := s.encode(v, 0, staged, &budget{}) if err != nil { - return err + return nil, err } frame := ontos.NewTuple(header, dest.Scope, dest.ID, body) if _, err := wire.EncodeMessage(frame, s.limits.Bytes); err != nil { - return Limit - } - if err := s.commit(staged); err != nil { - return err + return nil, Limit } - return s.sender.Send(dest.Path, frame) + return frame, s.commit(staged) } func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (ontos.Value, error) { @@ -516,13 +586,16 @@ func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx Context) error if closed { return ScopeEnded } + if _, err := wire.EncodeMessage(message, s.limits.Bytes); err != nil { + return Limit // the whole frame counts, reference material included (D8) + } f, ok := message.(ontos.Tuple) if !ok || f.Len() != 4 || !header.Equal(f.At(0)) { return MalformedFrame } token, ok1 := f.At(1).(ontos.Atom) id, ok2 := f.At(2).(ontos.Atom) - if !ok1 || !ok2 || token.Len() != 16 || id.Len() < 1 || id.Len() > 16 { + if !ok1 || !ok2 || token.Len() != 16 || id.Len() != 16 { return MalformedFrame } if !token.Equal(s.token) { @@ -577,6 +650,11 @@ func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *bu if err != nil { return nil, err } + for _, a := range append(append(wire.Path{}, r.Path...), r.Scope, r.ID) { + if err := s.spend(b, depth+1, a.Len()); err != nil { + return nil, err + } + } return s.importRef(r, interned), nil } return nil, MalformedFrame @@ -601,7 +679,7 @@ func readReference(v ontos.Value, maxDepth int) (Reference, error) { } scope, ok1 := t.At(1).(ontos.Atom) id, ok2 := t.At(2).(ontos.Atom) - if !ok1 || !ok2 || scope.Len() != 16 || id.Len() < 1 || id.Len() > 16 { + if !ok1 || !ok2 || scope.Len() != 16 || id.Len() != 16 { return Reference{}, MalformedFrame } return Reference{path, scope, id}, nil diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index 7b39943..fad97c7 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -58,7 +58,7 @@ func eventually(t *testing.T, what string, cond func() bool) { } } -// --- vectors (bitwire#83 at 20a6b6b, pinned by hash) --- +// --- vectors (bitwire#83 revision 2, pinned by hash) --- type jsonValue map[string]json.RawMessage @@ -97,7 +97,7 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != "621325467a19fa5d5ad34a1805e38feadb5609ad13fe5e71e991cf87b4d77278" { + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != "ce6b9f0f45db9c64627bbc1ba007344a8b4dde44297cc771431a7bc122546e6f" { t.Fatal("vectors changed; repin to the reviewed bitwire record") } var v struct { @@ -138,10 +138,11 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { t.Fatalf("%s: %v", c.Name, err) } } - // Frame rejection with a live target at the vectors' scope S and id "1". + // Frame rejection with a live target at the vectors' scope S and id I1. s.token = ontos.NewAtom([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) + i1, _ := hex.DecodeString("101112131415161718191a1b1c1d1e1f") target := NewEndpoint(8) - s.byID["1"], s.byEndpoint[target] = target, text("1") + s.byID[string(i1)], s.byEndpoint[target] = target, ontos.NewAtom(i1) for _, c := range v.RejectFrame { if err := s.Deliver(s.path, ground(c.Value), nil); !errors.Is(err, MalformedFrame) { t.Fatalf("%s: %v", c.Name, err) @@ -526,7 +527,7 @@ func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { }) } -func TestObservation8ReturningHome(t *testing.T) { +func TestObservation9ReturningHome(t *testing.T) { tr := newTree(t, "pair", "client", "provider") a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() var back Wire @@ -569,7 +570,7 @@ func TestObservation8ReturningHome(t *testing.T) { } } -func TestObservation9ReceivedContextIsNotData(t *testing.T) { +func TestObservation10ReceivedContextIsNotData(t *testing.T) { tr := newTree(t, "pair", "client", "provider") got := make(chan received, 1) _, svc := serve(t, tr.parts["provider"].scope(), func(v Value, c Context) { got <- received{v, c} }) @@ -583,7 +584,7 @@ func TestObservation9ReceivedContextIsNotData(t *testing.T) { } } -func TestObservation10AtomicDecoding(t *testing.T) { +func TestObservation11AtomicDecoding(t *testing.T) { tr := newTree(t, "pair", "client", "provider") b := tr.parts["provider"].scope() var mu sync.Mutex @@ -607,7 +608,7 @@ type refusingSender struct{} func (refusingSender) Send(wire.Path, ontos.Value) error { return errors.New("carrier refused") } -func TestObservation11RegistrationBeforeAdmission(t *testing.T) { +func TestObservation12RegistrationBeforeAdmission(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, DefaultLimits) target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) w := NewEndpoint(1) @@ -626,7 +627,7 @@ func TestObservation11RegistrationBeforeAdmission(t *testing.T) { } } -func TestObservation12Ordering(t *testing.T) { +func TestObservation14Ordering(t *testing.T) { carriers(t, func(t *testing.T, carrier string) { tr := newTree(t, carrier, "client", "provider") var mu sync.Mutex @@ -651,7 +652,7 @@ func TestObservation12Ordering(t *testing.T) { }) } -func TestObservation13Bounds(t *testing.T) { +func TestObservation15Bounds(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, Limits{Exports: 2, Nodes: 8, Depth: 3, Bytes: 64}) target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) deep := Value(text("x")) @@ -687,3 +688,175 @@ func TestObservation13Bounds(t *testing.T) { return len(d) == 1 && errors.Is(d[0].err, TargetRefused) }) } + +// Observation 8: one reference grants its own Wire and no sibling, including an +// endpoint the owner exported for another holder. +func TestObservation8SiblingForgery(t *testing.T) { + carriers(t, func(t *testing.T, carrier string) { + tr := newTree(t, carrier, "client", "provider", "third") + provider := tr.parts["provider"].scope() + var mu sync.Mutex + private := 0 + _, public := serve(t, provider, func(Value, Context) {}) + _, intended := serve(t, provider, func(Value, Context) { mu.Lock(); private++; mu.Unlock() }) + // The private endpoint really is held by another participant. + if err := tr.parts["third"].scope().Connect(intended).Send(text("legitimate")); err != nil { + t.Fatal(err) + } + eventually(t, "the intended holder was not served", func() bool { mu.Lock(); defer mu.Unlock(); return private == 1 }) + if public.ID.Len() != 16 || public.ID.Equal(intended.ID) { + t.Fatalf("export ids %x and %x", public.ID.Bytes(), intended.ID.Bytes()) + } + // The client holds only the public reference, and so knows the scope token. + client := tr.parts["client"].scope() + bump := append([]byte(nil), public.ID.Bytes()...) + bump[15]++ + forged := []ontos.Atom{ontos.NewAtom(bump), ontos.NewAtom(make([]byte, 16)), text("2"), text("0000000000000002")} + for _, id := range forged { + _ = client.Connect(Reference{public.Path, public.Scope, id}).Send(text("forged")) + } + eventually(t, "forged references were not refused", func() bool { return len(tr.diagnostics()) == len(forged) }) + for _, d := range tr.diagnostics() { + if d.who != "provider" || !(errors.Is(d.err, UnknownExport) || errors.Is(d.err, MalformedFrame)) { + t.Fatalf("forged reference: %+v", d) + } + } + mu.Lock() + defer mu.Unlock() + if private != 1 { + t.Fatalf("the private endpoint received %d deliveries, want only the legitimate one", private) + } + }) +} + +type recordingSender struct { + mu sync.Mutex + frames []ontos.Value +} + +func (r *recordingSender) Send(_ wire.Path, v ontos.Value) error { + r.mu.Lock() + r.frames = append(r.frames, v) + r.mu.Unlock() + return nil +} + +// Observation 13: concurrent sends agree on one export per face, and never pass +// the export bound. +func TestObservation13ConcurrentSends(t *testing.T) { + run := func(n int, f func(i int)) { + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { defer wg.Done(); <-start; f(i) }() + } + close(start) + wg.Wait() + } + dest := Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + rec := &recordingSender{} + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, rec, DefaultLimits) + target := s.Connect(dest) + face := NewEndpoint(1) + run(32, func(int) { + if err := target.Send(tuple(t, face)); err != nil { + t.Error(err) + } + }) + if s.Live() != 1 || len(rec.frames) != 32 { + t.Fatalf("one face: %d exports, %d frames", s.Live(), len(rec.frames)) + } + registered := s.byEndpoint[face] + for _, f := range rec.frames { + leaf := f.(ontos.Tuple).At(3).(ontos.Tuple).At(1).(ontos.Tuple).At(0) // body (01, ((02, ref))) + ref, err := readReference(leaf.(ontos.Tuple).At(1), DefaultLimits.Depth) + if err != nil || !ref.ID.Equal(registered) { + t.Fatal("a frame names an id that was not registered") + } + } + + bounded, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, &recordingSender{}, Limits{Exports: 4, Nodes: 64, Depth: 8, Bytes: 1 << 16}) + to := bounded.Connect(dest) + var mu sync.Mutex + refused := 0 + run(8, func(int) { + if err := to.Send(tuple(t, NewEndpoint(1))); errors.Is(err, Limit) { + mu.Lock() + refused++ + mu.Unlock() + } else if err != nil { + t.Error(err) + } + }) + if bounded.Live() != 4 || refused != 4 { + t.Fatalf("bound 4: %d live, %d refused", bounded.Live(), refused) + } +} + +// Observation 15, incoming: a frame counts whole against the byte bound, +// reference material included, and nothing is delivered. +func TestObservation15IncomingFrameBytes(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, Limits{Exports: 4, Nodes: 64, Depth: 8, Bytes: 128}) + target := NewEndpoint(4) + ref, _ := s.Expose(target) + huge := Reference{wire.Path{ontos.NewAtom(make([]byte, 4096))}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + body := ontos.NewTuple(tupleTag, ontos.NewTuple(huge.value())) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, body), nil); !errors.Is(err, Limit) { + t.Fatalf("an oversized reference: %v", err) + } + if target.pending() != 0 { + t.Fatal("an oversized frame was delivered") + } +} + +// Observation 17: the Endpoint laws hold on every path. A local send conveys only +// the sending face; a stale detach removes only its own receiver; a receiver's +// failure terminates its endpoint, which ends its export. +func TestObservation17EndpointLaws(t *testing.T) { + got := make(chan Value, 2) + local := NewEndpoint(4) + if _, err := local.Receive(func(v Value, _ Context) { got <- v }); err != nil { + t.Fatal(err) + } + private := NewEndpoint(1) + _ = local.Wire().Send(private) + _ = local.Wire().Send(tuple(t, private)) + if v := <-got; v != private.Wire() { + t.Fatalf("a bare endpoint arrived as %T", v) + } + if v := <-got; items(t, v)[0] != private.Wire() { + t.Fatal("an endpoint inside a tuple kept its receive and close authority") + } + + e := NewEndpoint(4) + stale, _ := e.Receive(func(Value, Context) {}) + stale() + seen := make(chan Value, 1) + if _, err := e.Receive(func(v Value, _ Context) { seen <- v }); err != nil { + t.Fatal(err) + } + stale() + _ = e.Send(text("after")) + select { + case <-seen: + case <-time.After(2 * time.Second): + t.Fatal("a stale detach removed the newer receiver") + } + + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, DefaultLimits) + failing := NewEndpoint(4) + if _, err := failing.Receive(func(Value, Context) { panic("receiver fault") }); err != nil { + t.Fatal(err) + } + ref, _ := s.Expose(failing) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("x")), nil); err != nil { + t.Fatal(err) + } + eventually(t, "the failing receiver did not terminate its endpoint", func() bool { + return failing.Failure() != nil && s.Live() == 0 + }) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("y")), nil); !errors.Is(err, UnknownExport) { + t.Fatalf("after termination: %v", err) + } +} diff --git a/hydrated/go/testdata/hydrated-vectors.json b/hydrated/go/testdata/hydrated-vectors.json index 05340c6..9e24d87 100644 --- a/hydrated/go/testdata/hydrated-vectors.json +++ b/hydrated/go/testdata/hydrated-vectors.json @@ -1,6 +1,7 @@ { - "basis": "bitwire decision 0019 (proposed), hydrated frame edition bitwire/hydrated/1. Encodings are hand-derived from ontos-codec-v1 (Atom = 00 uvarint(length) bytes; Tuple = 01 uvarint(arity) items), then checked against the released bitwire 0.5.0 encoder. Atoms are hex. Tags: 01 tuple, 02 wire; 00 and every other value are reserved. A reference is (02, ((path segments...), scope, id)) with a 16-octet scope and a 1-to-16-octet id. \"live\" describes the hydrated value with wire{path, scope, id} for a wire leaf; S and S2 are the two fixed scopes below.", + "basis": "bitwire decision 0019 (proposed), hydrated frame edition bitwire/hydrated/1. Encodings are hand-derived from ontos-codec-v1 (Atom = 00 uvarint(length) bytes; Tuple = 01 uvarint(arity) items), then checked against the released bitwire 0.5.0 encoder. Atoms are hex. Tags: 01 tuple, 02 wire; 00 and every other value are reserved. A reference is (02, ((path segments...), scope, id)) with a 16-octet scope and a 16-octet id. \"live\" describes the hydrated value with wire{path, scope, id} for a wire leaf; S and S2 are the two fixed scopes and I1, I2, I7, I8 fixed ids below.", "scopes": {"S":"000102030405060708090a0b0c0d0e0f","S2":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"}, + "ids": {"I1":"101112131415161718191a1b1c1d1e1f","I2":"202122232425262728292a2b2c2d2e2f","I7":"707172737475767778797a7b7c7d7e7f","I8":"808182838485868788898a8b8c8d8e8f"}, "encode": [ {"name":"bare atom","live":"hello","body":{"atom":"68656c6c6f"},"hex":"000568656c6c6f"}, {"name":"empty atom","live":"\"\"","body":{"atom":""},"hex":"0000"}, @@ -8,19 +9,19 @@ {"name":"an atom equal to the wire tag stays an atom","live":"0x02","body":{"atom":"02"},"hex":"000102"}, {"name":"empty tuple","live":"()","body":{"tuple":[{"atom":"01"},{"tuple":[]}]},"hex":"01020001010100"}, {"name":"tuple of two atoms","live":"(a, b)","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"61"},{"atom":"62"}]}]},"hex":"01020001010102000161000162"}, - {"name":"wire leaf","live":"wire{[client42], S, \"1\"}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]},"hex":"0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, - {"name":"wire at the root path","live":"wire{[], S, \"1\"}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]},"hex":"0102000102010301000010000102030405060708090a0b0c0d0e0f000131"}, - {"name":"wire with exact binary path segments","live":"wire{[\"\", ff2f], S2, \"7\"}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":""},{"atom":"ff2f"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"37"}]}]},"hex":"01020001020103010200000002ff2f0010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000137"}, - {"name":"echo request with a reply wire","live":"(echo, hello, wire{[client42], S, \"1\"})","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"0102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, - {"name":"nested wires in a nested tuple","live":"(hello, (wire{[a,b,service2], S2, \"7\"}, wire{[client42], S, \"2\"}))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"37"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"32"}]}]}]}]}]}]},"hex":"01020001010102000568656c6c6f01020001010102010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0001370102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000132"}, - {"name":"one wire twice encodes identical references","live":"(wire{[client42], S, \"1\"}, wire{[client42], S, \"1\"})","body":{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"010200010101020102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0001310102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, - {"name":"data shaped like an encoded wire stays data","live":"(0x02, ((client42), S, \"1\"))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"02"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"},{"tuple":[{"atom":"636c69656e743432"}]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"0102000101010200010201020001010103010200010101010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, + {"name":"wire leaf","live":"wire{[client42], S, I1}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]},"hex":"0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"wire at the root path","live":"wire{[], S, I1}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]},"hex":"0102000102010301000010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"wire with exact binary path segments","live":"wire{[\"\", ff2f], S2, I7}","body":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":""},{"atom":"ff2f"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"707172737475767778797a7b7c7d7e7f"}]}]},"hex":"01020001020103010200000002ff2f0010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f"}, + {"name":"echo request with a reply wire","live":"(echo, hello, wire{[client42], S, I1})","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"0102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"nested wires in a nested tuple","live":"(hello, (wire{[a,b,service2], S2, I7}, wire{[client42], S, I2}))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"707172737475767778797a7b7c7d7e7f"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"202122232425262728292a2b2c2d2e2f"}]}]}]}]}]}]},"hex":"01020001010102000568656c6c6f01020001010102010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010202122232425262728292a2b2c2d2e2f"}, + {"name":"one wire twice encodes identical references","live":"(wire{[client42], S, I1}, wire{[client42], S, I1})","body":{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"010200010101020102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"data shaped like an encoded wire stays data","live":"(0x02, ((client42), S, \"1\"))","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"02"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"},{"tuple":[{"atom":"636c69656e743432"}]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"0102000101010200010201020001010103010200010101010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, {"name":"data shaped like an encoded tuple stays data","live":"(0x01, ())","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"01"},{"tuple":[{"atom":"01"},{"tuple":[]}]}]}]},"hex":"0102000101010200010101020001010100"} ], "frames": [ - {"name":"echo request to the provider","path":["61","62","7365727669636532"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"37","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101030001610001620008736572766963653201040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0001370102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f000131"}, - {"name":"reply to the caller carrying a further wire","path":["636c69656e743432"],"scope":"000102030405060708090a0b0c0d0e0f","id":"31","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"38"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101010008636c69656e74343201040012626974776972652f68796472617465642f310010000102030405060708090a0b0c0d0e0f00013101020001010102000568656c6c6f010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000138"}, - {"name":"ground data only","path":["","ff2f"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"37","body":{"atom":"70696e67"},"hex":"01030013626974776972652f6164647265737365642f31010200000002ff2f01040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000137000470696e67"} + {"name":"echo request to the provider","path":["61","62","7365727669636532"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"707172737475767778797a7b7c7d7e7f","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6563686f"},{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"636c69656e743432"}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101030001610001620008736572766963653201040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f0102000101010300046563686f000568656c6c6f0102000102010301010008636c69656e7434320010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f"}, + {"name":"reply to the caller carrying a further wire","path":["636c69656e743432"],"scope":"000102030405060708090a0b0c0d0e0f","id":"101112131415161718191a1b1c1d1e1f","body":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"68656c6c6f"},{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"61"},{"atom":"62"},{"atom":"7365727669636532"}]},{"atom":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},{"atom":"808182838485868788898a8b8c8d8e8f"}]}]}]}]},"hex":"01030013626974776972652f6164647265737365642f3101010008636c69656e74343201040012626974776972652f68796472617465642f310010000102030405060708090a0b0c0d0e0f0010101112131415161718191a1b1c1d1e1f01020001010102000568656c6c6f010200010201030103000161000162000873657276696365320010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010808182838485868788898a8b8c8d8e8f"}, + {"name":"ground data only","path":["","ff2f"],"scope":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff","id":"707172737475767778797a7b7c7d7e7f","body":{"atom":"70696e67"},"hex":"01030013626974776972652f6164647265737365642f31010200000002ff2f01040012626974776972652f68796472617465642f310010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff0010707172737475767778797a7b7c7d7e7f000470696e67"} ], "rejectBody": [ {"name":"a tag with no payload","value":{"tuple":[{"atom":"01"}]}}, @@ -32,22 +33,25 @@ {"name":"tuple payload that is an atom","value":{"tuple":[{"atom":"01"},{"atom":"78"}]}}, {"name":"wire payload that is an atom","value":{"tuple":[{"atom":"02"},{"atom":"78"}]}}, {"name":"reference without an id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"atom":"70"}]},{"atom":"000102030405060708090a0b0c0d0e0f"}]}]}}, - {"name":"reference path that is an atom","value":{"tuple":[{"atom":"02"},{"tuple":[{"atom":"70"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}}, - {"name":"reference path segment that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"tuple":[]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}}, - {"name":"15-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"31"}]}]}}, - {"name":"17-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f10"},{"atom":"31"}]}]}}, - {"name":"scope that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"tuple":[]},{"atom":"31"}]}]}}, + {"name":"reference path that is an atom","value":{"tuple":[{"atom":"02"},{"tuple":[{"atom":"70"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"reference path segment that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[{"tuple":[]}]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"15-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"17-octet scope","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f10"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, + {"name":"scope that is a tuple","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"tuple":[]},{"atom":"101112131415161718191a1b1c1d1e1f"}]}]}}, {"name":"empty id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":""}]}]}}, - {"name":"17-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"3131313131313131313131313131313131"}]}]}}, + {"name":"1-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}]}}, + {"name":"15-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e"}]}]}}, + {"name":"17-octet id","value":{"tuple":[{"atom":"02"},{"tuple":[{"tuple":[]},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f20"}]}]}}, {"name":"a malformed node deep inside a valid tuple","value":{"tuple":[{"atom":"01"},{"tuple":[{"atom":"6f6b"},{"tuple":[{"atom":"01"},{"tuple":[{"tuple":[{"atom":"01"}]}]}]}]}]}} ], "rejectFrame": [ - {"name":"another version","value":{"tuple":[{"atom":"626974776972652f68796472617465642f32"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"atom":"78"}]}}, - {"name":"three items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"}]}}, - {"name":"five items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"atom":"78"},{"atom":"79"}]}}, - {"name":"15-octet target scope","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"31"},{"atom":"78"}]}}, + {"name":"another version","value":{"tuple":[{"atom":"626974776972652f68796472617465642f32"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"atom":"78"}]}}, + {"name":"three items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"}]}}, + {"name":"five items","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"atom":"78"},{"atom":"79"}]}}, + {"name":"15-octet target scope","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"atom":"78"}]}}, {"name":"empty target id","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":""},{"atom":"78"}]}}, + {"name":"1-octet target id","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"atom":"78"}]}}, {"name":"a bare atom","value":{"atom":"626974776972652f68796472617465642f31"}}, - {"name":"a malformed body","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"31"},{"tuple":[{"atom":"01"}]}]}} + {"name":"a malformed body","value":{"tuple":[{"atom":"626974776972652f68796472617465642f31"},{"atom":"000102030405060708090a0b0c0d0e0f"},{"atom":"101112131415161718191a1b1c1d1e1f"},{"tuple":[{"atom":"01"}]}]}} ] } diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index 05413f8..d8ed83b 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -50,9 +50,12 @@ export class HydratedTuple { // Any Wire may sit in a tuple; encoding refuses one the runtime does not own (D7). const isWire = (v: unknown): v is Wire => typeof (v as Wire | undefined)?.send === 'function'; +/** An Endpoint travels only as its sending face, on every path (D7). */ +const sendOnly = (v: Value): Value => (v instanceof Endpoint ? v.wire : v); + /** Captures items; returns the ground tuple when no Wire is beneath them. */ export function hydratedTuple(items: readonly Value[]): Value { - const captured = [...items]; + const captured = items.map(sendOnly); let live = false; for (const item of captured) { if (item instanceof HydratedTuple || isWire(item)) live = true; @@ -92,6 +95,8 @@ export class Endpoint implements Wire { #queue: { value: Value; context: Context }[] = []; #limit: number; #handler: ((value: Value, context: Context) => void) | undefined; + #generation = 0; + #failure: unknown; #isClosed = false; #draining = false; #resolveClosed!: () => void; @@ -105,13 +110,18 @@ export class Endpoint implements Wire { send(value: Value): Promise { return this.wire.send(value); } + /** Attaches the one receiver; its detach removes only that receiver. */ receive(handler: (value: Value, context: Context) => void): () => void { if (this.#handler) throw new Error('hydrated: receiver already attached'); + const generation = ++this.#generation; this.#handler = handler; this.#drain(); - return () => { this.#handler = undefined; }; + return () => { if (this.#generation === generation) this.#handler = undefined; }; } + /** The receiver failure that terminated the endpoint, if any. */ + get failure(): unknown { return this.#failure; } + async close(): Promise { if (this.#isClosed) return; this.#isClosed = true; @@ -148,19 +158,25 @@ export class Endpoint implements Wire { this.#draining = false; while (!this.#isClosed && this.#handler && this.#queue.length) { const d = this.#queue.shift()!; - this.#handler(d.value, d.context); + try { + this.#handler(d.value, d.context); + } catch (error) { + // A receiver failure terminates the endpoint, which ends its export. + this.#failure = error; + void this.close(); + } } }); } } +/** Each face's owner, private to this module: a face reveals no endpoint. */ +const owners = new WeakMap(); + /** An Endpoint's sending face. It grants sending only. */ class Face implements Wire { - readonly #endpoint: Endpoint; - constructor(endpoint: Endpoint) { this.#endpoint = endpoint; Object.freeze(this); } - async send(value: Value): Promise { this.#endpoint._admit(value, LOCAL); } - /** @internal */ - get _endpoint(): Endpoint { return this.#endpoint; } + constructor(endpoint: Endpoint) { owners.set(this, endpoint); Object.freeze(this); } + async send(value: Value): Promise { owners.get(this)!._admit(sendOnly(value), LOCAL); } } /** @@ -196,7 +212,6 @@ export class Scope { readonly ns: Namespace; readonly #sender: AddressedWire; readonly #limits: Limits; - #next = 0n; readonly #byId = new Map(); readonly #byEndpoint = new Map(); #closed = false; @@ -245,9 +260,14 @@ export class Scope { const known = staged.get(endpoint) ?? this.#byEndpoint.get(endpoint); if (known) return known; if (this.#byEndpoint.size + staged.size >= this.#limits.exports) throw new Refusal(LIMIT); - const id = atom(new TextEncoder().encode(String(++this.#next))); - staged.set(endpoint, id); - return id; + // 16 octets from a secure source, redrawn on collision: no reference reveals another's (D3). + for (;;) { + const id = atom(crypto.getRandomValues(new Uint8Array(16))); + if (!this.#byId.has(hex(id)) && ![...staged.values()].some((other) => other.equals(id))) { + staged.set(endpoint, id); + return id; + } + } } #commit(staged: Map): void { @@ -288,7 +308,7 @@ export class Scope { if (v instanceof Endpoint) return this._encode(v.wire, depth, staged, b); if (v instanceof Face) { this.#spend(b, depth, 0); - return refValue({ path: this.path, scope: this.token, id: this.#exportId(v._endpoint, staged) }); + return refValue({ path: this.path, scope: this.token, id: this.#exportId(owners.get(v)!, staged) }); } if (v instanceof Proxy) { if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); @@ -303,9 +323,11 @@ export class Scope { deliver(path: Path, message: Atom | Tuple, context: Context): void { if (!pathEqual(path, this.path)) throw new Refusal(MALFORMED_FRAME); if (this.#closed) throw new Refusal(SCOPE_ENDED); + // The whole frame counts against the byte bound, reference material included (D8). + try { encodeMessage(message, this.#limits.bytes); } catch { throw new Refusal(LIMIT); } if (!(message instanceof Tuple) || message.length !== 4 || !HEADER.equals(message.at(0)!)) throw new Refusal(MALFORMED_FRAME); const [, token, id, body] = message.items(); - if (!(token instanceof Atom) || !(id instanceof Atom) || token.length !== 16 || id.length < 1 || id.length > 16) throw new Refusal(MALFORMED_FRAME); + if (!(token instanceof Atom) || !(id instanceof Atom) || token.length !== 16 || id.length !== 16) throw new Refusal(MALFORMED_FRAME); if (!token.equals(this.token)) throw new Refusal(STALE_SCOPE); const target = this.#byId.get(hex(id)); if (!target) throw new Refusal(UNKNOWN_EXPORT); @@ -325,7 +347,11 @@ export class Scope { if (!(payload instanceof Tuple)) throw new Refusal(MALFORMED_FRAME); return hydratedTuple(payload.items().map((c) => this._decode(c, depth + 1, interned, b))); } - if (tag.equals(WIRE_TAG)) return this.#import(readReference(payload!, this.#limits.depth), interned); + if (tag.equals(WIRE_TAG)) { + const ref = readReference(payload!, this.#limits.depth); + for (const a of [...ref.path, ref.scope, ref.id]) this.#spend(b, depth + 1, a.length); + return this.#import(ref, interned); + } throw new Refusal(MALFORMED_FRAME); } @@ -349,7 +375,7 @@ export function readReference(v: Atom | Tuple, maxDepth: number = DEFAULT_LIMITS const [p, scope, id] = v.items(); if (!(p instanceof Tuple) || p.length > maxDepth) throw new Refusal(MALFORMED_FRAME); const path = p.items().map((s) => { if (!(s instanceof Atom)) throw new Refusal(MALFORMED_FRAME); return s; }); - if (!(scope instanceof Atom) || !(id instanceof Atom) || scope.length !== 16 || id.length < 1 || id.length > 16) throw new Refusal(MALFORMED_FRAME); + if (!(scope instanceof Atom) || !(id instanceof Atom) || scope.length !== 16 || id.length !== 16) throw new Refusal(MALFORMED_FRAME); return Object.freeze({ path: Object.freeze(path), scope, id }); } diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index e07d4af..a7b5819 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -11,9 +11,9 @@ import { } from '../../../dist/hydrated/ts/src/index.js'; import { text, str, eventually, PATHS, tree, serve, call } from './harness.mjs'; -test('observation 1: vectors round-trip (bitwire#83 at 20a6b6b, pinned)', () => { +test('observation 1: vectors round-trip (bitwire#83 revision 2, pinned)', () => { const raw = readFileSync(new URL('../../go/testdata/hydrated-vectors.json', import.meta.url)); - assert.equal(createHash('sha256').update(raw).digest('hex'), '621325467a19fa5d5ad34a1805e38feadb5609ad13fe5e71e991cf87b4d77278'); + assert.equal(createHash('sha256').update(raw).digest('hex'), 'ce6b9f0f45db9c64627bbc1ba007344a8b4dde44297cc771431a7bc122546e6f'); const v = JSON.parse(raw); const from = (x) => ('atom' in x ? atom(Buffer.from(x.atom, 'hex')) : tuple(x.tuple.map(from))); const wires = (x) => (x instanceof Atom || x instanceof Tuple ? 0 : (items(x)?.reduce((n, c) => n + wires(c), 0) ?? 1)); @@ -154,3 +154,65 @@ test('observation 9: received context is not data', async (t) => { assert.equal(r.context, 'provider-link'); assert.ok(forged.equals(r.value)); }); + +for (const carrier of ['pair', 'websocket']) { + test(`${carrier}: observation 8, one reference grants no sibling`, async (t) => { + const n = await tree(t, carrier, 'client', 'provider', 'third'); + const provider = n.parts.provider.scope; + let privateHits = 0; + const [, pub] = serve(t, provider, () => {}); + const [, intended] = serve(t, provider, () => { privateHits++; }); + await n.parts.third.scope.connect(intended).send(text('legitimate')); + await eventually(() => privateHits === 1, 'the intended holder was not served'); + assert.equal(pub.id.length, 16); + assert.ok(!pub.id.equals(intended.id)); + const bump = Uint8Array.from(pub.id.bytes()); bump[15]++; + const forged = [atom(bump), atom(new Uint8Array(16)), text('2'), text('0000000000000002')]; + for (const id of forged) await n.parts.client.scope.connect({ ...pub, id }).send(text('forged')).catch(() => {}); + await eventually(() => n.diags.length === forged.length, 'forged references were not refused'); + for (const d of n.diags) assert.ok(d.who === 'provider' && ['unknown-export', 'malformed-frame'].includes(d.reason), JSON.stringify(d)); + assert.equal(privateHits, 1); + }); +} + +test('observation 15, incoming: a frame counts whole, reference material included', () => { + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }, { exports: 4, nodes: 64, depth: 8, bytes: 128 }); + const target = new Endpoint(4); + let delivered = 0; + target.receive(() => { delivered++; }); + const ref = s.expose(target); + const huge = tuple([atom([2]), tuple([tuple([atom(new Uint8Array(4096))]), atom(new Uint8Array(16)), atom(new Uint8Array(16))])]); + const frame = tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, tuple([atom([1]), tuple([huge])])]); + assert.throws(() => s.deliver(s.path, frame, undefined), (e) => e.reason === 'limit'); + assert.equal(delivered, 0); +}); + +test('observation 17: Endpoint laws on every path', async () => { + const got = []; + const local = new Endpoint(4); + local.receive((v) => got.push(v)); + const priv = new Endpoint(1); + await local.wire.send(priv); + await local.wire.send(hydratedTuple([priv])); + await eventually(() => got.length === 2, 'local sends not delivered'); + assert.equal(got[0], priv.wire, 'a bare endpoint kept its authority'); + assert.equal(items(got[1])[0], priv.wire, 'an endpoint inside a tuple kept its authority'); + assert.equal('close' in got[0], false); + + const e = new Endpoint(4); + const stale = e.receive(() => {}); + stale(); + let seen = 0; + e.receive(() => { seen++; }); + stale(); + await e.send(text('after')); + await eventually(() => seen === 1, 'a stale detach removed the newer receiver'); + + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }); + const failing = new Endpoint(4); + failing.receive(() => { throw new Error('receiver fault'); }); + const ref = s.expose(failing); + s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('x')]), undefined); + await eventually(() => failing.failure !== undefined && s.live === 0, 'the failing receiver did not terminate its endpoint'); + assert.throws(() => s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('y')]), undefined), (e) => e.reason === UNKNOWN_EXPORT); +}); From 2110f737724a9989f829d8d51dd4275d75acde4b Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 16:46:26 +0200 Subject: [PATCH 07/21] hydrated: README lists the TypeScript observations now covered Co-Authored-By: Claude Opus 5.5 --- hydrated/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hydrated/README.md b/hydrated/README.md index fca9268..b6c3b49 100644 --- a/hydrated/README.md +++ b/hydrated/README.md @@ -8,7 +8,7 @@ branch is not proposed for merge until the record is accepted. The run the record's observations 1-15 and 17 around an opaque middle router, over local pairs and WebSocket where the carrier matters. -The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4 and 6-9 +The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4, 6-10, 15 and 17 ([tests](ts/test/hydrated.test.mjs)), with 2, 3, 4 and 7 over both carriers. [scripts/hydrated-interop.mjs](../scripts/hydrated-interop.mjs) runs observation 14 over a real WebSocket in both roles, Go serving TypeScript and TypeScript From 63c5918034d8662dda08419c39ef285be4edc3e2 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 16:57:27 +0200 Subject: [PATCH 08/21] hydrated: one counting domain for value bounds in both directions Fiber Composition's replay found that one configured bound meant different things when sending and receiving: encoding counted a Wire leaf as one node with no bytes, decoding counted its tag node and every path segment, scope and id at the next depth. A tuple of three occurrences of one face passed at the sender and was refused at the receiver. Both directions now count the hydrated value (decision 0019 D8): each atom, tuple and Wire leaf is one node at its tuple depth; bytes are atom bytes plus each Wire leaf's reference material (path bytes and 32). The whole-frame byte check stays on both sides. Observation 15's symmetry case sweeps tight node, depth and byte budgets in Go (700 cases, both outcomes) and TypeScript: the sender accepts exactly when the receiver does. Co-Authored-By: Claude Opus 5.5 --- hydrated/go/hydrated.go | 27 +++++++++----- hydrated/go/hydrated_test.go | 56 ++++++++++++++++++++++++++++++ hydrated/ts/src/index.ts | 15 +++++--- hydrated/ts/test/hydrated.test.mjs | 34 ++++++++++++++++++ 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index db12805..df250b7 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -495,6 +495,17 @@ func (s *Scope) commit(staged map[*Endpoint]ontos.Atom) error { type budget struct{ nodes, bytes int } +// The node, depth and byte bounds count the hydrated value, identically when +// sending and receiving (D8): each atom, tuple and Wire leaf is one node at its +// tuple depth; bytes are atom bytes plus each Wire leaf's reference material. +func refBytes(path wire.Path) int { + n := 32 // scope token and export id + for _, a := range path { + n += a.Len() + } + return n +} + func (s *Scope) spend(b *budget, depth, n int) error { b.nodes++ b.bytes += n @@ -555,7 +566,7 @@ func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *b case *Endpoint: return s.encode(x.face, depth, staged, b) case *face: - if err := s.spend(b, depth, 0); err != nil { + if err := s.spend(b, depth, refBytes(s.path)); err != nil { return nil, err } id, err := s.exportID(x.e, staged) @@ -567,7 +578,7 @@ func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *b if x.ns != s.ns { return nil, ForeignNamespace } - return x.ref.value(), s.spend(b, depth, 0) + return x.ref.value(), s.spend(b, depth, refBytes(x.ref.Path)) case Wire: return nil, Unexportable } @@ -628,15 +639,15 @@ func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *bu if !ok || tag.Len() != 1 { return nil, MalformedFrame } - if err := s.spend(b, depth, 0); err != nil { - return nil, err - } switch { case tag.Equal(tupleTag): children, ok := t.At(1).(ontos.Tuple) if !ok { return nil, MalformedFrame } + if err := s.spend(b, depth, 0); err != nil { + return nil, err + } items := make([]Value, children.Len()) for i, c := range children.Items() { var err error @@ -650,10 +661,8 @@ func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *bu if err != nil { return nil, err } - for _, a := range append(append(wire.Path{}, r.Path...), r.Scope, r.ID) { - if err := s.spend(b, depth+1, a.Len()); err != nil { - return nil, err - } + if err := s.spend(b, depth, refBytes(r.Path)); err != nil { + return nil, err } return s.importRef(r, interned), nil } diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index fad97c7..1d97d8c 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -860,3 +860,59 @@ func TestObservation17EndpointLaws(t *testing.T) { t.Fatalf("after termination: %v", err) } } + +// Observation 15, symmetry: under the same bounds, the sender accepts a value +// exactly when the receiver does, at tight node, depth and byte budgets. +func TestObservation15SenderAndReceiverCountAlike(t *testing.T) { + face := NewEndpoint(1) + nested := Value(text("x")) + for i := 0; i < 3; i++ { + nested = tuple(t, nested, face) + } + values := map[string]Value{ + "one face three times": tuple(t, face, face, face), + "nested faces": nested, + "a long atom": ontos.NewAtom(make([]byte, 96)), + "atoms and a face": tuple(t, text("a"), text("bb"), face), + } + seen := map[bool]int{} + for nodes := 2; nodes <= 8; nodes++ { + for depth := 0; depth <= 4; depth++ { + for _, bytes := range []int{40, 64, 96, 128, 1024} { + limits := Limits{Exports: 4, Nodes: nodes, Depth: depth + 1, Bytes: bytes} + for name, v := range values { + rec := &recordingSender{} + sender, _ := NewScope(NewNamespace("x"), wire.Path{text("s")}, rec, limits) + receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) + target := NewEndpoint(4) + ref, _ := receiver.Expose(target) + sendErr := sender.Connect(ref).Send(v) + accepted := sendErr == nil + seen[accepted]++ + if !accepted && !errors.Is(sendErr, Limit) { + t.Fatalf("%s: %v", name, sendErr) + } + var recvErr error + if accepted { + recvErr = receiver.Deliver(receiver.path, rec.frames[0], nil) + } else { + // Build the frame without the sender's budget and offer it. + open, _ := NewScope(NewNamespace("x"), wire.Path{text("s")}, &recordingSender{}, Limits{Exports: 4, Nodes: 1 << 20, Depth: 1 << 10, Bytes: 1 << 20}) + frame, err := open.stage(ref, v) + if err != nil { + t.Fatal(err) + } + recvErr = receiver.Deliver(receiver.path, frame, nil) + } + if accepted != (recvErr == nil) { + t.Fatalf("%s at nodes %d depth %d bytes %d: sender %v, receiver %v", name, nodes, depth+1, bytes, sendErr, recvErr) + } + } + } + } + } + if seen[true] == 0 || seen[false] == 0 { + t.Fatalf("the budgets never separated outcomes: %v", seen) + } + t.Logf("accepted %d, refused %d", seen[true], seen[false]) +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index d8ed83b..37cf9fa 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -205,6 +205,13 @@ export const DEFAULT_LIMITS: Limits = Object.freeze({ exports: 64, nodes: 4096, interface Budget { nodes: number; bytes: number } +/** + * The node, depth and byte bounds count the hydrated value, identically when + * sending and receiving (D8): each atom, tuple and Wire leaf is one node at its + * tuple depth; bytes are atom bytes plus each Wire leaf's reference material. + */ +const refBytes = (path: Path): number => path.reduce((n, a) => n + a.length, 32); + /** One participant's hydration state for one incarnation (Terms). */ export class Scope { readonly path: Path; @@ -307,12 +314,12 @@ export class Scope { } if (v instanceof Endpoint) return this._encode(v.wire, depth, staged, b); if (v instanceof Face) { - this.#spend(b, depth, 0); + this.#spend(b, depth, refBytes(this.path)); return refValue({ path: this.path, scope: this.token, id: this.#exportId(owners.get(v)!, staged) }); } if (v instanceof Proxy) { if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); - this.#spend(b, depth, 0); + this.#spend(b, depth, refBytes(v.ref.path)); return refValue(v.ref); } if (v && typeof (v as Wire).send === 'function') throw new Refusal(UNEXPORTABLE); @@ -342,14 +349,14 @@ export class Scope { if (v.length !== 2) throw new Refusal(MALFORMED_FRAME); const [tag, payload] = v.items(); if (!(tag instanceof Atom) || tag.length !== 1) throw new Refusal(MALFORMED_FRAME); - this.#spend(b, depth, 0); if (tag.equals(TUPLE_TAG)) { if (!(payload instanceof Tuple)) throw new Refusal(MALFORMED_FRAME); + this.#spend(b, depth, 0); return hydratedTuple(payload.items().map((c) => this._decode(c, depth + 1, interned, b))); } if (tag.equals(WIRE_TAG)) { const ref = readReference(payload!, this.#limits.depth); - for (const a of [...ref.path, ref.scope, ref.id]) this.#spend(b, depth + 1, a.length); + this.#spend(b, depth, refBytes(ref.path)); return this.#import(ref, interned); } throw new Refusal(MALFORMED_FRAME); diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index a7b5819..55a4e36 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -216,3 +216,37 @@ test('observation 17: Endpoint laws on every path', async () => { await eventually(() => failing.failure !== undefined && s.live === 0, 'the failing receiver did not terminate its endpoint'); assert.throws(() => s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('y')]), undefined), (e) => e.reason === UNKNOWN_EXPORT); }); + +test('observation 15, symmetry: sender and receiver count a value alike', async () => { + const face = new Endpoint(1); + let nested = text('x'); + for (let i = 0; i < 3; i++) nested = hydratedTuple([nested, face]); + const values = { + 'one face three times': hydratedTuple([face, face, face]), + 'nested faces': nested, + 'a long atom': atom(new Uint8Array(96)), + 'atoms and a face': hydratedTuple([text('a'), text('bb'), face]), + }; + const seen = { true: 0, false: 0 }; + for (let nodes = 2; nodes <= 8; nodes++) for (let depth = 1; depth <= 5; depth++) for (const bytes of [40, 64, 96, 128, 1024]) { + const limits = { exports: 4, nodes, depth, bytes }; + for (const [name, v] of Object.entries(values)) { + const frames = []; + const sender = new Scope(new Namespace('x'), [text('s')], { send: async (_p, f) => { frames.push(f); } }, limits); + const receiver = new Scope(new Namespace('x'), [text('r')], { send: async () => {} }, limits); + const ref = receiver.expose(new Endpoint(4)); + let accepted = true; + try { await sender.connect(ref).send(v); } catch (e) { if (e.reason !== 'limit') throw e; accepted = false; } + seen[accepted]++; + let frame = frames[0]; + if (!accepted) { + const open = new Scope(new Namespace('x'), [text('s')], { send: async (_p, f) => { frame = f; } }, { exports: 4, nodes: 1 << 20, depth: 1 << 10, bytes: 1 << 20 }); + await open.connect(ref).send(v); + } + let received = true; + try { receiver.deliver(receiver.path, frame, undefined); } catch (e) { if (e.reason !== 'limit') throw e; received = false; } + assert.equal(received, accepted, `${name} at nodes ${nodes} depth ${depth} bytes ${bytes}`); + } + } + assert.ok(seen.true > 0 && seen.false > 0, JSON.stringify(seen)); +}); From 8a52138458740f13de24c3e75a1ed661737f4c83 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 17:14:04 +0200 Subject: [PATCH 09/21] hydrated: reference paths are byte material, not depth (D8) Both realizations refused a reference whose path had more segments than the depth bound, a rule neither decision 0019 nor the proposed pure codec record contains: under D8 path segments count toward the byte bound only. The rule is removed; a five-segment reference under depth 2 is now accepted (observation 15, paths). Found while reviewing the codec record at bitwire 54b868e. Co-Authored-By: Claude Opus 5.5 --- hydrated/go/hydrated.go | 10 ++++++---- hydrated/go/hydrated_test.go | 22 +++++++++++++++++++++- hydrated/ts/src/index.ts | 7 ++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index df250b7..1ff832a 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -142,7 +142,7 @@ func (r Reference) Payload() ontos.Value { } // ReadReference reads a reference payload. -func ReadReference(v ontos.Value) (Reference, error) { return readReference(v, DefaultLimits.Depth) } +func ReadReference(v ontos.Value) (Reference, error) { return readReference(v) } func (r Reference) key() string { k := strconv.Itoa(len(r.Path)) @@ -657,7 +657,7 @@ func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *bu } return NewTuple(items...) case tag.Equal(wireTag): - r, err := readReference(t.At(1), s.limits.Depth) + r, err := readReference(t.At(1)) if err != nil { return nil, err } @@ -669,13 +669,15 @@ func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *bu return nil, MalformedFrame } -func readReference(v ontos.Value, maxDepth int) (Reference, error) { +// readReference reads a reference payload. Path segments are reference material: +// they count toward the byte bound, not as nodes or depth (D8). +func readReference(v ontos.Value) (Reference, error) { t, ok := v.(ontos.Tuple) if !ok || t.Len() != 3 { return Reference{}, MalformedFrame } p, ok := t.At(0).(ontos.Tuple) - if !ok || p.Len() > maxDepth { + if !ok { return Reference{}, MalformedFrame } path := make(wire.Path, p.Len()) diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index 1d97d8c..0938a7c 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -770,7 +770,7 @@ func TestObservation13ConcurrentSends(t *testing.T) { registered := s.byEndpoint[face] for _, f := range rec.frames { leaf := f.(ontos.Tuple).At(3).(ontos.Tuple).At(1).(ontos.Tuple).At(0) // body (01, ((02, ref))) - ref, err := readReference(leaf.(ontos.Tuple).At(1), DefaultLimits.Depth) + ref, err := readReference(leaf.(ontos.Tuple).At(1)) if err != nil || !ref.ID.Equal(registered) { t.Fatal("a frame names an id that was not registered") } @@ -916,3 +916,23 @@ func TestObservation15SenderAndReceiverCountAlike(t *testing.T) { } t.Logf("accepted %d, refused %d", seen[true], seen[false]) } + +// Observation 15, paths: a reference path longer than the depth bound is +// reference material, counted in bytes, not refused as depth (D8). +func TestObservation15LongReferencePath(t *testing.T) { + limits := Limits{Exports: 4, Nodes: 8, Depth: 2, Bytes: 4096} + receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) + target := NewEndpoint(4) + got := make(chan Value, 1) + _, _ = target.Receive(func(v Value, _ Context) { got <- v }) + ref, _ := receiver.Expose(target) + long := wire.Path{text("a"), text("b"), text("c"), text("d"), text("e")} + far := Reference{long, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + body := ontos.NewTuple(tupleTag, ontos.NewTuple(far.value())) + if err := receiver.Deliver(receiver.path, ontos.NewTuple(header, ref.Scope, ref.ID, body), nil); err != nil { + t.Fatalf("a five-segment reference under depth 2: %v", err) + } + if v := <-got; !wire.PathEqual(items(t, v)[0].(*proxy).ref.Path, long) { + t.Fatal("the long path changed") + } +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index 37cf9fa..41206a2 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -355,7 +355,7 @@ export class Scope { return hydratedTuple(payload.items().map((c) => this._decode(c, depth + 1, interned, b))); } if (tag.equals(WIRE_TAG)) { - const ref = readReference(payload!, this.#limits.depth); + const ref = readReference(payload!); this.#spend(b, depth, refBytes(ref.path)); return this.#import(ref, interned); } @@ -377,10 +377,11 @@ export class Scope { } /** Reads a reference payload ((path...), scope, id). */ -export function readReference(v: Atom | Tuple, maxDepth: number = DEFAULT_LIMITS.depth): Reference { +/** Path segments are reference material: they count toward the byte bound, not as nodes or depth (D8). */ +export function readReference(v: Atom | Tuple): Reference { if (!(v instanceof Tuple) || v.length !== 3) throw new Refusal(MALFORMED_FRAME); const [p, scope, id] = v.items(); - if (!(p instanceof Tuple) || p.length > maxDepth) throw new Refusal(MALFORMED_FRAME); + if (!(p instanceof Tuple)) throw new Refusal(MALFORMED_FRAME); const path = p.items().map((s) => { if (!(s instanceof Atom)) throw new Refusal(MALFORMED_FRAME); return s; }); if (!(scope instanceof Atom) || !(id instanceof Atom) || scope.length !== 16 || id.length !== 16) throw new Refusal(MALFORMED_FRAME); return Object.freeze({ path: Object.freeze(path), scope, id }); From 476bf521a09187b3c48b8588fdc577f3f0e39a61 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 17:29:37 +0200 Subject: [PATCH 10/21] hydrated: no construction path bypasses projection (D1, D7) Wire & Runtime reproduced at 8a52138 that the exported TypeScript HydratedTuple constructor copied items without the send-only projection: new HydratedTuple([privateEndpoint]) sent locally delivered the endpoint with a callable close. The class stays exported for instanceof, but only this module can construct it; direct construction throws and points to hydratedTuple(), which projects endpoints to faces and collapses Wire-free tuples to ground tuples. In Go, the zero Tuple (the only one NewTuple does not make) now arrives as the ground empty tuple. Observation 17's construction cases cover bare, nested and ground use. Co-Authored-By: Claude Opus 5.5 --- hydrated/go/hydrated.go | 12 +++++++++--- hydrated/go/hydrated_test.go | 12 ++++++++++++ hydrated/ts/src/index.ts | 9 +++++++-- hydrated/ts/test/hydrated.test.mjs | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index 1ff832a..b2b63ee 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -88,10 +88,16 @@ func NewTuple(items ...Value) (Value, error) { return ontos.NewTuple(ground...), nil } -// sendOnly projects an Endpoint to its sending face. +// sendOnly projects an Endpoint to its sending face, and the zero Tuple, the +// only one NewTuple does not make, to the ground empty tuple (D1, D7). func sendOnly(v Value) Value { - if e, ok := v.(*Endpoint); ok { - return e.face + switch x := v.(type) { + case *Endpoint: + return x.face + case Tuple: + if x.items == nil { + return ontos.NewTuple() + } } return v } diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index 0938a7c..5b2191f 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -936,3 +936,15 @@ func TestObservation15LongReferencePath(t *testing.T) { t.Fatal("the long path changed") } } + +// Observation 17, construction: the zero Tuple, the only one NewTuple does not +// make, arrives as the ground empty tuple. +func TestObservation17ZeroTupleIsGround(t *testing.T) { + got := make(chan Value, 1) + local := NewEndpoint(1) + _, _ = local.Receive(func(v Value, _ Context) { got <- v }) + _ = local.Wire().Send(Tuple{}) + if v, ok := (<-got).(ontos.Tuple); !ok || v.Len() != 0 { + t.Fatal("the zero hydrated tuple was not delivered as the ground empty tuple") + } +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index 41206a2..4084213 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -39,9 +39,14 @@ const TUPLE_TAG = atom([1]); const WIRE_TAG = atom([2]); /** A tuple with at least one Wire beneath it; see hydratedTuple (D1). */ +/** Only this module constructs hydrated tuples, so every one is canonical (D1, D7). */ +const CONSTRUCT = Symbol('hydrated tuple'); + +/** A tuple with at least one Wire beneath it. Construct it with hydratedTuple(). */ export class HydratedTuple { readonly items: readonly Value[]; - constructor(items: readonly Value[]) { + constructor(items: readonly Value[], token?: symbol) { + if (token !== CONSTRUCT) throw new TypeError('hydrated: construct tuples with hydratedTuple()'); this.items = Object.freeze([...items]); Object.freeze(this); } @@ -61,7 +66,7 @@ export function hydratedTuple(items: readonly Value[]): Value { if (item instanceof HydratedTuple || isWire(item)) live = true; else if (!(item instanceof Atom) && !(item instanceof Tuple)) throw new TypeError('hydrated: not a value'); } - return live ? new HydratedTuple(captured) : tuple(captured as (Atom | Tuple)[]); + return live ? new HydratedTuple(captured, CONSTRUCT) : tuple(captured as (Atom | Tuple)[]); } /** Any tuple's items, ground or hydrated. */ diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 55a4e36..f545f26 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -250,3 +250,18 @@ test('observation 15, symmetry: sender and receiver count a value alike', async } assert.ok(seen.true > 0 && seen.false > 0, JSON.stringify(seen)); }); + +test('observation 17, construction: the exported tuple class cannot bypass projection', async () => { + const { HydratedTuple } = await import('../../../dist/hydrated/ts/src/index.js'); + const priv = new Endpoint(1); + assert.throws(() => new HydratedTuple([priv]), /hydratedTuple/); + assert.throws(() => new HydratedTuple([text('ground')]), /hydratedTuple/); + const got = []; + const local = new Endpoint(4); + local.receive((v) => got.push(v)); + await local.wire.send(hydratedTuple([hydratedTuple([priv]), text('x')])); + await eventually(() => got.length === 1, 'not delivered'); + const inner = items(items(got[0])[0])[0]; + assert.equal(inner, priv.wire, 'a nested endpoint kept its authority'); + assert.ok(hydratedTuple([text('a')]) instanceof Tuple, 'a tuple without a Wire is not ground'); +}); From ab21b3e48ac1af8496c1c29e247e9cfb6a73cac4 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 17:35:56 +0200 Subject: [PATCH 11/21] hydrated: pin the accepted 0019 vectors (bitwire#83 at 6e33fb3) Only the corpus basis text changed (proposed -> accepted); cases and bytes are identical. With PR43 merged: TS 54/54, go test -race, interop both roles. Co-Authored-By: Claude Opus 5.5 --- hydrated/README.md | 2 +- hydrated/go/hydrated_test.go | 4 ++-- hydrated/go/testdata/hydrated-vectors.json | 2 +- hydrated/ts/test/hydrated.test.mjs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hydrated/README.md b/hydrated/README.md index b6c3b49..bf71b0e 100644 --- a/hydrated/README.md +++ b/hydrated/README.md @@ -15,6 +15,6 @@ The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4, 6-10, 15 and serving Go. Both peers build from this repository, not from fresh published dependencies, so they show the grammar interoperates, not package delivery. -The record's vectors are copied from bitwire#83 (revision 2) into +The record's vectors are copied from bitwire#83 (accepted, at 6e33fb3) into [testdata](go/testdata/hydrated-vectors.json), pinned by sha256; both languages replay them. diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index 5b2191f..e9e6848 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -58,7 +58,7 @@ func eventually(t *testing.T, what string, cond func() bool) { } } -// --- vectors (bitwire#83 revision 2, pinned by hash) --- +// --- vectors (bitwire#83 accepted at 6e33fb3, pinned by hash) --- type jsonValue map[string]json.RawMessage @@ -97,7 +97,7 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != "ce6b9f0f45db9c64627bbc1ba007344a8b4dde44297cc771431a7bc122546e6f" { + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != "9d5f684871d1b56719b33bdff3114a101d2a7a1a8702d1f4474303df0e109312" { t.Fatal("vectors changed; repin to the reviewed bitwire record") } var v struct { diff --git a/hydrated/go/testdata/hydrated-vectors.json b/hydrated/go/testdata/hydrated-vectors.json index 9e24d87..953df7d 100644 --- a/hydrated/go/testdata/hydrated-vectors.json +++ b/hydrated/go/testdata/hydrated-vectors.json @@ -1,5 +1,5 @@ { - "basis": "bitwire decision 0019 (proposed), hydrated frame edition bitwire/hydrated/1. Encodings are hand-derived from ontos-codec-v1 (Atom = 00 uvarint(length) bytes; Tuple = 01 uvarint(arity) items), then checked against the released bitwire 0.5.0 encoder. Atoms are hex. Tags: 01 tuple, 02 wire; 00 and every other value are reserved. A reference is (02, ((path segments...), scope, id)) with a 16-octet scope and a 16-octet id. \"live\" describes the hydrated value with wire{path, scope, id} for a wire leaf; S and S2 are the two fixed scopes and I1, I2, I7, I8 fixed ids below.", + "basis": "bitwire decision 0019, hydrated frame edition bitwire/hydrated/1. Encodings are hand-derived from ontos-codec-v1 (Atom = 00 uvarint(length) bytes; Tuple = 01 uvarint(arity) items), then checked against the released bitwire 0.5.0 encoder. Atoms are hex. Tags: 01 tuple, 02 wire; 00 and every other value are reserved. A reference is (02, ((path segments...), scope, id)) with a 16-octet scope and a 16-octet id. \"live\" describes the hydrated value with wire{path, scope, id} for a wire leaf; S and S2 are the two fixed scopes and I1, I2, I7, I8 fixed ids below.", "scopes": {"S":"000102030405060708090a0b0c0d0e0f","S2":"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"}, "ids": {"I1":"101112131415161718191a1b1c1d1e1f","I2":"202122232425262728292a2b2c2d2e2f","I7":"707172737475767778797a7b7c7d7e7f","I8":"808182838485868788898a8b8c8d8e8f"}, "encode": [ diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index f545f26..bc4190b 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -11,9 +11,9 @@ import { } from '../../../dist/hydrated/ts/src/index.js'; import { text, str, eventually, PATHS, tree, serve, call } from './harness.mjs'; -test('observation 1: vectors round-trip (bitwire#83 revision 2, pinned)', () => { +test('observation 1: vectors round-trip (bitwire#83 accepted at 6e33fb3, pinned)', () => { const raw = readFileSync(new URL('../../go/testdata/hydrated-vectors.json', import.meta.url)); - assert.equal(createHash('sha256').update(raw).digest('hex'), 'ce6b9f0f45db9c64627bbc1ba007344a8b4dde44297cc771431a7bc122546e6f'); + assert.equal(createHash('sha256').update(raw).digest('hex'), '9d5f684871d1b56719b33bdff3114a101d2a7a1a8702d1f4474303df0e109312'); const v = JSON.parse(raw); const from = (x) => ('atom' in x ? atom(Buffer.from(x.atom, 'hex')) : tuple(x.tuple.map(from))); const wires = (x) => (x instanceof Atom || x instanceof Tuple ? 0 : (items(x)?.reduce((n, c) => n + wires(c), 0) ?? 1)); From 3a4bad3eb434085757db36a867408c2984229a1a Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 17:58:24 +0200 Subject: [PATCH 12/21] hydrated: align with bitwire's public hydrated surface; production docs and smokes - Public names (bitwire#86): HydratedValue, HydratedWire and ReceivedContext replace the local Value, Wire and Context, so moving to bitwire's declarations is an import change only. - TypeScript HydratedTuple has bitwire's shape (kind 'tuple', length, items(), at()); faces, proxies and endpoints carry kind 'wire'. - Endpoint termination follows bitwire's contract: Go Termination() and TypeScript closed: Promise, "failed" for a receiver failure and "closed" otherwise. - README, LAYOUT and CHANGELOG describe the component; the package smoke installs the packed package and exchanges a hydrated reply; the Go smoke builds and runs the hydrated peer from the public module. TS 54/54, go test -race ./..., interop both roles, package smoke pass. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++ LAYOUT.md | 4 +- conformance/interop/hydrated/main.go | 34 +++---- hydrated/README.md | 45 ++++++--- hydrated/go/hydrated.go | 100 +++++++++--------- hydrated/go/hydrated_test.go | 145 ++++++++++++++------------- hydrated/ts/src/index.ts | 105 ++++++++++--------- hydrated/ts/test/hydrated.test.mjs | 7 +- scripts/go-smoke.mjs | 21 +++- scripts/package-smoke.mjs | 14 ++- 10 files changed, 289 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4141d3f..8b35bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- Add `hydrated/{go,ts}`, the realization of bitwire decision 0019 (hydrated + wire protocol, first edition). + - Messages may carry live Wires; references are `(owner path, scope, id)`, + with 16-octet unpredictable ids, valid end to end in one namespace. + - Exports are bound to a local Endpoint's lifetime, and liveness is judged on + send. + - Received context is delivered beside the value. + - Bounds use one counting domain in both directions. +- Go/TypeScript hydrated interoperability, and the composition conformance + suite (Cell, Text, Counter). + ## 0.6.0 - Restore addressless Wire sending and Endpoint ownership under bitwire 0.5.0. diff --git a/LAYOUT.md b/LAYOUT.md index 634b20f..f7668e2 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -1,7 +1,7 @@ # Repository layout -Use component first, language second: `//`, with two-letter codes such as `go` and `ts`. The current implementation is `core/{go,ts}` and `websocket/{go,ts}`. TypeScript source and tests use `src/` and `test/` within each component; Go tests sit beside their packages. +Use component first, language second: `//`, with two-letter codes such as `go` and `ts`. The current implementation is `core/{go,ts}`, `websocket/{go,ts}` and `hydrated/{go,ts}`. TypeScript source and tests use `src/` and `test/` within each component; Go tests sit beside their packages. -The root `go.mod` and `package.json` describe one release unit. Shared documentation stays under `docs/`. The generic interoperability peer is test-only at `conformance/interop/go`; its runner is `scripts/interop.mjs`. Test fixtures live with the tests that use them. Do not add empty packages for planned components. +The root `go.mod` and `package.json` describe one release unit. Shared documentation stays under `docs/`. The generic interoperability peer is test-only at `conformance/interop/go`; its runner is `scripts/interop.mjs`. The hydrated peer is `conformance/interop/hydrated`, run by `scripts/hydrated-interop.mjs`. Test fixtures live with the tests that use them. Do not add empty packages for planned components. Move imports, tests, manifests, build/release tooling and documentation together whenever a component moves. See [RELEASING.md](RELEASING.md) for the exported coordinates. diff --git a/conformance/interop/hydrated/main.go b/conformance/interop/hydrated/main.go index ef4b9ab..c15f26a 100644 --- a/conformance/interop/hydrated/main.go +++ b/conformance/interop/hydrated/main.go @@ -4,12 +4,12 @@ // hydrated serve listen at participant [s]; on connection, expose an echo // endpoint and send its reference payload at ["bootstrap"]; // print the URL; exit when stdin closes -// hydrated client participant [c]: call "continue" with a reply Wire, send a -// third Wire through the returned continuation, print "ok" -// when the third Wire receives the value +// hydrated client participant [c]: call "continue" with a reply HydratedWire, send a +// third HydratedWire through the returned continuation, print "ok" +// when the third HydratedWire receives the value // // The echo: (op, arg, reply) replies (arg, continuation); the continuation sends -// the value it receives to the Wire it carries, then closes. +// the value it receives to the HydratedWire it carries, then closes. package main import ( @@ -60,16 +60,16 @@ func participant(link wire.AddressedEndpoint, path wire.Path, onBootstrap func(o return scope } -func echo(v hydrated.Value, _ hydrated.Context) { +func echo(v hydrated.HydratedValue, _ hydrated.ReceivedContext) { xs, _ := hydrated.Items(v) cont := hydrated.NewEndpoint(1) - _, _ = cont.Receive(func(next hydrated.Value, _ hydrated.Context) { + _, _ = cont.Receive(func(next hydrated.HydratedValue, _ hydrated.ReceivedContext) { ys, _ := hydrated.Items(next) - _ = ys[1].(hydrated.Wire).Send(ys[0]) + _ = ys[1].(hydrated.HydratedWire).Send(ys[0]) _ = cont.Close() }) r, _ := hydrated.NewTuple(xs[1], cont) - _ = xs[2].(hydrated.Wire).Send(r) + _ = xs[2].(hydrated.HydratedWire).Send(r) } func serve() { @@ -114,13 +114,13 @@ func client(url string) { fail(err) } reply := hydrated.NewEndpoint(1) - replies := make(chan hydrated.Value, 1) - _, _ = reply.Receive(func(v hydrated.Value, _ hydrated.Context) { replies <- v }) + replies := make(chan hydrated.HydratedValue, 1) + _, _ = reply.Receive(func(v hydrated.HydratedValue, _ hydrated.ReceivedContext) { replies <- v }) req, _ := hydrated.NewTuple(text("continue"), text("hello"), reply) if err := scope.Connect(ref).Send(req); err != nil { fail(err) } - var r hydrated.Value + var r hydrated.HydratedValue select { case r = <-replies: case <-time.After(10 * time.Second): @@ -131,19 +131,19 @@ func client(url string) { fail(fmt.Errorf("wrong reply")) } third := hydrated.NewEndpoint(1) - thirds := make(chan hydrated.Value, 1) - _, _ = third.Receive(func(v hydrated.Value, _ hydrated.Context) { thirds <- v }) - next, _ := hydrated.NewTuple(text("third Wire"), third) - if err := xs[1].(hydrated.Wire).Send(next); err != nil { + thirds := make(chan hydrated.HydratedValue, 1) + _, _ = third.Receive(func(v hydrated.HydratedValue, _ hydrated.ReceivedContext) { thirds <- v }) + next, _ := hydrated.NewTuple(text("third HydratedWire"), third) + if err := xs[1].(hydrated.HydratedWire).Send(next); err != nil { fail(err) } select { case v := <-thirds: - if !v.(ontos.Atom).Equal(text("third Wire")) { + if !v.(ontos.Atom).Equal(text("third HydratedWire")) { fail(fmt.Errorf("wrong third value")) } case <-time.After(10 * time.Second): - fail(fmt.Errorf("third Wire not reached")) + fail(fmt.Errorf("third HydratedWire not reached")) } fmt.Println("ok") _ = e.Close() diff --git a/hydrated/README.md b/hydrated/README.md index bf71b0e..844f12e 100644 --- a/hydrated/README.md +++ b/hydrated/README.md @@ -1,20 +1,35 @@ -# Hydrated wires (evidence for bitwire decision 0019) +# Hydrated wires -**Status, 7 October 2026:** evidence for the proposed -[bitwire decision 0019](https://github.com/Bitspark/bitwire/pull/83), the first -edition of the hydrated wire protocol. This is not a released API, and this -branch is not proposed for merge until the record is accepted. The -[package](go/hydrated.go) realizes the record's rules in Go. [Its tests](go/hydrated_test.go) -run the record's observations 1-15 and 17 around an opaque middle router, over local -pairs and WebSocket where the carrier matters. +**Status, 7 October 2026:** the realization of bitwire decision 0019, the hydrated +wire protocol's first edition, accepted at bitwire `5b1de82`. It is proposed for +the next bitruntime release. Before release it must import bitwire's public +hydrated declarations and its shared pure codec instead of the private types +and grammar traversal here, and keep every observation below green. -The [TypeScript mirror](ts/src/index.ts) repeats observations 1-4, 6-10, 15 and 17 -([tests](ts/test/hydrated.test.mjs)), with 2, 3, 4 and 7 over both carriers. -[scripts/hydrated-interop.mjs](../scripts/hydrated-interop.mjs) runs observation -14 over a real WebSocket in both roles, Go serving TypeScript and TypeScript -serving Go. Both peers build from this repository, not from fresh published -dependencies, so they show the grammar interoperates, not package delivery. +[The Go package](go/hydrated.go) and [the TypeScript module](ts/src/index.ts) +hold the stateful half of the protocol: +- scopes with random tokens and 16-octet unpredictable export ids; +- frames sent to owner paths; +- proxies recognized throughout the namespace; +- exports bound to a local Endpoint's lifetime; +- liveness judged on send; +- received context delivered beside the value; +- one counting domain for bounds, in both directions; +- Endpoint termination, reported as bitwire's `Termination`. -The record's vectors are copied from bitwire#83 (accepted, at 6e33fb3) into +[The Go tests](go/hydrated_test.go) run the record's observations 1-15 and 17 +around an opaque middle router, over local pairs and WebSocket where the carrier +matters. The [TypeScript tests](ts/test/hydrated.test.mjs) repeat observations +1-4, 6-10, 15 and 17. [scripts/hydrated-interop.mjs](../scripts/hydrated-interop.mjs) +runs observation 16 over a real WebSocket in both roles: Go serving TypeScript, +and TypeScript serving Go. The peers build from this repository; published +packages are qualified at release. + +[The composition suite](ts/test/COMPOSITION.md) is Fiber Composition's +consumer conformance, accepted at `4217f1b`. It covers Cell, Text and Counter +adapters, nested cells, lazy reads, both write directions, failures and bounded +reply lifetimes, over both carriers. + +The record's vectors are copied from bitwire `5b1de82` into [testdata](go/testdata/hydrated-vectors.json), pinned by sha256; both languages replay them. diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index b2b63ee..2bebc40 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -19,16 +19,16 @@ import ( wire "github.com/Bitspark/bitwire/wire/go" ) -// Value is a hydrated value: a ground ontos value, a Tuple or a Wire. -type Value any +// HydratedValue is a hydrated value: a ground ontos value, a Tuple or a HydratedWire. +type HydratedValue any -// Wire sends one hydrated value. Only a local Endpoint's sending face or a proxy +// HydratedWire sends one hydrated value. Only a local Endpoint's sending face or a proxy // imported in the same namespace can travel inside a hydrated value (D7). -type Wire interface{ Send(Value) error } +type HydratedWire interface{ Send(HydratedValue) error } -// Context is what the composition establishes about an arrival, for example a +// ReceivedContext is what the composition establishes about an arrival, for example a // checked origin. It is never part of a value (D4). -type Context any +type ReceivedContext any // Local is the context of a send that never left the process. type Local struct{} @@ -56,15 +56,15 @@ var ( wireTag = ontos.NewAtom([]byte{2}) ) -// Tuple is a hydrated tuple with at least one Wire beneath it. NewTuple returns +// Tuple is a hydrated tuple with at least one HydratedWire beneath it. NewTuple returns // the ground tuple when there is none (D1). -type Tuple struct{ items []Value } +type Tuple struct{ items []HydratedValue } -// NewTuple captures items. Each must be an ontos value, a Tuple or a Wire. An +// NewTuple captures items. Each must be an ontos value, a Tuple or a HydratedWire. An // Endpoint is captured as its sending face: a value never carries receive or // close authority, on any path (D7). -func NewTuple(items ...Value) (Value, error) { - captured := make([]Value, len(items)) +func NewTuple(items ...HydratedValue) (HydratedValue, error) { + captured := make([]HydratedValue, len(items)) for i, item := range items { captured[i] = sendOnly(item) } @@ -76,7 +76,7 @@ func NewTuple(items ...Value) (Value, error) { ground = append(ground, x) case ontos.Tuple: ground = append(ground, x) - case Tuple, Wire: + case Tuple, HydratedWire: live = true default: return nil, errors.New("hydrated: not a value") @@ -90,7 +90,7 @@ func NewTuple(items ...Value) (Value, error) { // sendOnly projects an Endpoint to its sending face, and the zero Tuple, the // only one NewTuple does not make, to the ground empty tuple (D1, D7). -func sendOnly(v Value) Value { +func sendOnly(v HydratedValue) HydratedValue { switch x := v.(type) { case *Endpoint: return x.face @@ -103,15 +103,15 @@ func sendOnly(v Value) Value { } // Items returns the tuple's items. -func (t Tuple) Items() []Value { return append([]Value(nil), t.items...) } +func (t Tuple) Items() []HydratedValue { return append([]HydratedValue(nil), t.items...) } // Items returns any tuple's items, ground or hydrated. -func Items(v Value) ([]Value, bool) { +func Items(v HydratedValue) ([]HydratedValue, bool) { switch x := v.(type) { case Tuple: return x.Items(), true case ontos.Tuple: - out := make([]Value, x.Len()) + out := make([]HydratedValue, x.Len()) for i, item := range x.Items() { out[i] = item } @@ -127,7 +127,7 @@ type Namespace struct{ name string } // NewNamespace creates a namespace. func NewNamespace(name string) *Namespace { return &Namespace{name} } -// Reference is the ground form of a live Wire (D2, D3). +// Reference is the ground form of a live HydratedWire (D2, D3). type Reference struct { Path wire.Path Scope, ID ontos.Atom @@ -165,7 +165,7 @@ type Endpoint struct { mu sync.Mutex queue []delivery limit int - handler func(Value, Context) + handler func(HydratedValue, ReceivedContext) gen uint64 // receiver generation: a detach removes only its own receiver failure error // set when a receiver failed and so terminated the endpoint closed bool @@ -176,14 +176,14 @@ type Endpoint struct { } type delivery struct { - value Value - context Context + value HydratedValue + context ReceivedContext } // face is an Endpoint's sending face. It grants sending only. type face struct{ e *Endpoint } -func (f *face) Send(v Value) error { return f.e.admit(sendOnly(v), Local{}) } +func (f *face) Send(v HydratedValue) error { return f.e.admit(sendOnly(v), Local{}) } // NewEndpoint creates an endpoint whose queue admits at most limit values. func NewEndpoint(limit int) *Endpoint { @@ -196,15 +196,15 @@ func NewEndpoint(limit int) *Endpoint { return e } -// Wire is the endpoint's sending face. -func (e *Endpoint) Wire() Wire { return e.face } +// HydratedWire is the endpoint's sending face. +func (e *Endpoint) Wire() HydratedWire { return e.face } // Send admits a value locally, as its sending face does. -func (e *Endpoint) Send(v Value) error { return e.face.Send(v) } +func (e *Endpoint) Send(v HydratedValue) error { return e.face.Send(v) } // Receive attaches the one receiver. Values are dispatched in admission order, // never inline with the admitting send. -func (e *Endpoint) Receive(handler func(Value, Context)) (detach func(), err error) { +func (e *Endpoint) Receive(handler func(HydratedValue, ReceivedContext)) (detach func(), err error) { e.mu.Lock() defer e.mu.Unlock() if e.handler != nil { @@ -223,11 +223,19 @@ func (e *Endpoint) Receive(handler func(Value, Context)) (detach func(), err err }, nil } -// Failure reports the receiver failure that terminated the endpoint, if any. -func (e *Endpoint) Failure() error { +// Termination reports how the endpoint ended, as bitwire's Endpoint contract +// does: "failed" with the receiver's failure, or "closed". It is the zero value +// while the endpoint is open, and stable once Closed is closed. +func (e *Endpoint) Termination() wire.Termination { e.mu.Lock() defer e.mu.Unlock() - return e.failure + switch { + case e.failure != nil: + return wire.Termination{Kind: "failed", Message: e.failure.Error()} + case e.closed: + return wire.Termination{Kind: "closed"} + } + return wire.Termination{} } // Closed is closed when the endpoint closes. @@ -252,7 +260,7 @@ func (e *Endpoint) Close() error { return nil } -func (e *Endpoint) admit(v Value, ctx Context) error { +func (e *Endpoint) admit(v HydratedValue, ctx ReceivedContext) error { e.mu.Lock() defer e.mu.Unlock() if e.closed || len(e.queue) >= e.limit { @@ -298,7 +306,7 @@ func (e *Endpoint) dispatch() { } } -func deliverTo(h func(Value, Context), d delivery) (err error) { +func deliverTo(h func(HydratedValue, ReceivedContext), d delivery) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("hydrated: receiver failed: %v", r) @@ -331,7 +339,7 @@ func (e *Endpoint) isClosed() bool { return e.closed } -// proxy is an imported Wire. It carries its reference and namespace, so any +// proxy is an imported HydratedWire. It carries its reference and namespace, so any // participant in the namespace forwards it unchanged (D6). A proxy with a dead // reason is this participant's own stale or withdrawn reference: its sends are // refused locally (D7, returning home). @@ -342,7 +350,7 @@ type proxy struct { dead Refusal } -func (p *proxy) Send(v Value) error { +func (p *proxy) Send(v HydratedValue) error { if p.dead != "" { return p.dead } @@ -412,7 +420,7 @@ func (s *Scope) Expose(e *Endpoint) (Reference, error) { } // Connect returns a proxy for a reference, for composition bootstrap. -func (s *Scope) Connect(r Reference) Wire { return s.importRef(r, map[string]Wire{}) } +func (s *Scope) Connect(r Reference) HydratedWire { return s.importRef(r, map[string]HydratedWire{}) } // Close ends the scope: its exports end and its references go stale. It closes // no domain endpoint. @@ -502,8 +510,8 @@ func (s *Scope) commit(staged map[*Endpoint]ontos.Atom) error { type budget struct{ nodes, bytes int } // The node, depth and byte bounds count the hydrated value, identically when -// sending and receiving (D8): each atom, tuple and Wire leaf is one node at its -// tuple depth; bytes are atom bytes plus each Wire leaf's reference material. +// sending and receiving (D8): each atom, tuple and HydratedWire leaf is one node at its +// tuple depth; bytes are atom bytes plus each HydratedWire leaf's reference material. func refBytes(path wire.Path) int { n := 32 // scope token and export id for _, a := range path { @@ -523,7 +531,7 @@ func (s *Scope) spend(b *budget, depth, n int) error { // send encodes v atomically and registers its new exports in one step per // scope, then admits one frame to the owner path (D5). -func (s *Scope) send(dest Reference, v Value) error { +func (s *Scope) send(dest Reference, v HydratedValue) error { s.mu.Lock() closed := s.closed s.mu.Unlock() @@ -537,7 +545,7 @@ func (s *Scope) send(dest Reference, v Value) error { return s.sender.Send(dest.Path, frame) } -func (s *Scope) stage(dest Reference, v Value) (ontos.Value, error) { +func (s *Scope) stage(dest Reference, v HydratedValue) (ontos.Value, error) { s.encodeMu.Lock() defer s.encodeMu.Unlock() staged := map[*Endpoint]ontos.Atom{} @@ -552,7 +560,7 @@ func (s *Scope) stage(dest Reference, v Value) (ontos.Value, error) { return frame, s.commit(staged) } -func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (ontos.Value, error) { +func (s *Scope) encode(v HydratedValue, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (ontos.Value, error) { switch x := v.(type) { case ontos.Atom: return x, s.spend(b, depth, x.Len()) @@ -585,7 +593,7 @@ func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *b return nil, ForeignNamespace } return x.ref.value(), s.spend(b, depth, refBytes(x.ref.Path)) - case Wire: + case HydratedWire: return nil, Unexportable } return nil, errors.New("hydrated: not a value") @@ -593,7 +601,7 @@ func (s *Scope) encode(v Value, depth int, staged map[*Endpoint]ontos.Atom, b *b // Deliver decides one value addressed to this participant's own path (D4). The // returned refusal is a host diagnostic; nothing is sent back. -func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx Context) error { +func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx ReceivedContext) error { if !wire.PathEqual(path, s.path) { return MalformedFrame } @@ -624,7 +632,7 @@ func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx Context) error if target == nil { return UnknownExport } - value, err := s.decode(f.At(3), 0, map[string]Wire{}, &budget{}) + value, err := s.decode(f.At(3), 0, map[string]HydratedWire{}, &budget{}) if err != nil { return err } @@ -633,7 +641,7 @@ func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx Context) error // decode reads a body. Occurrences of one reference within the value share one // proxy; nothing is retained across messages (D7). -func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *budget) (Value, error) { +func (s *Scope) decode(v ontos.Value, depth int, interned map[string]HydratedWire, b *budget) (HydratedValue, error) { if a, ok := v.(ontos.Atom); ok { return a, s.spend(b, depth, a.Len()) } @@ -654,7 +662,7 @@ func (s *Scope) decode(v ontos.Value, depth int, interned map[string]Wire, b *bu if err := s.spend(b, depth, 0); err != nil { return nil, err } - items := make([]Value, children.Len()) + items := make([]HydratedValue, children.Len()) for i, c := range children.Items() { var err error if items[i], err = s.decode(c, depth+1, interned, b); err != nil { @@ -702,10 +710,10 @@ func readReference(v ontos.Value) (Reference, error) { return Reference{path, scope, id}, nil } -// importRef turns a reference into a Wire. A reference to this participant in +// importRef turns a reference into a HydratedWire. A reference to this participant in // its current scope returns home to the original face; a stale or withdrawn one -// becomes a Wire whose sends are refused. Liveness is judged on send (D7). -func (s *Scope) importRef(r Reference, interned map[string]Wire) Wire { +// becomes a HydratedWire whose sends are refused. Liveness is judged on send (D7). +func (s *Scope) importRef(r Reference, interned map[string]HydratedWire) HydratedWire { if wire.PathEqual(r.Path, s.path) { if !r.Scope.Equal(s.token) { return &proxy{ref: r, ns: s.ns, via: s, dead: StaleScope} diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index e9e6848..07ddcd8 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -29,7 +29,7 @@ func (e *Endpoint) pending() int { return len(e.queue) } -func tuple(t *testing.T, items ...Value) Value { +func tuple(t *testing.T, items ...HydratedValue) HydratedValue { t.Helper() v, err := NewTuple(items...) if err != nil { @@ -38,7 +38,7 @@ func tuple(t *testing.T, items ...Value) Value { return v } -func items(t *testing.T, v Value) []Value { +func items(t *testing.T, v HydratedValue) []HydratedValue { t.Helper() xs, ok := Items(v) if !ok { @@ -78,9 +78,9 @@ func ground(v jsonValue) ontos.Value { return ontos.NewTuple(out...) } -func wires(v Value) int { +func wires(v HydratedValue) int { switch x := v.(type) { - case Wire: + case HydratedWire: return 1 case Tuple: n := 0 @@ -117,7 +117,7 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { s, _ := NewScope(NewNamespace("vectors"), wire.Path{text("vectors")}, nil, DefaultLimits) for _, c := range v.Encode { body := ground(c.Body) - live, err := s.decode(body, 0, map[string]Wire{}, &budget{}) + live, err := s.decode(body, 0, map[string]HydratedWire{}, &budget{}) if err != nil { t.Fatalf("%s: %v", c.Name, err) } @@ -134,7 +134,7 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { } } for _, c := range v.RejectBody { - if _, err := s.decode(ground(c.Value), 0, map[string]Wire{}, &budget{}); !errors.Is(err, MalformedFrame) { + if _, err := s.decode(ground(c.Value), 0, map[string]HydratedWire{}, &budget{}); !errors.Is(err, MalformedFrame) { t.Fatalf("%s: %v", c.Name, err) } } @@ -287,7 +287,7 @@ func (tr *tree) replace(t *testing.T, name string) *Scope { } // serve exposes a domain endpoint at a participant: the composition bootstrap. -func serve(t *testing.T, s *Scope, handler func(Value, Context)) (*Endpoint, Reference) { +func serve(t *testing.T, s *Scope, handler func(HydratedValue, ReceivedContext)) (*Endpoint, Reference) { e := NewEndpoint(64) if _, err := e.Receive(handler); err != nil { t.Fatal(err) @@ -301,21 +301,21 @@ func serve(t *testing.T, s *Scope, handler func(Value, Context)) (*Endpoint, Ref } type received struct { - value Value - context Context + value HydratedValue + context ReceivedContext } // call is a domain adapter: it sends (op, args..., reply) and closes its reply // endpoint after the reply. It knows no reference, table or route. -func call(t *testing.T, target Wire, op string, args ...Value) received { +func call(t *testing.T, target HydratedWire, op string, args ...HydratedValue) received { t.Helper() reply := NewEndpoint(1) defer reply.Close() got := make(chan received, 1) - if _, err := reply.Receive(func(v Value, c Context) { got <- received{v, c} }); err != nil { + if _, err := reply.Receive(func(v HydratedValue, c ReceivedContext) { got <- received{v, c} }); err != nil { t.Fatal(err) } - if err := target.Send(tuple(t, append(append([]Value{text(op)}, args...), reply)...)); err != nil { + if err := target.Send(tuple(t, append(append([]HydratedValue{text(op)}, args...), reply)...)); err != nil { t.Fatal(err) } select { @@ -329,19 +329,19 @@ func call(t *testing.T, target Wire, op string, args ...Value) received { // echo is a domain provider: (op, arg, reply). "echo" replies (arg); "continue" // replies (arg, continuation), where the continuation forwards one value to the -// Wire it carries and then closes. -func echo(t *testing.T) func(Value, Context) { - return func(v Value, _ Context) { +// HydratedWire it carries and then closes. +func echo(t *testing.T) func(HydratedValue, ReceivedContext) { + return func(v HydratedValue, _ ReceivedContext) { xs, _ := Items(v) - op, arg, reply := xs[0].(ontos.Atom), xs[1], xs[len(xs)-1].(Wire) + op, arg, reply := xs[0].(ontos.Atom), xs[1], xs[len(xs)-1].(HydratedWire) switch string(op.Bytes()) { case "echo": _ = reply.Send(arg) case "continue": cont := NewEndpoint(1) - _, _ = cont.Receive(func(next Value, _ Context) { + _, _ = cont.Receive(func(next HydratedValue, _ ReceivedContext) { ys, _ := Items(next) - _ = ys[1].(Wire).Send(ys[0]) + _ = ys[1].(HydratedWire).Send(ys[0]) _ = cont.Close() }) r, _ := NewTuple(arg, cont) @@ -367,18 +367,18 @@ func TestObservation2RecursionThroughAnOpaqueRouter(t *testing.T) { t.Fatal("wrong reply") } third := NewEndpoint(1) - got := make(chan Value, 1) - _, _ = third.Receive(func(v Value, _ Context) { got <- v }) - if err := xs[1].(Wire).Send(tuple(t, text("third Wire"), third)); err != nil { + got := make(chan HydratedValue, 1) + _, _ = third.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) + if err := xs[1].(HydratedWire).Send(tuple(t, text("third HydratedWire"), third)); err != nil { t.Fatal(err) } select { case v := <-got: - if !v.(ontos.Atom).Equal(text("third Wire")) { + if !v.(ontos.Atom).Equal(text("third HydratedWire")) { t.Fatal("wrong third value") } case <-time.After(5 * time.Second): - t.Fatal("third Wire not reached") + t.Fatal("third HydratedWire not reached") } if n := tr.frames(); n != 4 { t.Fatalf("%d frames crossed the middle, want 4", n) @@ -416,22 +416,22 @@ func TestObservation4ASharedWireEndsOnlyWithItsOwner(t *testing.T) { tr := newTree(t, carrier, "client", "provider", "third") a, b, c := tr.parts["client"].scope(), tr.parts["provider"].scope(), tr.parts["third"].scope() var mu sync.Mutex - var atB, atC Wire - _, refC := serve(t, c, func(v Value, _ Context) { mu.Lock(); atC = v.(Wire); mu.Unlock() }) + var atB, atC HydratedWire + _, refC := serve(t, c, func(v HydratedValue, _ ReceivedContext) { mu.Lock(); atC = v.(HydratedWire); mu.Unlock() }) toC := b.Connect(refC) - _, refB := serve(t, b, func(v Value, _ Context) { + _, refB := serve(t, b, func(v HydratedValue, _ ReceivedContext) { mu.Lock() - atB = v.(Wire) + atB = v.(HydratedWire) mu.Unlock() - _ = toC.Send(v) // B forwards the Wire it received + _ = toC.Send(v) // B forwards the HydratedWire it received }) w := NewEndpoint(8) - var seen []Context - _, _ = w.Receive(func(_ Value, ctx Context) { mu.Lock(); seen = append(seen, ctx); mu.Unlock() }) + var seen []ReceivedContext + _, _ = w.Receive(func(_ HydratedValue, ctx ReceivedContext) { mu.Lock(); seen = append(seen, ctx); mu.Unlock() }) if err := a.Connect(refB).Send(w); err != nil { t.Fatal(err) } - eventually(t, "C did not receive the Wire", func() bool { mu.Lock(); defer mu.Unlock(); return atC != nil }) + eventually(t, "C did not receive the HydratedWire", func() bool { mu.Lock(); defer mu.Unlock(); return atC != nil }) if b.Live() != 1 || c.Live() != 1 { t.Fatalf("forwarding registered state: B %d C %d", b.Live(), c.Live()) } @@ -455,7 +455,7 @@ func TestObservation4ASharedWireEndsOnlyWithItsOwner(t *testing.T) { mu.Lock() defer mu.Unlock() if len(seen) != 1 { - t.Fatal("a closed Wire received") + t.Fatal("a closed HydratedWire received") } }) } @@ -477,18 +477,18 @@ func TestObservation5NoProxyOfAProxy(t *testing.T) { } } -type funcWire func(Value) error +type funcWire func(HydratedValue) error -func (f funcWire) Send(v Value) error { return f(v) } +func (f funcWire) Send(v HydratedValue) error { return f(v) } func TestObservation6RefusalsBeforeAdmission(t *testing.T) { tr := newTree(t, "pair", "client", "provider") a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() - _, svc := serve(t, b, func(Value, Context) {}) + _, svc := serve(t, b, func(HydratedValue, ReceivedContext) {}) target := a.Connect(svc) kept := NewEndpoint(1) - if err := target.Send(tuple(t, kept, funcWire(func(Value) error { return nil }))); !errors.Is(err, Unexportable) { - t.Fatalf("arbitrary Wire: %v", err) + if err := target.Send(tuple(t, kept, funcWire(func(HydratedValue) error { return nil }))); !errors.Is(err, Unexportable) { + t.Fatalf("arbitrary HydratedWire: %v", err) } other, _ := NewScope(NewNamespace("other"), wire.Path{text("x")}, nil, DefaultLimits) foreign := other.Connect(Reference{wire.Path{text("y")}, ontos.NewAtom(make([]byte, 16)), text("1")}) @@ -508,12 +508,12 @@ func TestObservation6RefusalsBeforeAdmission(t *testing.T) { func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { carriers(t, func(t *testing.T, carrier string) { tr := newTree(t, carrier, "client", "provider") - _, svc := serve(t, tr.parts["provider"].scope(), func(Value, Context) {}) + _, svc := serve(t, tr.parts["provider"].scope(), func(HydratedValue, ReceivedContext) {}) old := tr.parts["client"].scope().Connect(svc) fresh := tr.replace(t, "provider") var mu sync.Mutex hit := false - _, _ = serve(t, fresh, func(Value, Context) { mu.Lock(); hit = true; mu.Unlock() }) + _, _ = serve(t, fresh, func(HydratedValue, ReceivedContext) { mu.Lock(); hit = true; mu.Unlock() }) _ = old.Send(text("stale")) eventually(t, "not refused stale-scope", func() bool { d := tr.diagnostics() @@ -530,19 +530,19 @@ func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { func TestObservation9ReturningHome(t *testing.T) { tr := newTree(t, "pair", "client", "provider") a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() - var back Wire + var back HydratedWire var mu sync.Mutex proceed := make(chan struct{}) - _, svc := serve(t, b, func(v Value, _ Context) { + _, svc := serve(t, b, func(v HydratedValue, _ ReceivedContext) { <-proceed xs, _ := Items(v) r, _ := NewTuple(text("data"), xs[0], xs[1]) // send both Wires home - _ = xs[2].(Wire).Send(r) + _ = xs[2].(HydratedWire).Send(r) }) home, gone := NewEndpoint(1), NewEndpoint(1) reply := NewEndpoint(1) - got := make(chan Value, 1) - _, _ = reply.Receive(func(v Value, _ Context) { got <- v }) + got := make(chan HydratedValue, 1) + _, _ = reply.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) if err := a.Connect(svc).Send(tuple(t, home, gone, reply)); err != nil { t.Fatal(err) } @@ -554,7 +554,7 @@ func TestObservation9ReturningHome(t *testing.T) { case v := <-got: xs := items(t, v) mu.Lock() - back = xs[1].(Wire) + back = xs[1].(HydratedWire) mu.Unlock() if !xs[0].(ontos.Atom).Equal(text("data")) { t.Fatal("the rest of the message was lost") @@ -562,7 +562,7 @@ func TestObservation9ReturningHome(t *testing.T) { if back != home.Wire() { t.Fatal("own reference did not return to the original face") } - if err := xs[2].(Wire).Send(text("x")); !errors.Is(err, UnknownExport) { + if err := xs[2].(HydratedWire).Send(text("x")); !errors.Is(err, UnknownExport) { t.Fatalf("a withdrawn own reference: %v", err) } case <-time.After(5 * time.Second): @@ -573,7 +573,7 @@ func TestObservation9ReturningHome(t *testing.T) { func TestObservation10ReceivedContextIsNotData(t *testing.T) { tr := newTree(t, "pair", "client", "provider") got := make(chan received, 1) - _, svc := serve(t, tr.parts["provider"].scope(), func(v Value, c Context) { got <- received{v, c} }) + _, svc := serve(t, tr.parts["provider"].scope(), func(v HydratedValue, c ReceivedContext) { got <- received{v, c} }) forged := ontos.NewTuple(text("context"), text("client-link")) if err := tr.parts["client"].scope().Connect(svc).Send(forged); err != nil { t.Fatal(err) @@ -589,7 +589,7 @@ func TestObservation11AtomicDecoding(t *testing.T) { b := tr.parts["provider"].scope() var mu sync.Mutex delivered := 0 - _, svc := serve(t, b, func(Value, Context) { mu.Lock(); delivered++; mu.Unlock() }) + _, svc := serve(t, b, func(HydratedValue, ReceivedContext) { mu.Lock(); delivered++; mu.Unlock() }) w := NewEndpoint(1) ref, _ := tr.parts["client"].scope().Expose(w) bad := ontos.NewTuple(tupleTag, ontos.NewTuple(ref.value(), ontos.NewTuple(tupleTag))) @@ -632,7 +632,7 @@ func TestObservation14Ordering(t *testing.T) { tr := newTree(t, carrier, "client", "provider") var mu sync.Mutex var seen []string - _, svc := serve(t, tr.parts["provider"].scope(), func(v Value, _ Context) { + _, svc := serve(t, tr.parts["provider"].scope(), func(v HydratedValue, _ ReceivedContext) { mu.Lock() seen = append(seen, string(v.(ontos.Atom).Bytes())) mu.Unlock() @@ -655,15 +655,15 @@ func TestObservation14Ordering(t *testing.T) { func TestObservation15Bounds(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, Limits{Exports: 2, Nodes: 8, Depth: 3, Bytes: 64}) target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) - deep := Value(text("x")) + deep := HydratedValue(text("x")) for i := 0; i < 5; i++ { deep = tuple(t, deep) } - wide := make([]Value, 9) + wide := make([]HydratedValue, 9) for i := range wide { wide[i] = text("y") } - for name, v := range map[string]Value{ + for name, v := range map[string]HydratedValue{ "depth": deep, "nodes": tuple(t, wide...), "bytes": ontos.NewAtom(make([]byte, 65)), @@ -689,7 +689,7 @@ func TestObservation15Bounds(t *testing.T) { }) } -// Observation 8: one reference grants its own Wire and no sibling, including an +// Observation 8: one reference grants its own HydratedWire and no sibling, including an // endpoint the owner exported for another holder. func TestObservation8SiblingForgery(t *testing.T) { carriers(t, func(t *testing.T, carrier string) { @@ -697,8 +697,8 @@ func TestObservation8SiblingForgery(t *testing.T) { provider := tr.parts["provider"].scope() var mu sync.Mutex private := 0 - _, public := serve(t, provider, func(Value, Context) {}) - _, intended := serve(t, provider, func(Value, Context) { mu.Lock(); private++; mu.Unlock() }) + _, public := serve(t, provider, func(HydratedValue, ReceivedContext) {}) + _, intended := serve(t, provider, func(HydratedValue, ReceivedContext) { mu.Lock(); private++; mu.Unlock() }) // The private endpoint really is held by another participant. if err := tr.parts["third"].scope().Connect(intended).Send(text("legitimate")); err != nil { t.Fatal(err) @@ -814,9 +814,9 @@ func TestObservation15IncomingFrameBytes(t *testing.T) { // the sending face; a stale detach removes only its own receiver; a receiver's // failure terminates its endpoint, which ends its export. func TestObservation17EndpointLaws(t *testing.T) { - got := make(chan Value, 2) + got := make(chan HydratedValue, 2) local := NewEndpoint(4) - if _, err := local.Receive(func(v Value, _ Context) { got <- v }); err != nil { + if _, err := local.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }); err != nil { t.Fatal(err) } private := NewEndpoint(1) @@ -830,10 +830,10 @@ func TestObservation17EndpointLaws(t *testing.T) { } e := NewEndpoint(4) - stale, _ := e.Receive(func(Value, Context) {}) + stale, _ := e.Receive(func(HydratedValue, ReceivedContext) {}) stale() - seen := make(chan Value, 1) - if _, err := e.Receive(func(v Value, _ Context) { seen <- v }); err != nil { + seen := make(chan HydratedValue, 1) + if _, err := e.Receive(func(v HydratedValue, _ ReceivedContext) { seen <- v }); err != nil { t.Fatal(err) } stale() @@ -846,7 +846,7 @@ func TestObservation17EndpointLaws(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, DefaultLimits) failing := NewEndpoint(4) - if _, err := failing.Receive(func(Value, Context) { panic("receiver fault") }); err != nil { + if _, err := failing.Receive(func(HydratedValue, ReceivedContext) { panic("receiver fault") }); err != nil { t.Fatal(err) } ref, _ := s.Expose(failing) @@ -854,22 +854,31 @@ func TestObservation17EndpointLaws(t *testing.T) { t.Fatal(err) } eventually(t, "the failing receiver did not terminate its endpoint", func() bool { - return failing.Failure() != nil && s.Live() == 0 + return failing.Termination().Kind == "failed" && s.Live() == 0 }) if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("y")), nil); !errors.Is(err, UnknownExport) { t.Fatalf("after termination: %v", err) } + quiet := NewEndpoint(1) + if quiet.Termination() != (wire.Termination{}) { + t.Fatal("an open endpoint reports a termination") + } + _ = quiet.Close() + <-quiet.Closed() + if quiet.Termination().Kind != "closed" { + t.Fatalf("a closed endpoint reports %+v", quiet.Termination()) + } } // Observation 15, symmetry: under the same bounds, the sender accepts a value // exactly when the receiver does, at tight node, depth and byte budgets. func TestObservation15SenderAndReceiverCountAlike(t *testing.T) { face := NewEndpoint(1) - nested := Value(text("x")) + nested := HydratedValue(text("x")) for i := 0; i < 3; i++ { nested = tuple(t, nested, face) } - values := map[string]Value{ + values := map[string]HydratedValue{ "one face three times": tuple(t, face, face, face), "nested faces": nested, "a long atom": ontos.NewAtom(make([]byte, 96)), @@ -923,8 +932,8 @@ func TestObservation15LongReferencePath(t *testing.T) { limits := Limits{Exports: 4, Nodes: 8, Depth: 2, Bytes: 4096} receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) target := NewEndpoint(4) - got := make(chan Value, 1) - _, _ = target.Receive(func(v Value, _ Context) { got <- v }) + got := make(chan HydratedValue, 1) + _, _ = target.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) ref, _ := receiver.Expose(target) long := wire.Path{text("a"), text("b"), text("c"), text("d"), text("e")} far := Reference{long, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} @@ -940,9 +949,9 @@ func TestObservation15LongReferencePath(t *testing.T) { // Observation 17, construction: the zero Tuple, the only one NewTuple does not // make, arrives as the ground empty tuple. func TestObservation17ZeroTupleIsGround(t *testing.T) { - got := make(chan Value, 1) + got := make(chan HydratedValue, 1) local := NewEndpoint(1) - _, _ = local.Receive(func(v Value, _ Context) { got <- v }) + _, _ = local.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) _ = local.Wire().Send(Tuple{}) if v, ok := (<-got).(ontos.Tuple); !ok || v.Len() != 0 { t.Fatal("the zero hydrated tuple was not delivered as the ground empty tuple") diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index 4084213..b4c2274 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -2,16 +2,16 @@ // hydrated wire protocol, bitwire/hydrated/1, realized in TypeScript. It mirrors // hydrated/go and is not a released API. import { Atom, Tuple, atom, tuple, encodeMessage, pathEqual } from '@bitspark/bitwire'; -import type { Path, AddressedWire } from '@bitspark/bitwire'; +import type { Path, AddressedWire, Termination } from '@bitspark/bitwire'; -/** A hydrated value: a ground ontos value, a HydratedTuple or a Wire. */ -export type Value = Atom | Tuple | HydratedTuple | Wire; +/** A hydrated value: a ground ontos value, a HydratedTuple or a HydratedWire. */ +export type HydratedValue = Atom | Tuple | HydratedTuple | HydratedWire; /** Sends one hydrated value. Only an Endpoint's face or a proxy can travel (D7). */ -export interface Wire { send(value: Value): Promise } +export interface HydratedWire { readonly kind: 'wire'; send(value: HydratedValue): Promise } /** What the composition establishes about an arrival; never part of a value (D4). */ -export type Context = unknown; +export type ReceivedContext = unknown; /** The context of a send that never left the process. */ export const LOCAL: unique symbol = Symbol('local'); @@ -38,28 +38,35 @@ const HEADER = atom(new TextEncoder().encode('bitwire/hydrated/1')); const TUPLE_TAG = atom([1]); const WIRE_TAG = atom([2]); -/** A tuple with at least one Wire beneath it; see hydratedTuple (D1). */ +/** A tuple with at least one HydratedWire beneath it; see hydratedTuple (D1). */ /** Only this module constructs hydrated tuples, so every one is canonical (D1, D7). */ const CONSTRUCT = Symbol('hydrated tuple'); -/** A tuple with at least one Wire beneath it. Construct it with hydratedTuple(). */ +/** + * A tuple with at least one HydratedWire beneath it, shaped like bitwire's HydratedTuple + * (a ground Tuple has the same shape). Construct it with hydratedTuple(). + */ export class HydratedTuple { - readonly items: readonly Value[]; - constructor(items: readonly Value[], token?: symbol) { + readonly kind = 'tuple' as const; + readonly #items: readonly HydratedValue[]; + constructor(items: readonly HydratedValue[], token?: symbol) { if (token !== CONSTRUCT) throw new TypeError('hydrated: construct tuples with hydratedTuple()'); - this.items = Object.freeze([...items]); + this.#items = Object.freeze([...items]); Object.freeze(this); } + get length(): number { return this.#items.length; } + items(): readonly HydratedValue[] { return this.#items; } + at(index: number): HydratedValue | undefined { return this.#items.at(index); } } -// Any Wire may sit in a tuple; encoding refuses one the runtime does not own (D7). -const isWire = (v: unknown): v is Wire => typeof (v as Wire | undefined)?.send === 'function'; +// Any HydratedWire may sit in a tuple; encoding refuses one the runtime does not own (D7). +const isWire = (v: unknown): v is HydratedWire => typeof (v as HydratedWire | undefined)?.send === 'function'; /** An Endpoint travels only as its sending face, on every path (D7). */ -const sendOnly = (v: Value): Value => (v instanceof Endpoint ? v.wire : v); +const sendOnly = (v: HydratedValue): HydratedValue => (v instanceof Endpoint ? v.wire : v); -/** Captures items; returns the ground tuple when no Wire is beneath them. */ -export function hydratedTuple(items: readonly Value[]): Value { +/** Captures items; returns the ground tuple when no HydratedWire is beneath them. */ +export function hydratedTuple(items: readonly HydratedValue[]): HydratedValue { const captured = items.map(sendOnly); let live = false; for (const item of captured) { @@ -70,8 +77,8 @@ export function hydratedTuple(items: readonly Value[]): Value { } /** Any tuple's items, ground or hydrated. */ -export function items(v: Value): readonly Value[] | undefined { - if (v instanceof HydratedTuple) return v.items; +export function items(v: HydratedValue): readonly HydratedValue[] | undefined { + if (v instanceof HydratedTuple) return v.items(); if (v instanceof Tuple) return v.items(); return undefined; } @@ -82,7 +89,7 @@ export class Namespace { constructor(name: string) { this.name = name; } } -/** The ground form of a live Wire (D2, D3). */ +/** The ground form of a live HydratedWire (D2, D3). */ export interface Reference { readonly path: Path; readonly scope: Atom; readonly id: Atom } const hex = (a: Atom) => Array.from(a.bytes(), (b) => b.toString(16).padStart(2, '0')).join(''); @@ -94,17 +101,20 @@ const refValue = (r: Reference): Tuple => tuple([WIRE_TAG, tuple([tuple(r.path), * admitting send, a bounded queue and an owner lifetime. Its face is what a value * carries; closing it ends its export in every scope (D7). */ -export class Endpoint implements Wire { - readonly wire: Wire; - readonly closed: Promise; - #queue: { value: Value; context: Context }[] = []; +export class Endpoint implements HydratedWire { + readonly kind = 'wire' as const; + readonly wire: HydratedWire; + /** Resolves with how the endpoint ended, as bitwire's Endpoint contract does. */ + readonly closed: Promise; + #queue: { value: HydratedValue; context: ReceivedContext }[] = []; #limit: number; - #handler: ((value: Value, context: Context) => void) | undefined; + #handler: ((value: HydratedValue, context: ReceivedContext) => void) | undefined; #generation = 0; #failure: unknown; #isClosed = false; #draining = false; - #resolveClosed!: () => void; + #resolveClosed!: (termination: Termination) => void; + #termination: Termination | undefined; readonly #exports = new Map(); constructor(limit = 1) { @@ -113,10 +123,10 @@ export class Endpoint implements Wire { this.closed = new Promise((resolve) => { this.#resolveClosed = resolve; }); } - send(value: Value): Promise { return this.wire.send(value); } + send(value: HydratedValue): Promise { return this.wire.send(value); } /** Attaches the one receiver; its detach removes only that receiver. */ - receive(handler: (value: Value, context: Context) => void): () => void { + receive(handler: (value: HydratedValue, context: ReceivedContext) => void): () => void { if (this.#handler) throw new Error('hydrated: receiver already attached'); const generation = ++this.#generation; this.#handler = handler; @@ -124,8 +134,8 @@ export class Endpoint implements Wire { return () => { if (this.#generation === generation) this.#handler = undefined; }; } - /** The receiver failure that terminated the endpoint, if any. */ - get failure(): unknown { return this.#failure; } + /** How the endpoint ended: failed with its receiver's failure, or closed; undefined while open. */ + get termination(): Termination | undefined { return this.#termination; } async close(): Promise { if (this.#isClosed) return; @@ -134,13 +144,16 @@ export class Endpoint implements Wire { const exports = [...this.#exports]; this.#exports.clear(); for (const [scope, id] of exports) scope._withdraw(this, id); - this.#resolveClosed(); + this.#termination = this.#failure === undefined + ? Object.freeze({ kind: 'closed' }) + : Object.freeze({ kind: 'failed', message: String((this.#failure as Error)?.message ?? this.#failure) }); + this.#resolveClosed(this.#termination); } get isClosed(): boolean { return this.#isClosed; } /** @internal */ - _admit(value: Value, context: Context): void { + _admit(value: HydratedValue, context: ReceivedContext): void { if (this.#isClosed || this.#queue.length >= this.#limit) throw new Refusal(TARGET_REFUSED); this.#queue.push({ value, context }); this.#drain(); @@ -179,17 +192,19 @@ export class Endpoint implements Wire { const owners = new WeakMap(); /** An Endpoint's sending face. It grants sending only. */ -class Face implements Wire { +class Face implements HydratedWire { + readonly kind = 'wire' as const; constructor(endpoint: Endpoint) { owners.set(this, endpoint); Object.freeze(this); } - async send(value: Value): Promise { owners.get(this)!._admit(sendOnly(value), LOCAL); } + async send(value: HydratedValue): Promise { owners.get(this)!._admit(sendOnly(value), LOCAL); } } /** - * An imported Wire. It carries its reference and namespace, so any participant + * An imported HydratedWire. It carries its reference and namespace, so any participant * in the namespace forwards it unchanged (D6). A dead reason marks this * participant's own stale or withdrawn reference (D7, returning home). */ -class Proxy implements Wire { +class Proxy implements HydratedWire { + readonly kind = 'wire' as const; readonly ref: Reference; readonly ns: Namespace; readonly #via: Scope; @@ -198,7 +213,7 @@ class Proxy implements Wire { this.ref = ref; this.ns = ns; this.#via = via; this.#dead = dead; Object.freeze(this); } - async send(value: Value): Promise { + async send(value: HydratedValue): Promise { if (this.#dead) throw new Refusal(this.#dead); return this.#via._send(this.ref, value); } @@ -212,8 +227,8 @@ interface Budget { nodes: number; bytes: number } /** * The node, depth and byte bounds count the hydrated value, identically when - * sending and receiving (D8): each atom, tuple and Wire leaf is one node at its - * tuple depth; bytes are atom bytes plus each Wire leaf's reference material. + * sending and receiving (D8): each atom, tuple and HydratedWire leaf is one node at its + * tuple depth; bytes are atom bytes plus each HydratedWire leaf's reference material. */ const refBytes = (path: Path): number => path.reduce((n, a) => n + a.length, 32); @@ -248,7 +263,7 @@ export class Scope { } /** Composition bootstrap: a proxy for a reference. */ - connect(ref: Reference): Wire { return this.#import(ref, new Map()); } + connect(ref: Reference): HydratedWire { return this.#import(ref, new Map()); } /** Ends the scope: its exports end and its references go stale. Closes no endpoint. */ close(): void { @@ -300,7 +315,7 @@ export class Scope { /** Encodes atomically, registers new exports, then admits one frame (D5). */ /** @internal */ - async _send(dest: Reference, value: Value): Promise { + async _send(dest: Reference, value: HydratedValue): Promise { if (this.#closed) throw new Refusal(SCOPE_ENDED); const staged = new Map(); const body = this._encode(value, 0, staged, { nodes: 0, bytes: 0 }); @@ -311,7 +326,7 @@ export class Scope { } /** @internal */ - _encode(v: Value, depth: number, staged: Map, b: Budget): Atom | Tuple { + _encode(v: HydratedValue, depth: number, staged: Map, b: Budget): Atom | Tuple { if (v instanceof Atom) { this.#spend(b, depth, v.length); return v; } if (v instanceof Tuple || v instanceof HydratedTuple) { this.#spend(b, depth, 0); @@ -327,12 +342,12 @@ export class Scope { this.#spend(b, depth, refBytes(v.ref.path)); return refValue(v.ref); } - if (v && typeof (v as Wire).send === 'function') throw new Refusal(UNEXPORTABLE); + if (v && typeof (v as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); throw new TypeError('hydrated: not a value'); } /** Decides one value addressed to this participant's own path (D4); throws a Refusal as a host diagnostic. */ - deliver(path: Path, message: Atom | Tuple, context: Context): void { + deliver(path: Path, message: Atom | Tuple, context: ReceivedContext): void { if (!pathEqual(path, this.path)) throw new Refusal(MALFORMED_FRAME); if (this.#closed) throw new Refusal(SCOPE_ENDED); // The whole frame counts against the byte bound, reference material included (D8). @@ -349,7 +364,7 @@ export class Scope { /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). */ /** @internal */ - _decode(v: Atom | Tuple, depth: number, interned: Map, b: Budget): Value { + _decode(v: Atom | Tuple, depth: number, interned: Map, b: Budget): HydratedValue { if (v instanceof Atom) { this.#spend(b, depth, v.length); return v; } if (v.length !== 2) throw new Refusal(MALFORMED_FRAME); const [tag, payload] = v.items(); @@ -367,8 +382,8 @@ export class Scope { throw new Refusal(MALFORMED_FRAME); } - /** A reference home in the current scope returns the original face; stale or withdrawn, a Wire that refuses (D7). */ - #import(ref: Reference, interned: Map): Wire { + /** A reference home in the current scope returns the original face; stale or withdrawn, a HydratedWire that refuses (D7). */ + #import(ref: Reference, interned: Map): HydratedWire { if (pathEqual(ref.path, this.path)) { if (!ref.scope.equals(this.token)) return new Proxy(ref, this.ns, this, STALE_SCOPE); const endpoint = this.#byId.get(hex(ref.id)); diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index bc4190b..85c20c8 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -213,8 +213,13 @@ test('observation 17: Endpoint laws on every path', async () => { failing.receive(() => { throw new Error('receiver fault'); }); const ref = s.expose(failing); s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('x')]), undefined); - await eventually(() => failing.failure !== undefined && s.live === 0, 'the failing receiver did not terminate its endpoint'); + await eventually(() => failing.termination?.kind === 'failed' && s.live === 0, 'the failing receiver did not terminate its endpoint'); assert.throws(() => s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('y')]), undefined), (e) => e.reason === UNKNOWN_EXPORT); + assert.equal((await failing.closed).kind, 'failed'); + const quiet = new Endpoint(1); + assert.equal(quiet.termination, undefined); + await quiet.close(); + assert.deepEqual(await quiet.closed, { kind: 'closed' }); }); test('observation 15, symmetry: sender and receiver count a value alike', async () => { diff --git a/scripts/go-smoke.mjs b/scripts/go-smoke.mjs index ea04d9d..8dd55dc 100644 --- a/scripts/go-smoke.mjs +++ b/scripts/go-smoke.mjs @@ -1,5 +1,5 @@ -import {spawnSync} from 'node:child_process'; -import {mkdtempSync, writeFileSync, readFileSync} from 'node:fs'; +import {spawn, spawnSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, writeFileSync, readFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -19,4 +19,19 @@ writeFileSync(join(temp, 'main.go'), readFileSync(new URL('../conformance/intero run(['get', `github.com/Bitspark/bitruntime@${revision}`]); run(['mod', 'tidy']); run(['run', '.', 'smoke']); -console.log(`Installed public Go module at ${revision} in ${temp}`); + +// The hydrated peer from the public module: serve, then call with a reply Wire. +const hydrated = join(temp, 'hydrated'); +mkdirSync(hydrated); +writeFileSync(join(hydrated, 'main.go'), readFileSync(new URL('../conformance/interop/hydrated/main.go', import.meta.url))); +const binary = join(temp, process.platform === 'win32' ? 'hydrated.exe' : 'hydrated'); +run(['build', '-o', binary, './hydrated']); +const server = spawn(binary, ['serve'], {stdio: ['pipe', 'pipe', 'inherit']}); +const url = await new Promise((resolve, reject) => { + server.stdout.once('data', (d) => resolve(String(d).trim().split(/\s+/)[0])); + server.once('error', reject); +}); +const client = spawnSync(binary, ['client', url], {encoding: 'utf8', timeout: 30000}); +server.stdin.end(); +if (client.status !== 0 || client.stdout.trim() !== 'ok') throw new Error('hydrated Go peer failed: ' + client.stderr); +console.log(`Installed public Go module at ${revision} in ${temp}, including the hydrated peer`); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 1f0753e..e7852de 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -32,7 +32,7 @@ const packOutput = JSON.parse(run(npm, ['pack', '--json', '--pack-destination', // npm 12 keys results by package name; Node's bundled npm returns a list. const packed = Array.isArray(packOutput) ? packOutput : Object.values(packOutput); const files = packed[0].files.map((file) => file.path); -for (const required of ['LICENSE', 'NOTICE', 'README.md', 'package.json', 'dist/core/ts/src/index.js', 'dist/websocket/ts/src/index.d.ts']) +for (const required of ['LICENSE', 'NOTICE', 'README.md', 'package.json', 'dist/core/ts/src/index.js', 'dist/websocket/ts/src/index.d.ts', 'dist/hydrated/ts/src/index.js', 'dist/hydrated/ts/src/index.d.ts']) assert.ok(files.includes(required), `the tarball lacks ${required}`); assert.ok(!files.some((file) => /\/test\//.test(file) || file.endsWith('.go')), 'the tarball carries tests or Go sources'); writeFileSync(join(temp, 'package.json'), '{"type":"module","private":true}\n'); @@ -42,6 +42,7 @@ import assert from 'node:assert/strict'; import {atom,tuple,pathEqual} from '@bitspark/bitwire'; import {pair,compose,addressed,bind,asAddressed} from '@bitspark/bitruntime/core'; import {connectWebSocket,listenWebSocket} from '@bitspark/bitruntime/websocket'; +import {Endpoint,Namespace,Scope,hydratedTuple,items} from '@bitspark/bitruntime/hydrated'; const message=tuple([atom([255,0])]); const [left,right]=pair();const local=new Promise(r=>right.receive(r));await left.send(message);assert.ok((await local).equals(message));await left.close(); const leaf=compose(7);assert.equal(compose(3,[[atom([]),leaf]]).at([atom([])]),leaf); @@ -53,8 +54,17 @@ const client=await connectWebSocket(server.url);const scoped=addressed(client); const remote=new Promise(r=>scoped.receive((path,value)=>r({path,value}))); await bind(scoped,[atom([]),atom([255])]).send(message);const result=await remote; assert.ok(pathEqual(result.path,[atom([]),atom([255])]));assert.ok(result.value.equals(message));await client.close();await server.close(); +{ + const [x,y]=pair(),ns=new Namespace('smoke'),ax=addressed(x),ay=addressed(y); + const sx=new Scope(ns,[atom([1])],ax),sy=new Scope(ns,[atom([2])],ay); + ax.receive((p,v)=>sx.deliver(p,v,'x'));ay.receive((p,v)=>sy.deliver(p,v,'y')); + const service=new Endpoint(4);service.receive(v=>{const [arg,reply]=items(v);void reply.send(arg);}); + const reply=new Endpoint(1);const back=new Promise(r=>reply.receive(r)); + await sx.connect(sy.expose(service)).send(hydratedTuple([message,reply])); + assert.ok((await back).equals(message));await reply.close();await service.close();await x.close(); +} for(const retired of ['engine','dispatch','transports'])await assert.rejects(import('@bitspark/bitruntime/'+retired),{code:'ERR_PACKAGE_PATH_NOT_EXPORTED'}); -console.log('Fresh installed package: local pair, tree and WebSocket passed.'); +console.log('Fresh installed package: local pair, tree, WebSocket and hydrated reply passed.'); `); run(process.execPath, ['smoke.mjs'], temp); const metadata = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); From 7e6c5c9f65eaa873b964fb992386f2ec6ebbdc5a Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:10:24 +0200 Subject: [PATCH 13/21] hydrated: join bitwire's public declarations and shared codec Imports bitwire's public hydrated declarations (bitwire#88) and shared pure codec (bitwire#89) instead of the runtime's own: - HydratedValue, HydratedWire, HydratedEndpoint, ReceivedContext and Termination are bitwire's; the runtime's Endpoint, faces, proxies and tuples satisfy them (Go has compile-time assertions). - The codec packs and unpacks frames, bodies and references and checks every bound; codec failures map to malformed-frame and limit. The runtime keeps export ids and staging, reference recognition, swap-back, liveness, endpoints and the export bound. Folds in Wire & Runtime's production-review edges: - An endpoint bound must be a positive integer (TS refuses NaN, Infinity, fractions and nonpositive values; Go panics below one). - A receiver failure is tracked apart from its thrown value, so throwing undefined still reports "failed". - A local send recognizes leaves as dehydration would: foreign Wires and closed faces are refused unexportable, bare or nested, and an endpoint arrives as its face. A send to a malformed hand-made reference is now refused malformed-frame before it leaves (the codec validates the destination). Builds against bitwire v0.6.0 once released; verified locally against bitwire main plus #88 (merge 0c50f20): TS 55/55, go test -race ./..., interop in both roles. Co-Authored-By: Claude Opus 5.5 --- conformance/interop/hydrated/main.go | 26 +- hydrated/go/hydrated.go | 370 +++++++++++++-------------- hydrated/go/hydrated_test.go | 209 +++++++++------ hydrated/ts/src/index.ts | 180 +++++++------ hydrated/ts/test/hydrated.test.mjs | 32 ++- 5 files changed, 447 insertions(+), 370 deletions(-) diff --git a/conformance/interop/hydrated/main.go b/conformance/interop/hydrated/main.go index c15f26a..4e0940a 100644 --- a/conformance/interop/hydrated/main.go +++ b/conformance/interop/hydrated/main.go @@ -60,16 +60,16 @@ func participant(link wire.AddressedEndpoint, path wire.Path, onBootstrap func(o return scope } -func echo(v hydrated.HydratedValue, _ hydrated.ReceivedContext) { +func echo(v wire.HydratedValue, _ wire.ReceivedContext) { xs, _ := hydrated.Items(v) cont := hydrated.NewEndpoint(1) - _, _ = cont.Receive(func(next hydrated.HydratedValue, _ hydrated.ReceivedContext) { + _, _ = cont.Receive(func(next wire.HydratedValue, _ wire.ReceivedContext) { ys, _ := hydrated.Items(next) - _ = ys[1].(hydrated.HydratedWire).Send(ys[0]) + _ = ys[1].(wire.HydratedWire).Send(ys[0]) _ = cont.Close() }) r, _ := hydrated.NewTuple(xs[1], cont) - _ = xs[2].(hydrated.HydratedWire).Send(r) + _ = xs[2].(wire.HydratedWire).Send(r) } func serve() { @@ -82,7 +82,11 @@ func serve() { if err != nil { fail(err) } - if err := link.Send(bootstrap, ref.Payload()); err != nil { + payload, err := ref.Payload() + if err != nil { + fail(err) + } + if err := link.Send(bootstrap, payload); err != nil { fail(err) } }) @@ -114,13 +118,13 @@ func client(url string) { fail(err) } reply := hydrated.NewEndpoint(1) - replies := make(chan hydrated.HydratedValue, 1) - _, _ = reply.Receive(func(v hydrated.HydratedValue, _ hydrated.ReceivedContext) { replies <- v }) + replies := make(chan wire.HydratedValue, 1) + _, _ = reply.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { replies <- v }) req, _ := hydrated.NewTuple(text("continue"), text("hello"), reply) if err := scope.Connect(ref).Send(req); err != nil { fail(err) } - var r hydrated.HydratedValue + var r wire.HydratedValue select { case r = <-replies: case <-time.After(10 * time.Second): @@ -131,10 +135,10 @@ func client(url string) { fail(fmt.Errorf("wrong reply")) } third := hydrated.NewEndpoint(1) - thirds := make(chan hydrated.HydratedValue, 1) - _, _ = third.Receive(func(v hydrated.HydratedValue, _ hydrated.ReceivedContext) { thirds <- v }) + thirds := make(chan wire.HydratedValue, 1) + _, _ = third.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { thirds <- v }) next, _ := hydrated.NewTuple(text("third HydratedWire"), third) - if err := xs[1].(hydrated.HydratedWire).Send(next); err != nil { + if err := xs[1].(wire.HydratedWire).Send(next); err != nil { fail(err) } select { diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index 2bebc40..0ec65c8 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -1,6 +1,7 @@ -// Package hydrated is evidence for bitwire decision 0019 (proposed): the first -// edition of the hydrated wire protocol, bitwire/hydrated/1, realized in Go. It -// is not a released API; the record decides, this package demonstrates. +// Package hydrated realizes bitwire decision 0019, the hydrated wire protocol's +// first edition, over bitwire's public hydrated declarations and its shared pure +// codec. The codec owns the grammar, validation and bounds; this package owns +// export ids and staging, reference recognition, liveness and endpoints. // // A Scope is one participant's hydration state for one incarnation. Its // dispatcher owns the participant's addressed receive slot and hands the Scope @@ -19,16 +20,13 @@ import ( wire "github.com/Bitspark/bitwire/wire/go" ) -// HydratedValue is a hydrated value: a ground ontos value, a Tuple or a HydratedWire. -type HydratedValue any - -// HydratedWire sends one hydrated value. Only a local Endpoint's sending face or a proxy -// imported in the same namespace can travel inside a hydrated value (D7). -type HydratedWire interface{ Send(HydratedValue) error } - -// ReceivedContext is what the composition establishes about an arrival, for example a -// checked origin. It is never part of a value (D4). -type ReceivedContext any +// The runtime realizes bitwire's public hydrated declarations. +var ( + _ wire.HydratedEndpoint = (*Endpoint)(nil) + _ wire.HydratedWire = (*face)(nil) + _ wire.HydratedWire = (*proxy)(nil) + _ wire.HydratedTuple = Tuple{} +) // Local is the context of a send that never left the process. type Local struct{} @@ -50,21 +48,15 @@ const ( ScopeEnded Refusal = "scope-ended" ) -var ( - header = ontos.NewAtom([]byte("bitwire/hydrated/1")) - tupleTag = ontos.NewAtom([]byte{1}) - wireTag = ontos.NewAtom([]byte{2}) -) - -// Tuple is a hydrated tuple with at least one HydratedWire beneath it. NewTuple returns +// Tuple is a hydrated tuple with at least one wire.HydratedWire beneath it. NewTuple returns // the ground tuple when there is none (D1). -type Tuple struct{ items []HydratedValue } +type Tuple struct{ items []wire.HydratedValue } -// NewTuple captures items. Each must be an ontos value, a Tuple or a HydratedWire. An +// NewTuple captures items. Each must be an ontos value, a Tuple or a wire.HydratedWire. An // Endpoint is captured as its sending face: a value never carries receive or // close authority, on any path (D7). -func NewTuple(items ...HydratedValue) (HydratedValue, error) { - captured := make([]HydratedValue, len(items)) +func NewTuple(items ...wire.HydratedValue) (wire.HydratedValue, error) { + captured := make([]wire.HydratedValue, len(items)) for i, item := range items { captured[i] = sendOnly(item) } @@ -76,7 +68,7 @@ func NewTuple(items ...HydratedValue) (HydratedValue, error) { ground = append(ground, x) case ontos.Tuple: ground = append(ground, x) - case Tuple, HydratedWire: + case Tuple, wire.HydratedWire: live = true default: return nil, errors.New("hydrated: not a value") @@ -90,7 +82,7 @@ func NewTuple(items ...HydratedValue) (HydratedValue, error) { // sendOnly projects an Endpoint to its sending face, and the zero Tuple, the // only one NewTuple does not make, to the ground empty tuple (D1, D7). -func sendOnly(v HydratedValue) HydratedValue { +func sendOnly(v wire.HydratedValue) wire.HydratedValue { switch x := v.(type) { case *Endpoint: return x.face @@ -103,15 +95,15 @@ func sendOnly(v HydratedValue) HydratedValue { } // Items returns the tuple's items. -func (t Tuple) Items() []HydratedValue { return append([]HydratedValue(nil), t.items...) } +func (t Tuple) Items() []wire.HydratedValue { return append([]wire.HydratedValue(nil), t.items...) } // Items returns any tuple's items, ground or hydrated. -func Items(v HydratedValue) ([]HydratedValue, bool) { +func Items(v wire.HydratedValue) ([]wire.HydratedValue, bool) { switch x := v.(type) { case Tuple: return x.Items(), true case ontos.Tuple: - out := make([]HydratedValue, x.Len()) + out := make([]wire.HydratedValue, x.Len()) for i, item := range x.Items() { out[i] = item } @@ -127,28 +119,35 @@ type Namespace struct{ name string } // NewNamespace creates a namespace. func NewNamespace(name string) *Namespace { return &Namespace{name} } -// Reference is the ground form of a live HydratedWire (D2, D3). +// Reference is the ground form of a live wire.HydratedWire (D2, D3). type Reference struct { Path wire.Path Scope, ID ontos.Atom } -func (r Reference) value() ontos.Value { - segments := make([]ontos.Value, len(r.Path)) - for i, a := range r.Path { - segments[i] = a - } - return ontos.NewTuple(wireTag, ontos.NewTuple(ontos.NewTuple(segments...), r.Scope, r.ID)) +func (r Reference) data() (wire.HydratedReference, error) { + return wire.NewHydratedReference(r.Path, r.Scope, r.ID) } // Payload is the reference's ground payload ((path...), scope, id), for // composition bootstrap over ground data. -func (r Reference) Payload() ontos.Value { - return r.value().(ontos.Tuple).At(1) +func (r Reference) Payload() (ontos.Value, error) { + d, err := r.data() + if err != nil { + return nil, refusal(err) + } + v, err := wire.PackHydratedReference(d, DefaultLimits.codec()) + return v, refusal(err) } // ReadReference reads a reference payload. -func ReadReference(v ontos.Value) (Reference, error) { return readReference(v) } +func ReadReference(v ontos.Value) (Reference, error) { + d, err := wire.UnpackHydratedReference(v, DefaultLimits.codec()) + if err != nil { + return Reference{}, refusal(err) + } + return Reference{d.Path(), d.Scope(), d.ID()}, nil +} func (r Reference) key() string { k := strconv.Itoa(len(r.Path)) @@ -165,7 +164,7 @@ type Endpoint struct { mu sync.Mutex queue []delivery limit int - handler func(HydratedValue, ReceivedContext) + handler func(wire.HydratedValue, wire.ReceivedContext) gen uint64 // receiver generation: a detach removes only its own receiver failure error // set when a receiver failed and so terminated the endpoint closed bool @@ -176,19 +175,50 @@ type Endpoint struct { } type delivery struct { - value HydratedValue - context ReceivedContext + value wire.HydratedValue + context wire.ReceivedContext } // face is an Endpoint's sending face. It grants sending only. type face struct{ e *Endpoint } -func (f *face) Send(v HydratedValue) error { return f.e.admit(sendOnly(v), Local{}) } +func (f *face) Send(v wire.HydratedValue) error { + projected := sendOnly(v) + if err := checkLocal(projected); err != nil { + return err + } + return f.e.admit(projected, Local{}) +} -// NewEndpoint creates an endpoint whose queue admits at most limit values. +// checkLocal validates a value for local delivery as dehydration would for a +// remote one (D1, D7): only runtime-recognized leaves arrive. +func checkLocal(v wire.HydratedValue) error { + switch x := v.(type) { + case ontos.Atom, ontos.Tuple, *proxy: + return nil + case Tuple: + for _, item := range x.items { + if err := checkLocal(item); err != nil { + return err + } + } + return nil + case *face: + if x.e.isClosed() { + return Unexportable + } + return nil + case wire.HydratedWire: + return Unexportable + } + return errors.New("hydrated: not a value") +} + +// NewEndpoint creates an endpoint whose queue admits at most limit values. A +// bound below one is a programming error. func NewEndpoint(limit int) *Endpoint { - if limit <= 0 { - limit = 1 + if limit < 1 { + panic("hydrated: an endpoint bound must be at least one") } e := &Endpoint{limit: limit, done: make(chan struct{}), wake: make(chan struct{}, 1), exports: map[*Scope]ontos.Atom{}} e.face = &face{e} @@ -196,15 +226,15 @@ func NewEndpoint(limit int) *Endpoint { return e } -// HydratedWire is the endpoint's sending face. -func (e *Endpoint) Wire() HydratedWire { return e.face } +// wire.HydratedWire is the endpoint's sending face. +func (e *Endpoint) Wire() wire.HydratedWire { return e.face } // Send admits a value locally, as its sending face does. -func (e *Endpoint) Send(v HydratedValue) error { return e.face.Send(v) } +func (e *Endpoint) Send(v wire.HydratedValue) error { return e.face.Send(v) } // Receive attaches the one receiver. Values are dispatched in admission order, // never inline with the admitting send. -func (e *Endpoint) Receive(handler func(HydratedValue, ReceivedContext)) (detach func(), err error) { +func (e *Endpoint) Receive(handler func(wire.HydratedValue, wire.ReceivedContext)) (detach func(), err error) { e.mu.Lock() defer e.mu.Unlock() if e.handler != nil { @@ -260,7 +290,7 @@ func (e *Endpoint) Close() error { return nil } -func (e *Endpoint) admit(v HydratedValue, ctx ReceivedContext) error { +func (e *Endpoint) admit(v wire.HydratedValue, ctx wire.ReceivedContext) error { e.mu.Lock() defer e.mu.Unlock() if e.closed || len(e.queue) >= e.limit { @@ -306,7 +336,7 @@ func (e *Endpoint) dispatch() { } } -func deliverTo(h func(HydratedValue, ReceivedContext), d delivery) (err error) { +func deliverTo(h func(wire.HydratedValue, wire.ReceivedContext), d delivery) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("hydrated: receiver failed: %v", r) @@ -339,7 +369,7 @@ func (e *Endpoint) isClosed() bool { return e.closed } -// proxy is an imported HydratedWire. It carries its reference and namespace, so any +// proxy is an imported wire.HydratedWire. It carries its reference and namespace, so any // participant in the namespace forwards it unchanged (D6). A proxy with a dead // reason is this participant's own stale or withdrawn reference: its sends are // refused locally (D7, returning home). @@ -350,7 +380,7 @@ type proxy struct { dead Refusal } -func (p *proxy) Send(v HydratedValue) error { +func (p *proxy) Send(v wire.HydratedValue) error { if p.dead != "" { return p.dead } @@ -360,9 +390,25 @@ func (p *proxy) Send(v HydratedValue) error { // Limits bounds a scope (D8). type Limits struct{ Exports, Nodes, Depth, Bytes int } -// DefaultLimits are finite defaults for evidence; production configures its own. +// DefaultLimits are finite defaults; a composition configures its own. var DefaultLimits = Limits{Exports: 64, Nodes: 4096, Depth: 64, Bytes: 1 << 20} +// codec is the shared codec's share of the bounds; Exports stays the runtime's. +func (l Limits) codec() wire.HydratedCodecLimits { + return wire.HydratedCodecLimits{Nodes: l.Nodes, Depth: l.Depth, Bytes: l.Bytes} +} + +// refusal names a codec failure as the protocol's refusal (D8). +func refusal(err error) error { + switch { + case errors.Is(err, wire.ErrHydratedLimit): + return Limit + case errors.Is(err, wire.ErrHydratedMalformed): + return MalformedFrame + } + return err +} + // Scope is one participant's hydration state for one incarnation (Terms). type Scope struct { encodeMu sync.Mutex // encoding and registration are one step per scope (D5) @@ -420,7 +466,9 @@ func (s *Scope) Expose(e *Endpoint) (Reference, error) { } // Connect returns a proxy for a reference, for composition bootstrap. -func (s *Scope) Connect(r Reference) HydratedWire { return s.importRef(r, map[string]HydratedWire{}) } +func (s *Scope) Connect(r Reference) wire.HydratedWire { + return s.importRef(r, map[string]wire.HydratedWire{}) +} // Close ends the scope: its exports end and its references go stale. It closes // no domain endpoint. @@ -507,31 +555,9 @@ func (s *Scope) commit(staged map[*Endpoint]ontos.Atom) error { return nil } -type budget struct{ nodes, bytes int } - -// The node, depth and byte bounds count the hydrated value, identically when -// sending and receiving (D8): each atom, tuple and HydratedWire leaf is one node at its -// tuple depth; bytes are atom bytes plus each HydratedWire leaf's reference material. -func refBytes(path wire.Path) int { - n := 32 // scope token and export id - for _, a := range path { - n += a.Len() - } - return n -} - -func (s *Scope) spend(b *budget, depth, n int) error { - b.nodes++ - b.bytes += n - if b.nodes > s.limits.Nodes || depth > s.limits.Depth || b.bytes > s.limits.Bytes { - return Limit - } - return nil -} - -// send encodes v atomically and registers its new exports in one step per -// scope, then admits one frame to the owner path (D5). -func (s *Scope) send(dest Reference, v HydratedValue) error { +// send stages v and registers its new exports in one step per scope, then +// admits one frame to the owner path (D5). +func (s *Scope) send(dest Reference, v wire.HydratedValue) error { s.mu.Lock() closed := s.closed s.mu.Unlock() @@ -545,63 +571,106 @@ func (s *Scope) send(dest Reference, v HydratedValue) error { return s.sender.Send(dest.Path, frame) } -func (s *Scope) stage(dest Reference, v HydratedValue) (ontos.Value, error) { +func (s *Scope) stage(dest Reference, v wire.HydratedValue) (ontos.Value, error) { s.encodeMu.Lock() defer s.encodeMu.Unlock() staged := map[*Endpoint]ontos.Atom{} - body, err := s.encode(v, 0, staged, &budget{}) + body, err := s.toData(v, 0, staged) if err != nil { return nil, err } - frame := ontos.NewTuple(header, dest.Scope, dest.ID, body) - if _, err := wire.EncodeMessage(frame, s.limits.Bytes); err != nil { - return nil, Limit + frame, err := wire.PackHydratedFrame(wire.HydratedFrame{Scope: dest.Scope, ID: dest.ID, Body: body}, s.limits.codec()) + if err != nil { + return nil, refusal(err) } return frame, s.commit(staged) } -func (s *Scope) encode(v HydratedValue, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (ontos.Value, error) { +// toData maps a live value onto the codec's structural data, staging an export +// for each new face. The codec then checks every bound (D8); the depth guard +// here only keeps a pathological value from exhausting the stack first. +func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ontos.Atom) (wire.HydratedData, error) { + if depth > s.limits.Depth { + return nil, Limit + } switch x := v.(type) { case ontos.Atom: - return x, s.spend(b, depth, x.Len()) + return wire.NewHydratedDataAtom(x), nil case ontos.Tuple, Tuple: - if err := s.spend(b, depth, 0); err != nil { - return nil, err - } items, _ := Items(x) - out := make([]ontos.Value, len(items)) + out := make([]wire.HydratedData, len(items)) for i, item := range items { var err error - if out[i], err = s.encode(item, depth+1, staged, b); err != nil { + if out[i], err = s.toData(item, depth+1, staged); err != nil { return nil, err } } - return ontos.NewTuple(tupleTag, ontos.NewTuple(out...)), nil + t, err := wire.NewHydratedDataTuple(out...) + return t, refusal(err) case *Endpoint: - return s.encode(x.face, depth, staged, b) + return s.toData(x.face, depth, staged) case *face: - if err := s.spend(b, depth, refBytes(s.path)); err != nil { - return nil, err - } id, err := s.exportID(x.e, staged) if err != nil { return nil, err } - return Reference{s.path, s.token, id}.value(), nil + r, err := wire.NewHydratedReference(s.path, s.token, id) + return r, refusal(err) case *proxy: if x.ns != s.ns { return nil, ForeignNamespace } - return x.ref.value(), s.spend(b, depth, refBytes(x.ref.Path)) - case HydratedWire: + r, err := x.ref.data() + return r, refusal(err) + case wire.HydratedWire: return nil, Unexportable } return nil, errors.New("hydrated: not a value") } +// fromData maps decoded structural data onto live values. Occurrences of one +// reference within the value share one proxy; nothing is retained across +// messages (D7). +func (s *Scope) fromData(d wire.HydratedData, interned map[string]wire.HydratedWire) (wire.HydratedValue, error) { + switch x := d.(type) { + case wire.HydratedDataAtom: + return x.Value(), nil + case wire.HydratedDataTuple: + items := make([]wire.HydratedValue, x.Len()) + for i, c := range x.Items() { + var err error + if items[i], err = s.fromData(c, interned); err != nil { + return nil, err + } + } + return NewTuple(items...) + case wire.HydratedReference: + return s.importRef(Reference{x.Path(), x.Scope(), x.ID()}, interned), nil + } + return nil, MalformedFrame +} + +// encodeBody and decodeBody are the body halves of a frame. +func (s *Scope) encodeBody(v wire.HydratedValue, staged map[*Endpoint]ontos.Atom) (ontos.Value, error) { + d, err := s.toData(v, 0, staged) + if err != nil { + return nil, err + } + body, err := wire.PackHydratedBody(d, s.limits.codec()) + return body, refusal(err) +} + +func (s *Scope) decodeBody(body ontos.Value, interned map[string]wire.HydratedWire) (wire.HydratedValue, error) { + d, err := wire.UnpackHydratedBody(body, s.limits.codec()) + if err != nil { + return nil, refusal(err) + } + return s.fromData(d, interned) +} + // Deliver decides one value addressed to this participant's own path (D4). The // returned refusal is a host diagnostic; nothing is sent back. -func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx ReceivedContext) error { +func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx wire.ReceivedContext) error { if !wire.PathEqual(path, s.path) { return MalformedFrame } @@ -611,109 +680,30 @@ func (s *Scope) Deliver(path wire.Path, message ontos.Value, ctx ReceivedContext if closed { return ScopeEnded } - if _, err := wire.EncodeMessage(message, s.limits.Bytes); err != nil { - return Limit // the whole frame counts, reference material included (D8) - } - f, ok := message.(ontos.Tuple) - if !ok || f.Len() != 4 || !header.Equal(f.At(0)) { - return MalformedFrame - } - token, ok1 := f.At(1).(ontos.Atom) - id, ok2 := f.At(2).(ontos.Atom) - if !ok1 || !ok2 || token.Len() != 16 || id.Len() != 16 { - return MalformedFrame + frame, err := wire.UnpackHydratedFrame(message, s.limits.codec()) + if err != nil { + return refusal(err) } - if !token.Equal(s.token) { + if !frame.Scope.Equal(s.token) { return StaleScope } s.mu.Lock() - target := s.byID[string(id.Bytes())] + target := s.byID[string(frame.ID.Bytes())] s.mu.Unlock() if target == nil { return UnknownExport } - value, err := s.decode(f.At(3), 0, map[string]HydratedWire{}, &budget{}) + value, err := s.fromData(frame.Body, map[string]wire.HydratedWire{}) if err != nil { return err } return target.admit(value, ctx) } -// decode reads a body. Occurrences of one reference within the value share one -// proxy; nothing is retained across messages (D7). -func (s *Scope) decode(v ontos.Value, depth int, interned map[string]HydratedWire, b *budget) (HydratedValue, error) { - if a, ok := v.(ontos.Atom); ok { - return a, s.spend(b, depth, a.Len()) - } - t, ok := v.(ontos.Tuple) - if !ok || t.Len() != 2 { - return nil, MalformedFrame - } - tag, ok := t.At(0).(ontos.Atom) - if !ok || tag.Len() != 1 { - return nil, MalformedFrame - } - switch { - case tag.Equal(tupleTag): - children, ok := t.At(1).(ontos.Tuple) - if !ok { - return nil, MalformedFrame - } - if err := s.spend(b, depth, 0); err != nil { - return nil, err - } - items := make([]HydratedValue, children.Len()) - for i, c := range children.Items() { - var err error - if items[i], err = s.decode(c, depth+1, interned, b); err != nil { - return nil, err - } - } - return NewTuple(items...) - case tag.Equal(wireTag): - r, err := readReference(t.At(1)) - if err != nil { - return nil, err - } - if err := s.spend(b, depth, refBytes(r.Path)); err != nil { - return nil, err - } - return s.importRef(r, interned), nil - } - return nil, MalformedFrame -} - -// readReference reads a reference payload. Path segments are reference material: -// they count toward the byte bound, not as nodes or depth (D8). -func readReference(v ontos.Value) (Reference, error) { - t, ok := v.(ontos.Tuple) - if !ok || t.Len() != 3 { - return Reference{}, MalformedFrame - } - p, ok := t.At(0).(ontos.Tuple) - if !ok { - return Reference{}, MalformedFrame - } - path := make(wire.Path, p.Len()) - for i, segment := range p.Items() { - a, ok := segment.(ontos.Atom) - if !ok { - return Reference{}, MalformedFrame - } - path[i] = a - } - scope, ok1 := t.At(1).(ontos.Atom) - id, ok2 := t.At(2).(ontos.Atom) - if !ok1 || !ok2 || scope.Len() != 16 || id.Len() != 16 { - return Reference{}, MalformedFrame - } - return Reference{path, scope, id}, nil -} - -// importRef turns a reference into a HydratedWire. A reference to this participant in +// importRef turns a reference into a wire.HydratedWire. A reference to this participant in // its current scope returns home to the original face; a stale or withdrawn one -// becomes a HydratedWire whose sends are refused. Liveness is judged on send (D7). -func (s *Scope) importRef(r Reference, interned map[string]HydratedWire) HydratedWire { +// becomes a wire.HydratedWire whose sends are refused. Liveness is judged on send (D7). +func (s *Scope) importRef(r Reference, interned map[string]wire.HydratedWire) wire.HydratedWire { if wire.PathEqual(r.Path, s.path) { if !r.Scope.Equal(s.token) { return &proxy{ref: r, ns: s.ns, via: s, dead: StaleScope} diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index 07ddcd8..dcf44a2 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -22,6 +22,22 @@ import ( func text(s string) ontos.Atom { return ontos.NewAtom([]byte(s)) } +// The raw grammar, for crafting frames by hand; the runtime itself uses the codec. +var ( + header = ontos.NewAtom([]byte("bitwire/hydrated/1")) + tupleTag = ontos.NewAtom([]byte{1}) + wireTag = ontos.NewAtom([]byte{2}) +) + +// value is a reference's tagged body node, (02, ((path...), scope, id)). +func (r Reference) value() ontos.Value { + segments := make([]ontos.Value, len(r.Path)) + for i, a := range r.Path { + segments[i] = a + } + return ontos.NewTuple(wireTag, ontos.NewTuple(ontos.NewTuple(segments...), r.Scope, r.ID)) +} + // pending reads an endpoint's queue length under its lock. func (e *Endpoint) pending() int { e.mu.Lock() @@ -29,7 +45,7 @@ func (e *Endpoint) pending() int { return len(e.queue) } -func tuple(t *testing.T, items ...HydratedValue) HydratedValue { +func tuple(t *testing.T, items ...wire.HydratedValue) wire.HydratedValue { t.Helper() v, err := NewTuple(items...) if err != nil { @@ -38,7 +54,7 @@ func tuple(t *testing.T, items ...HydratedValue) HydratedValue { return v } -func items(t *testing.T, v HydratedValue) []HydratedValue { +func items(t *testing.T, v wire.HydratedValue) []wire.HydratedValue { t.Helper() xs, ok := Items(v) if !ok { @@ -78,9 +94,9 @@ func ground(v jsonValue) ontos.Value { return ontos.NewTuple(out...) } -func wires(v HydratedValue) int { +func wires(v wire.HydratedValue) int { switch x := v.(type) { - case HydratedWire: + case wire.HydratedWire: return 1 case Tuple: n := 0 @@ -117,14 +133,14 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { s, _ := NewScope(NewNamespace("vectors"), wire.Path{text("vectors")}, nil, DefaultLimits) for _, c := range v.Encode { body := ground(c.Body) - live, err := s.decode(body, 0, map[string]HydratedWire{}, &budget{}) + live, err := s.decodeBody(body, map[string]wire.HydratedWire{}) if err != nil { t.Fatalf("%s: %v", c.Name, err) } if got, want := wires(live), strings.Count(c.Live, "wire{"); got != want { t.Fatalf("%s: %d wire leaves, want %d", c.Name, got, want) } - again, err := s.encode(live, 0, map[*Endpoint]ontos.Atom{}, &budget{}) + again, err := s.encodeBody(live, map[*Endpoint]ontos.Atom{}) if err != nil || !again.Equal(body) { t.Fatalf("%s: re-encoding changed the body: %v", c.Name, err) } @@ -134,7 +150,7 @@ func TestObservation1VectorsRoundTrip(t *testing.T) { } } for _, c := range v.RejectBody { - if _, err := s.decode(ground(c.Value), 0, map[string]HydratedWire{}, &budget{}); !errors.Is(err, MalformedFrame) { + if _, err := s.decodeBody(ground(c.Value), map[string]wire.HydratedWire{}); !errors.Is(err, MalformedFrame) { t.Fatalf("%s: %v", c.Name, err) } } @@ -287,7 +303,7 @@ func (tr *tree) replace(t *testing.T, name string) *Scope { } // serve exposes a domain endpoint at a participant: the composition bootstrap. -func serve(t *testing.T, s *Scope, handler func(HydratedValue, ReceivedContext)) (*Endpoint, Reference) { +func serve(t *testing.T, s *Scope, handler func(wire.HydratedValue, wire.ReceivedContext)) (*Endpoint, Reference) { e := NewEndpoint(64) if _, err := e.Receive(handler); err != nil { t.Fatal(err) @@ -301,21 +317,21 @@ func serve(t *testing.T, s *Scope, handler func(HydratedValue, ReceivedContext)) } type received struct { - value HydratedValue - context ReceivedContext + value wire.HydratedValue + context wire.ReceivedContext } // call is a domain adapter: it sends (op, args..., reply) and closes its reply // endpoint after the reply. It knows no reference, table or route. -func call(t *testing.T, target HydratedWire, op string, args ...HydratedValue) received { +func call(t *testing.T, target wire.HydratedWire, op string, args ...wire.HydratedValue) received { t.Helper() reply := NewEndpoint(1) defer reply.Close() got := make(chan received, 1) - if _, err := reply.Receive(func(v HydratedValue, c ReceivedContext) { got <- received{v, c} }); err != nil { + if _, err := reply.Receive(func(v wire.HydratedValue, c wire.ReceivedContext) { got <- received{v, c} }); err != nil { t.Fatal(err) } - if err := target.Send(tuple(t, append(append([]HydratedValue{text(op)}, args...), reply)...)); err != nil { + if err := target.Send(tuple(t, append(append([]wire.HydratedValue{text(op)}, args...), reply)...)); err != nil { t.Fatal(err) } select { @@ -329,19 +345,19 @@ func call(t *testing.T, target HydratedWire, op string, args ...HydratedValue) r // echo is a domain provider: (op, arg, reply). "echo" replies (arg); "continue" // replies (arg, continuation), where the continuation forwards one value to the -// HydratedWire it carries and then closes. -func echo(t *testing.T) func(HydratedValue, ReceivedContext) { - return func(v HydratedValue, _ ReceivedContext) { +// wire.HydratedWire it carries and then closes. +func echo(t *testing.T) func(wire.HydratedValue, wire.ReceivedContext) { + return func(v wire.HydratedValue, _ wire.ReceivedContext) { xs, _ := Items(v) - op, arg, reply := xs[0].(ontos.Atom), xs[1], xs[len(xs)-1].(HydratedWire) + op, arg, reply := xs[0].(ontos.Atom), xs[1], xs[len(xs)-1].(wire.HydratedWire) switch string(op.Bytes()) { case "echo": _ = reply.Send(arg) case "continue": cont := NewEndpoint(1) - _, _ = cont.Receive(func(next HydratedValue, _ ReceivedContext) { + _, _ = cont.Receive(func(next wire.HydratedValue, _ wire.ReceivedContext) { ys, _ := Items(next) - _ = ys[1].(HydratedWire).Send(ys[0]) + _ = ys[1].(wire.HydratedWire).Send(ys[0]) _ = cont.Close() }) r, _ := NewTuple(arg, cont) @@ -367,18 +383,18 @@ func TestObservation2RecursionThroughAnOpaqueRouter(t *testing.T) { t.Fatal("wrong reply") } third := NewEndpoint(1) - got := make(chan HydratedValue, 1) - _, _ = third.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) - if err := xs[1].(HydratedWire).Send(tuple(t, text("third HydratedWire"), third)); err != nil { + got := make(chan wire.HydratedValue, 1) + _, _ = third.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + if err := xs[1].(wire.HydratedWire).Send(tuple(t, text("third wire.HydratedWire"), third)); err != nil { t.Fatal(err) } select { case v := <-got: - if !v.(ontos.Atom).Equal(text("third HydratedWire")) { + if !v.(ontos.Atom).Equal(text("third wire.HydratedWire")) { t.Fatal("wrong third value") } case <-time.After(5 * time.Second): - t.Fatal("third HydratedWire not reached") + t.Fatal("third wire.HydratedWire not reached") } if n := tr.frames(); n != 4 { t.Fatalf("%d frames crossed the middle, want 4", n) @@ -416,22 +432,26 @@ func TestObservation4ASharedWireEndsOnlyWithItsOwner(t *testing.T) { tr := newTree(t, carrier, "client", "provider", "third") a, b, c := tr.parts["client"].scope(), tr.parts["provider"].scope(), tr.parts["third"].scope() var mu sync.Mutex - var atB, atC HydratedWire - _, refC := serve(t, c, func(v HydratedValue, _ ReceivedContext) { mu.Lock(); atC = v.(HydratedWire); mu.Unlock() }) + var atB, atC wire.HydratedWire + _, refC := serve(t, c, func(v wire.HydratedValue, _ wire.ReceivedContext) { + mu.Lock() + atC = v.(wire.HydratedWire) + mu.Unlock() + }) toC := b.Connect(refC) - _, refB := serve(t, b, func(v HydratedValue, _ ReceivedContext) { + _, refB := serve(t, b, func(v wire.HydratedValue, _ wire.ReceivedContext) { mu.Lock() - atB = v.(HydratedWire) + atB = v.(wire.HydratedWire) mu.Unlock() - _ = toC.Send(v) // B forwards the HydratedWire it received + _ = toC.Send(v) // B forwards the wire.HydratedWire it received }) w := NewEndpoint(8) - var seen []ReceivedContext - _, _ = w.Receive(func(_ HydratedValue, ctx ReceivedContext) { mu.Lock(); seen = append(seen, ctx); mu.Unlock() }) + var seen []wire.ReceivedContext + _, _ = w.Receive(func(_ wire.HydratedValue, ctx wire.ReceivedContext) { mu.Lock(); seen = append(seen, ctx); mu.Unlock() }) if err := a.Connect(refB).Send(w); err != nil { t.Fatal(err) } - eventually(t, "C did not receive the HydratedWire", func() bool { mu.Lock(); defer mu.Unlock(); return atC != nil }) + eventually(t, "C did not receive the wire.HydratedWire", func() bool { mu.Lock(); defer mu.Unlock(); return atC != nil }) if b.Live() != 1 || c.Live() != 1 { t.Fatalf("forwarding registered state: B %d C %d", b.Live(), c.Live()) } @@ -455,7 +475,7 @@ func TestObservation4ASharedWireEndsOnlyWithItsOwner(t *testing.T) { mu.Lock() defer mu.Unlock() if len(seen) != 1 { - t.Fatal("a closed HydratedWire received") + t.Fatal("a closed wire.HydratedWire received") } }) } @@ -467,7 +487,7 @@ func TestObservation5NoProxyOfAProxy(t *testing.T) { ref, _ := a.Expose(w) imported := b.Connect(ref) for _, s := range []*Scope{b, c} { // a second local participant encodes it too - got, err := s.encode(imported, 0, map[*Endpoint]ontos.Atom{}, &budget{}) + got, err := s.encodeBody(imported, map[*Endpoint]ontos.Atom{}) if err != nil || !got.Equal(ref.value()) { t.Fatalf("%v: reference changed", err) } @@ -477,21 +497,21 @@ func TestObservation5NoProxyOfAProxy(t *testing.T) { } } -type funcWire func(HydratedValue) error +type funcWire func(wire.HydratedValue) error -func (f funcWire) Send(v HydratedValue) error { return f(v) } +func (f funcWire) Send(v wire.HydratedValue) error { return f(v) } func TestObservation6RefusalsBeforeAdmission(t *testing.T) { tr := newTree(t, "pair", "client", "provider") a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() - _, svc := serve(t, b, func(HydratedValue, ReceivedContext) {}) + _, svc := serve(t, b, func(wire.HydratedValue, wire.ReceivedContext) {}) target := a.Connect(svc) kept := NewEndpoint(1) - if err := target.Send(tuple(t, kept, funcWire(func(HydratedValue) error { return nil }))); !errors.Is(err, Unexportable) { - t.Fatalf("arbitrary HydratedWire: %v", err) + if err := target.Send(tuple(t, kept, funcWire(func(wire.HydratedValue) error { return nil }))); !errors.Is(err, Unexportable) { + t.Fatalf("arbitrary wire.HydratedWire: %v", err) } other, _ := NewScope(NewNamespace("other"), wire.Path{text("x")}, nil, DefaultLimits) - foreign := other.Connect(Reference{wire.Path{text("y")}, ontos.NewAtom(make([]byte, 16)), text("1")}) + foreign := other.Connect(Reference{wire.Path{text("y")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) if err := target.Send(tuple(t, kept, foreign)); !errors.Is(err, ForeignNamespace) { t.Fatalf("foreign proxy: %v", err) } @@ -508,12 +528,12 @@ func TestObservation6RefusalsBeforeAdmission(t *testing.T) { func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { carriers(t, func(t *testing.T, carrier string) { tr := newTree(t, carrier, "client", "provider") - _, svc := serve(t, tr.parts["provider"].scope(), func(HydratedValue, ReceivedContext) {}) + _, svc := serve(t, tr.parts["provider"].scope(), func(wire.HydratedValue, wire.ReceivedContext) {}) old := tr.parts["client"].scope().Connect(svc) fresh := tr.replace(t, "provider") var mu sync.Mutex hit := false - _, _ = serve(t, fresh, func(HydratedValue, ReceivedContext) { mu.Lock(); hit = true; mu.Unlock() }) + _, _ = serve(t, fresh, func(wire.HydratedValue, wire.ReceivedContext) { mu.Lock(); hit = true; mu.Unlock() }) _ = old.Send(text("stale")) eventually(t, "not refused stale-scope", func() bool { d := tr.diagnostics() @@ -530,19 +550,19 @@ func TestObservation7StaleScopeCannotReachTheReplacement(t *testing.T) { func TestObservation9ReturningHome(t *testing.T) { tr := newTree(t, "pair", "client", "provider") a, b := tr.parts["client"].scope(), tr.parts["provider"].scope() - var back HydratedWire + var back wire.HydratedWire var mu sync.Mutex proceed := make(chan struct{}) - _, svc := serve(t, b, func(v HydratedValue, _ ReceivedContext) { + _, svc := serve(t, b, func(v wire.HydratedValue, _ wire.ReceivedContext) { <-proceed xs, _ := Items(v) r, _ := NewTuple(text("data"), xs[0], xs[1]) // send both Wires home - _ = xs[2].(HydratedWire).Send(r) + _ = xs[2].(wire.HydratedWire).Send(r) }) home, gone := NewEndpoint(1), NewEndpoint(1) reply := NewEndpoint(1) - got := make(chan HydratedValue, 1) - _, _ = reply.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) + got := make(chan wire.HydratedValue, 1) + _, _ = reply.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) if err := a.Connect(svc).Send(tuple(t, home, gone, reply)); err != nil { t.Fatal(err) } @@ -554,7 +574,7 @@ func TestObservation9ReturningHome(t *testing.T) { case v := <-got: xs := items(t, v) mu.Lock() - back = xs[1].(HydratedWire) + back = xs[1].(wire.HydratedWire) mu.Unlock() if !xs[0].(ontos.Atom).Equal(text("data")) { t.Fatal("the rest of the message was lost") @@ -562,7 +582,7 @@ func TestObservation9ReturningHome(t *testing.T) { if back != home.Wire() { t.Fatal("own reference did not return to the original face") } - if err := xs[2].(HydratedWire).Send(text("x")); !errors.Is(err, UnknownExport) { + if err := xs[2].(wire.HydratedWire).Send(text("x")); !errors.Is(err, UnknownExport) { t.Fatalf("a withdrawn own reference: %v", err) } case <-time.After(5 * time.Second): @@ -573,7 +593,7 @@ func TestObservation9ReturningHome(t *testing.T) { func TestObservation10ReceivedContextIsNotData(t *testing.T) { tr := newTree(t, "pair", "client", "provider") got := make(chan received, 1) - _, svc := serve(t, tr.parts["provider"].scope(), func(v HydratedValue, c ReceivedContext) { got <- received{v, c} }) + _, svc := serve(t, tr.parts["provider"].scope(), func(v wire.HydratedValue, c wire.ReceivedContext) { got <- received{v, c} }) forged := ontos.NewTuple(text("context"), text("client-link")) if err := tr.parts["client"].scope().Connect(svc).Send(forged); err != nil { t.Fatal(err) @@ -589,7 +609,7 @@ func TestObservation11AtomicDecoding(t *testing.T) { b := tr.parts["provider"].scope() var mu sync.Mutex delivered := 0 - _, svc := serve(t, b, func(HydratedValue, ReceivedContext) { mu.Lock(); delivered++; mu.Unlock() }) + _, svc := serve(t, b, func(wire.HydratedValue, wire.ReceivedContext) { mu.Lock(); delivered++; mu.Unlock() }) w := NewEndpoint(1) ref, _ := tr.parts["client"].scope().Expose(w) bad := ontos.NewTuple(tupleTag, ontos.NewTuple(ref.value(), ontos.NewTuple(tupleTag))) @@ -610,7 +630,7 @@ func (refusingSender) Send(wire.Path, ontos.Value) error { return errors.New("ca func TestObservation12RegistrationBeforeAdmission(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, DefaultLimits) - target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) w := NewEndpoint(1) if err := target.Send(tuple(t, w)); err == nil { t.Fatal("the carrier's refusal was hidden") @@ -632,7 +652,7 @@ func TestObservation14Ordering(t *testing.T) { tr := newTree(t, carrier, "client", "provider") var mu sync.Mutex var seen []string - _, svc := serve(t, tr.parts["provider"].scope(), func(v HydratedValue, _ ReceivedContext) { + _, svc := serve(t, tr.parts["provider"].scope(), func(v wire.HydratedValue, _ wire.ReceivedContext) { mu.Lock() seen = append(seen, string(v.(ontos.Atom).Bytes())) mu.Unlock() @@ -654,16 +674,16 @@ func TestObservation14Ordering(t *testing.T) { func TestObservation15Bounds(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, refusingSender{}, Limits{Exports: 2, Nodes: 8, Depth: 3, Bytes: 64}) - target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), text("1")}) - deep := HydratedValue(text("x")) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + deep := wire.HydratedValue(text("x")) for i := 0; i < 5; i++ { deep = tuple(t, deep) } - wide := make([]HydratedValue, 9) + wide := make([]wire.HydratedValue, 9) for i := range wide { wide[i] = text("y") } - for name, v := range map[string]HydratedValue{ + for name, v := range map[string]wire.HydratedValue{ "depth": deep, "nodes": tuple(t, wide...), "bytes": ontos.NewAtom(make([]byte, 65)), @@ -689,7 +709,7 @@ func TestObservation15Bounds(t *testing.T) { }) } -// Observation 8: one reference grants its own HydratedWire and no sibling, including an +// Observation 8: one reference grants its own wire.HydratedWire and no sibling, including an // endpoint the owner exported for another holder. func TestObservation8SiblingForgery(t *testing.T) { carriers(t, func(t *testing.T, carrier string) { @@ -697,8 +717,8 @@ func TestObservation8SiblingForgery(t *testing.T) { provider := tr.parts["provider"].scope() var mu sync.Mutex private := 0 - _, public := serve(t, provider, func(HydratedValue, ReceivedContext) {}) - _, intended := serve(t, provider, func(HydratedValue, ReceivedContext) { mu.Lock(); private++; mu.Unlock() }) + _, public := serve(t, provider, func(wire.HydratedValue, wire.ReceivedContext) {}) + _, intended := serve(t, provider, func(wire.HydratedValue, wire.ReceivedContext) { mu.Lock(); private++; mu.Unlock() }) // The private endpoint really is held by another participant. if err := tr.parts["third"].scope().Connect(intended).Send(text("legitimate")); err != nil { t.Fatal(err) @@ -711,10 +731,14 @@ func TestObservation8SiblingForgery(t *testing.T) { client := tr.parts["client"].scope() bump := append([]byte(nil), public.ID.Bytes()...) bump[15]++ - forged := []ontos.Atom{ontos.NewAtom(bump), ontos.NewAtom(make([]byte, 16)), text("2"), text("0000000000000002")} + forged := []ontos.Atom{ontos.NewAtom(bump), ontos.NewAtom(make([]byte, 16)), text("0000000000000002")} for _, id := range forged { _ = client.Connect(Reference{public.Path, public.Scope, id}).Send(text("forged")) } + // A counter-like id of the wrong length is refused before it leaves. + if err := client.Connect(Reference{public.Path, public.Scope, text("2")}).Send(text("forged")); !errors.Is(err, MalformedFrame) { + t.Fatalf("a 1-octet id: %v", err) + } eventually(t, "forged references were not refused", func() bool { return len(tr.diagnostics()) == len(forged) }) for _, d := range tr.diagnostics() { if d.who != "provider" || !(errors.Is(d.err, UnknownExport) || errors.Is(d.err, MalformedFrame)) { @@ -770,7 +794,7 @@ func TestObservation13ConcurrentSends(t *testing.T) { registered := s.byEndpoint[face] for _, f := range rec.frames { leaf := f.(ontos.Tuple).At(3).(ontos.Tuple).At(1).(ontos.Tuple).At(0) // body (01, ((02, ref))) - ref, err := readReference(leaf.(ontos.Tuple).At(1)) + ref, err := ReadReference(leaf.(ontos.Tuple).At(1)) if err != nil || !ref.ID.Equal(registered) { t.Fatal("a frame names an id that was not registered") } @@ -814,9 +838,9 @@ func TestObservation15IncomingFrameBytes(t *testing.T) { // the sending face; a stale detach removes only its own receiver; a receiver's // failure terminates its endpoint, which ends its export. func TestObservation17EndpointLaws(t *testing.T) { - got := make(chan HydratedValue, 2) + got := make(chan wire.HydratedValue, 2) local := NewEndpoint(4) - if _, err := local.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }); err != nil { + if _, err := local.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }); err != nil { t.Fatal(err) } private := NewEndpoint(1) @@ -830,10 +854,10 @@ func TestObservation17EndpointLaws(t *testing.T) { } e := NewEndpoint(4) - stale, _ := e.Receive(func(HydratedValue, ReceivedContext) {}) + stale, _ := e.Receive(func(wire.HydratedValue, wire.ReceivedContext) {}) stale() - seen := make(chan HydratedValue, 1) - if _, err := e.Receive(func(v HydratedValue, _ ReceivedContext) { seen <- v }); err != nil { + seen := make(chan wire.HydratedValue, 1) + if _, err := e.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { seen <- v }); err != nil { t.Fatal(err) } stale() @@ -846,7 +870,7 @@ func TestObservation17EndpointLaws(t *testing.T) { s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, DefaultLimits) failing := NewEndpoint(4) - if _, err := failing.Receive(func(HydratedValue, ReceivedContext) { panic("receiver fault") }); err != nil { + if _, err := failing.Receive(func(wire.HydratedValue, wire.ReceivedContext) { panic("receiver fault") }); err != nil { t.Fatal(err) } ref, _ := s.Expose(failing) @@ -874,11 +898,11 @@ func TestObservation17EndpointLaws(t *testing.T) { // exactly when the receiver does, at tight node, depth and byte budgets. func TestObservation15SenderAndReceiverCountAlike(t *testing.T) { face := NewEndpoint(1) - nested := HydratedValue(text("x")) + nested := wire.HydratedValue(text("x")) for i := 0; i < 3; i++ { nested = tuple(t, nested, face) } - values := map[string]HydratedValue{ + values := map[string]wire.HydratedValue{ "one face three times": tuple(t, face, face, face), "nested faces": nested, "a long atom": ontos.NewAtom(make([]byte, 96)), @@ -932,8 +956,8 @@ func TestObservation15LongReferencePath(t *testing.T) { limits := Limits{Exports: 4, Nodes: 8, Depth: 2, Bytes: 4096} receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) target := NewEndpoint(4) - got := make(chan HydratedValue, 1) - _, _ = target.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) + got := make(chan wire.HydratedValue, 1) + _, _ = target.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) ref, _ := receiver.Expose(target) long := wire.Path{text("a"), text("b"), text("c"), text("d"), text("e")} far := Reference{long, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} @@ -949,11 +973,48 @@ func TestObservation15LongReferencePath(t *testing.T) { // Observation 17, construction: the zero Tuple, the only one NewTuple does not // make, arrives as the ground empty tuple. func TestObservation17ZeroTupleIsGround(t *testing.T) { - got := make(chan HydratedValue, 1) + got := make(chan wire.HydratedValue, 1) local := NewEndpoint(1) - _, _ = local.Receive(func(v HydratedValue, _ ReceivedContext) { got <- v }) + _, _ = local.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) _ = local.Wire().Send(Tuple{}) if v, ok := (<-got).(ontos.Tuple); !ok || v.Len() != 0 { t.Fatal("the zero hydrated tuple was not delivered as the ground empty tuple") } } + +// Observation 17, local edges: a bound below one is refused, and a local send +// recognizes leaves as dehydration would. +func TestObservation17LocalEdges(t *testing.T) { + func() { + defer func() { + if recover() == nil { + t.Fatal("an endpoint bound of zero was accepted") + } + }() + NewEndpoint(0) + }() + got := make(chan wire.HydratedValue, 4) + local := NewEndpoint(8) + _, _ = local.Receive(func(v wire.HydratedValue, _ wire.ReceivedContext) { got <- v }) + foreign := funcWire(func(wire.HydratedValue) error { return nil }) + if err := local.Wire().Send(foreign); !errors.Is(err, Unexportable) { + t.Fatalf("a bare foreign Wire: %v", err) + } + if err := local.Wire().Send(tuple(t, text("a"), tuple(t, foreign))); !errors.Is(err, Unexportable) { + t.Fatalf("a nested foreign Wire: %v", err) + } + closed := NewEndpoint(1) + _ = closed.Close() + if err := local.Wire().Send(tuple(t, closed.Wire())); !errors.Is(err, Unexportable) { + t.Fatalf("a closed face: %v", err) + } + if err := local.Wire().Send(tuple(t, text("ok"), NewEndpoint(1))); err != nil { + t.Fatal(err) + } + <-got + select { + case v := <-got: + t.Fatalf("a refused local value was delivered: %v", v) + case <-time.After(50 * time.Millisecond): + } +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index b4c2274..15e00ca 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -1,17 +1,17 @@ -// Evidence for bitwire decision 0019 (proposed): the first edition of the -// hydrated wire protocol, bitwire/hydrated/1, realized in TypeScript. It mirrors -// hydrated/go and is not a released API. -import { Atom, Tuple, atom, tuple, encodeMessage, pathEqual } from '@bitspark/bitwire'; -import type { Path, AddressedWire, Termination } from '@bitspark/bitwire'; - -/** A hydrated value: a ground ontos value, a HydratedTuple or a HydratedWire. */ -export type HydratedValue = Atom | Tuple | HydratedTuple | HydratedWire; - -/** Sends one hydrated value. Only an Endpoint's face or a proxy can travel (D7). */ -export interface HydratedWire { readonly kind: 'wire'; send(value: HydratedValue): Promise } - -/** What the composition establishes about an arrival; never part of a value (D4). */ -export type ReceivedContext = unknown; +// The realization of bitwire decision 0019, the hydrated wire protocol's first +// edition, over bitwire's public hydrated declarations and its shared pure codec. +// The codec owns the grammar, validation and bounds; this module owns export ids +// and staging, reference recognition, liveness and endpoints. +import { + Atom, Tuple, atom, tuple, pathEqual, + HydratedCodecError, HydratedDataAtom, HydratedDataTuple, HydratedReference, + packHydratedBody, unpackHydratedBody, packHydratedFrame, unpackHydratedFrame, + packHydratedReference, unpackHydratedReference, +} from '@bitspark/bitwire'; +import type { + Path, AddressedWire, Termination, HydratedValue, HydratedWire, HydratedEndpoint, ReceivedContext, + HydratedData, HydratedCodecLimits, HydratedTuple as HydratedTupleView, +} from '@bitspark/bitwire'; /** The context of a send that never left the process. */ export const LOCAL: unique symbol = Symbol('local'); @@ -34,10 +34,6 @@ export class Refusal extends Error { } } -const HEADER = atom(new TextEncoder().encode('bitwire/hydrated/1')); -const TUPLE_TAG = atom([1]); -const WIRE_TAG = atom([2]); - /** A tuple with at least one HydratedWire beneath it; see hydratedTuple (D1). */ /** Only this module constructs hydrated tuples, so every one is canonical (D1, D7). */ const CONSTRUCT = Symbol('hydrated tuple'); @@ -46,7 +42,7 @@ const CONSTRUCT = Symbol('hydrated tuple'); * A tuple with at least one HydratedWire beneath it, shaped like bitwire's HydratedTuple * (a ground Tuple has the same shape). Construct it with hydratedTuple(). */ -export class HydratedTuple { +export class HydratedTuple implements HydratedTupleView { readonly kind = 'tuple' as const; readonly #items: readonly HydratedValue[]; constructor(items: readonly HydratedValue[], token?: symbol) { @@ -65,6 +61,24 @@ const isWire = (v: unknown): v is HydratedWire => typeof (v as HydratedWire | un /** An Endpoint travels only as its sending face, on every path (D7). */ const sendOnly = (v: HydratedValue): HydratedValue => (v instanceof Endpoint ? v.wire : v); +/** + * Validates a value for local delivery as dehydration would for a remote one (D1, + * D7): only runtime-recognized leaves arrive, and an endpoint arrives as its face. + */ +function local(v: HydratedValue): HydratedValue { + const check = (x: HydratedValue): void => { + if (x instanceof Atom || x instanceof Tuple) return; + if (x instanceof HydratedTuple) { x.items().forEach(check); return; } + if (x instanceof Face) { if (owners.get(x)!.isClosed) throw new Refusal(UNEXPORTABLE); return; } + if (x instanceof Proxy) return; + if (x && typeof (x as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); + throw new TypeError('hydrated: not a value'); + }; + const projected = sendOnly(v); + check(projected); + return projected; +} + /** Captures items; returns the ground tuple when no HydratedWire is beneath them. */ export function hydratedTuple(items: readonly HydratedValue[]): HydratedValue { const captured = items.map(sendOnly); @@ -94,14 +108,20 @@ export interface Reference { readonly path: Path; readonly scope: Atom; readonly const hex = (a: Atom) => Array.from(a.bytes(), (b) => b.toString(16).padStart(2, '0')).join(''); const refKey = (r: Reference) => `${r.path.map(hex).join('/')}|${r.path.length}|${hex(r.scope)}|${hex(r.id)}`; -const refValue = (r: Reference): Tuple => tuple([WIRE_TAG, tuple([tuple(r.path), r.scope, r.id])]); +/** A codec failure, named as the protocol's refusal (D8). */ +function refusal(error: unknown): never { + if (error instanceof HydratedCodecError) throw new Refusal(error.kind === 'limit' ? LIMIT : MALFORMED_FRAME); + throw error; +} +const codec = (f: () => T): T => { try { return f(); } catch (error) { return refusal(error); } }; +const codecLimits = (l: Limits): HydratedCodecLimits => ({ nodes: l.nodes, depth: l.depth, bytes: l.bytes }); /** * A local HydratedEndpoint: one receiver, ordered dispatch never inline with the * admitting send, a bounded queue and an owner lifetime. Its face is what a value * carries; closing it ends its export in every scope (D7). */ -export class Endpoint implements HydratedWire { +export class Endpoint implements HydratedEndpoint { readonly kind = 'wire' as const; readonly wire: HydratedWire; /** Resolves with how the endpoint ended, as bitwire's Endpoint contract does. */ @@ -110,6 +130,7 @@ export class Endpoint implements HydratedWire { #limit: number; #handler: ((value: HydratedValue, context: ReceivedContext) => void) | undefined; #generation = 0; + #failed = false; #failure: unknown; #isClosed = false; #draining = false; @@ -118,7 +139,8 @@ export class Endpoint implements HydratedWire { readonly #exports = new Map(); constructor(limit = 1) { - this.#limit = Math.max(1, limit); + if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError('hydrated: an endpoint bound must be a positive integer'); + this.#limit = limit; this.wire = new Face(this); this.closed = new Promise((resolve) => { this.#resolveClosed = resolve; }); } @@ -144,7 +166,7 @@ export class Endpoint implements HydratedWire { const exports = [...this.#exports]; this.#exports.clear(); for (const [scope, id] of exports) scope._withdraw(this, id); - this.#termination = this.#failure === undefined + this.#termination = !this.#failed ? Object.freeze({ kind: 'closed' }) : Object.freeze({ kind: 'failed', message: String((this.#failure as Error)?.message ?? this.#failure) }); this.#resolveClosed(this.#termination); @@ -180,6 +202,7 @@ export class Endpoint implements HydratedWire { this.#handler(d.value, d.context); } catch (error) { // A receiver failure terminates the endpoint, which ends its export. + this.#failed = true; this.#failure = error; void this.close(); } @@ -195,7 +218,7 @@ const owners = new WeakMap(); class Face implements HydratedWire { readonly kind = 'wire' as const; constructor(endpoint: Endpoint) { owners.set(this, endpoint); Object.freeze(this); } - async send(value: HydratedValue): Promise { owners.get(this)!._admit(sendOnly(value), LOCAL); } + async send(value: HydratedValue): Promise { owners.get(this)!._admit(local(value), LOCAL); } } /** @@ -223,15 +246,6 @@ class Proxy implements HydratedWire { export interface Limits { exports: number; nodes: number; depth: number; bytes: number } export const DEFAULT_LIMITS: Limits = Object.freeze({ exports: 64, nodes: 4096, depth: 64, bytes: 1 << 20 }); -interface Budget { nodes: number; bytes: number } - -/** - * The node, depth and byte bounds count the hydrated value, identically when - * sending and receiving (D8): each atom, tuple and HydratedWire leaf is one node at its - * tuple depth; bytes are atom bytes plus each HydratedWire leaf's reference material. - */ -const refBytes = (path: Path): number => path.reduce((n, a) => n + a.length, 32); - /** One participant's hydration state for one incarnation (Terms). */ export class Scope { readonly path: Path; @@ -308,78 +322,65 @@ export class Scope { } } - #spend(b: Budget, depth: number, bytes: number): void { - b.nodes++; b.bytes += bytes; - if (b.nodes > this.#limits.nodes || depth > this.#limits.depth || b.bytes > this.#limits.bytes) throw new Refusal(LIMIT); - } - - /** Encodes atomically, registers new exports, then admits one frame (D5). */ + /** Stages the value and registers its new exports in one step, then admits one frame (D5). */ /** @internal */ async _send(dest: Reference, value: HydratedValue): Promise { if (this.#closed) throw new Refusal(SCOPE_ENDED); const staged = new Map(); - const body = this._encode(value, 0, staged, { nodes: 0, bytes: 0 }); - const frame = tuple([HEADER, dest.scope, dest.id, body]); - try { encodeMessage(frame, this.#limits.bytes); } catch { throw new Refusal(LIMIT); } + const body = this.#toData(value, 0, staged); + const frame = codec(() => packHydratedFrame({ scope: dest.scope, id: dest.id, body }, codecLimits(this.#limits))); this.#commit(staged); await this.#sender.send(dest.path, frame); } - /** @internal */ - _encode(v: HydratedValue, depth: number, staged: Map, b: Budget): Atom | Tuple { - if (v instanceof Atom) { this.#spend(b, depth, v.length); return v; } + /** + * Maps a live value onto the codec's structural data, staging an export for each + * new face. The codec checks every bound (D8); the depth guard only keeps a + * pathological value from exhausting the stack first. + */ + #toData(v: HydratedValue, depth: number, staged: Map): HydratedData { + if (depth > this.#limits.depth) throw new Refusal(LIMIT); + if (v instanceof Atom) return new HydratedDataAtom(v); if (v instanceof Tuple || v instanceof HydratedTuple) { - this.#spend(b, depth, 0); - return tuple([TUPLE_TAG, tuple(items(v)!.map((c) => this._encode(c, depth + 1, staged, b)))]); - } - if (v instanceof Endpoint) return this._encode(v.wire, depth, staged, b); - if (v instanceof Face) { - this.#spend(b, depth, refBytes(this.path)); - return refValue({ path: this.path, scope: this.token, id: this.#exportId(owners.get(v)!, staged) }); + return codec(() => new HydratedDataTuple(items(v)!.map((c) => this.#toData(c, depth + 1, staged)))); } + if (v instanceof Endpoint) return this.#toData(v.wire, depth, staged); + if (v instanceof Face) return codec(() => new HydratedReference(this.path, this.token, this.#exportId(owners.get(v)!, staged))); if (v instanceof Proxy) { if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); - this.#spend(b, depth, refBytes(v.ref.path)); - return refValue(v.ref); + return codec(() => new HydratedReference(v.ref.path, v.ref.scope, v.ref.id)); } if (v && typeof (v as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); throw new TypeError('hydrated: not a value'); } + /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). */ + #fromData(d: HydratedData, interned: Map): HydratedValue { + if (d instanceof HydratedDataAtom) return d.value; + if (d instanceof HydratedDataTuple) return hydratedTuple(d.items().map((c) => this.#fromData(c, interned))); + return this.#import({ path: d.path, scope: d.scope, id: d.id }, interned); + } + + /** @internal The body halves of a frame. */ + _encodeBody(v: HydratedValue, staged: Map): Atom | Tuple { + const data = this.#toData(v, 0, staged); + return codec(() => packHydratedBody(data, codecLimits(this.#limits))) as Atom | Tuple; + } + + /** @internal */ + _decodeBody(body: Atom | Tuple, interned: Map): HydratedValue { + return this.#fromData(codec(() => unpackHydratedBody(body, codecLimits(this.#limits))), interned); + } + /** Decides one value addressed to this participant's own path (D4); throws a Refusal as a host diagnostic. */ deliver(path: Path, message: Atom | Tuple, context: ReceivedContext): void { if (!pathEqual(path, this.path)) throw new Refusal(MALFORMED_FRAME); if (this.#closed) throw new Refusal(SCOPE_ENDED); - // The whole frame counts against the byte bound, reference material included (D8). - try { encodeMessage(message, this.#limits.bytes); } catch { throw new Refusal(LIMIT); } - if (!(message instanceof Tuple) || message.length !== 4 || !HEADER.equals(message.at(0)!)) throw new Refusal(MALFORMED_FRAME); - const [, token, id, body] = message.items(); - if (!(token instanceof Atom) || !(id instanceof Atom) || token.length !== 16 || id.length !== 16) throw new Refusal(MALFORMED_FRAME); - if (!token.equals(this.token)) throw new Refusal(STALE_SCOPE); - const target = this.#byId.get(hex(id)); + const frame = codec(() => unpackHydratedFrame(message, codecLimits(this.#limits))); + if (!frame.scope.equals(this.token)) throw new Refusal(STALE_SCOPE); + const target = this.#byId.get(hex(frame.id)); if (!target) throw new Refusal(UNKNOWN_EXPORT); - const value = this._decode(body!, 0, new Map(), { nodes: 0, bytes: 0 }); - target._admit(value, context); - } - - /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). */ - /** @internal */ - _decode(v: Atom | Tuple, depth: number, interned: Map, b: Budget): HydratedValue { - if (v instanceof Atom) { this.#spend(b, depth, v.length); return v; } - if (v.length !== 2) throw new Refusal(MALFORMED_FRAME); - const [tag, payload] = v.items(); - if (!(tag instanceof Atom) || tag.length !== 1) throw new Refusal(MALFORMED_FRAME); - if (tag.equals(TUPLE_TAG)) { - if (!(payload instanceof Tuple)) throw new Refusal(MALFORMED_FRAME); - this.#spend(b, depth, 0); - return hydratedTuple(payload.items().map((c) => this._decode(c, depth + 1, interned, b))); - } - if (tag.equals(WIRE_TAG)) { - const ref = readReference(payload!); - this.#spend(b, depth, refBytes(ref.path)); - return this.#import(ref, interned); - } - throw new Refusal(MALFORMED_FRAME); + target._admit(this.#fromData(frame.body, new Map()), context); } /** A reference home in the current scope returns the original face; stale or withdrawn, a HydratedWire that refuses (D7). */ @@ -397,15 +398,12 @@ export class Scope { } /** Reads a reference payload ((path...), scope, id). */ -/** Path segments are reference material: they count toward the byte bound, not as nodes or depth (D8). */ export function readReference(v: Atom | Tuple): Reference { - if (!(v instanceof Tuple) || v.length !== 3) throw new Refusal(MALFORMED_FRAME); - const [p, scope, id] = v.items(); - if (!(p instanceof Tuple)) throw new Refusal(MALFORMED_FRAME); - const path = p.items().map((s) => { if (!(s instanceof Atom)) throw new Refusal(MALFORMED_FRAME); return s; }); - if (!(scope instanceof Atom) || !(id instanceof Atom) || scope.length !== 16 || id.length !== 16) throw new Refusal(MALFORMED_FRAME); - return Object.freeze({ path: Object.freeze(path), scope, id }); + const r = codec(() => unpackHydratedReference(v, codecLimits(DEFAULT_LIMITS))); + return Object.freeze({ path: Object.freeze([...r.path]), scope: r.scope, id: r.id }); } /** The reference payload of a reference, for composition bootstrap over ground data. */ -export function referenceValue(ref: Reference): Tuple { return tuple([tuple(ref.path), ref.scope, ref.id]); } +export function referenceValue(ref: Reference): Atom | Tuple { + return codec(() => packHydratedReference(new HydratedReference(ref.path, ref.scope, ref.id), codecLimits(DEFAULT_LIMITS))) as Atom | Tuple; +} diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 85c20c8..fb123ea 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -20,13 +20,13 @@ test('observation 1: vectors round-trip (bitwire#83 accepted at 6e33fb3, pinned) const s = new Scope(new Namespace('vectors'), [text('vectors')], { send: async () => {} }); for (const c of v.encode) { const body = from(c.body); - const live = s._decode(body, 0, new Map(), { nodes: 0, bytes: 0 }); + const live = s._decodeBody(body, new Map()); assert.equal(wires(live), (c.live.match(/wire\{/g) ?? []).length, c.name); - const again = s._encode(live, 0, new Map(), { nodes: 0, bytes: 0 }); + const again = s._encodeBody(live, new Map()); assert.equal(Buffer.from(encodeMessage(again)).toString('hex'), c.hex, c.name); } for (const c of v.rejectBody) { - assert.throws(() => s._decode(from(c.value), 0, new Map(), { nodes: 0, bytes: 0 }), (e) => e.reason === MALFORMED_FRAME, c.name); + assert.throws(() => s._decodeBody(from(c.value), new Map()), (e) => e.reason === MALFORMED_FRAME, c.name); } }); @@ -167,8 +167,10 @@ for (const carrier of ['pair', 'websocket']) { assert.equal(pub.id.length, 16); assert.ok(!pub.id.equals(intended.id)); const bump = Uint8Array.from(pub.id.bytes()); bump[15]++; - const forged = [atom(bump), atom(new Uint8Array(16)), text('2'), text('0000000000000002')]; + const forged = [atom(bump), atom(new Uint8Array(16)), text('0000000000000002')]; for (const id of forged) await n.parts.client.scope.connect({ ...pub, id }).send(text('forged')).catch(() => {}); + // A counter-like id of the wrong length is refused before it leaves. + await assert.rejects(n.parts.client.scope.connect({ ...pub, id: text('2') }).send(text('forged')), (e) => e.reason === MALFORMED_FRAME); await eventually(() => n.diags.length === forged.length, 'forged references were not refused'); for (const d of n.diags) assert.ok(d.who === 'provider' && ['unknown-export', 'malformed-frame'].includes(d.reason), JSON.stringify(d)); assert.equal(privateHits, 1); @@ -270,3 +272,25 @@ test('observation 17, construction: the exported tuple class cannot bypass proje assert.equal(inner, priv.wire, 'a nested endpoint kept its authority'); assert.ok(hydratedTuple([text('a')]) instanceof Tuple, 'a tuple without a Wire is not ground'); }); + +test('observation 17, local edges: bounds, failure tracking and recognition on local sends', async () => { + for (const bad of [0, -1, 1.5, NaN, Infinity]) assert.throws(() => new Endpoint(bad), RangeError, String(bad)); + const silent = new Endpoint(1); + silent.receive(() => { throw undefined; }); + await silent.wire.send(text('x')); + assert.equal((await silent.closed).kind, 'failed', 'a receiver throwing undefined still failed'); + + const got = []; + const local = new Endpoint(8); + local.receive((v) => got.push(v)); + const foreign = { kind: 'wire', send: async () => {} }; + await assert.rejects(local.wire.send(foreign), (e) => e.reason === UNEXPORTABLE); + await assert.rejects(local.wire.send(hydratedTuple([text('a'), hydratedTuple([foreign])])), (e) => e.reason === UNEXPORTABLE); + const closed = new Endpoint(1); + const closedFace = closed.wire; + await closed.close(); + await assert.rejects(local.wire.send(hydratedTuple([closedFace])), (e) => e.reason === UNEXPORTABLE); + await local.wire.send(hydratedTuple([text('ok'), new Endpoint(1)])); + await eventually(() => got.length === 1, 'the valid value was not delivered'); + assert.equal(got.length, 1, 'a refused local value was delivered'); +}); From 982f64cfeb0f64f29e6bcd99684484f62d5de16d Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:13:57 +0200 Subject: [PATCH 14/21] hydrated: gate the hydrated interop; retire the superseded spike Fiber Composition's production-cut review of #44: - CI and the release workflow now run scripts/hydrated-interop.mjs after the build, so the Go/TypeScript hydrated exchange is a required gate in both, not only local evidence. - The PR40 hydration spike (spikes/hydration and its workflow) is removed under the clean-replacement rule; its history stays at 4ead83f. Its observations are covered on this candidate: the 13 hydration observations by decision 0019's observations 1-15 and 17 in Go and TypeScript; the 2 Slot composition cases by the 15-case composition suite; and its call-64 retention limit is superseded by observation 3 (200 calls, bounded exports). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/hydration-spike.yml | 27 --- .github/workflows/release.yml | 1 + hydrated/ts/test/COMPOSITION.md | 4 +- spikes/hydration/README.md | 224 ------------------ spikes/hydration/ts/.npmrc | 1 - spikes/hydration/ts/fetch-runtime.mjs | 20 -- spikes/hydration/ts/package-lock.json | 74 ------ spikes/hydration/ts/package.json | 19 -- spikes/hydration/ts/src/echo.ts | 51 ---- spikes/hydration/ts/src/hydration.ts | 183 -------------- spikes/hydration/ts/src/live.ts | 30 --- spikes/hydration/ts/src/slot.ts | 62 ----- spikes/hydration/ts/test/composition.test.mjs | 51 ---- spikes/hydration/ts/test/harness.mjs | 65 ----- spikes/hydration/ts/test/hydration.test.mjs | 175 -------------- spikes/hydration/ts/tsconfig.json | 14 -- 17 files changed, 4 insertions(+), 998 deletions(-) delete mode 100644 .github/workflows/hydration-spike.yml delete mode 100644 spikes/hydration/README.md delete mode 100644 spikes/hydration/ts/.npmrc delete mode 100644 spikes/hydration/ts/fetch-runtime.mjs delete mode 100644 spikes/hydration/ts/package-lock.json delete mode 100644 spikes/hydration/ts/package.json delete mode 100644 spikes/hydration/ts/src/echo.ts delete mode 100644 spikes/hydration/ts/src/hydration.ts delete mode 100644 spikes/hydration/ts/src/live.ts delete mode 100644 spikes/hydration/ts/src/slot.ts delete mode 100644 spikes/hydration/ts/test/composition.test.mjs delete mode 100644 spikes/hydration/ts/test/harness.mjs delete mode 100644 spikes/hydration/ts/test/hydration.test.mjs delete mode 100644 spikes/hydration/ts/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f59cb..f57c70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,4 @@ jobs: - run: npm test - run: node scripts/package-smoke.mjs - run: node scripts/interop.mjs + - run: node scripts/hydrated-interop.mjs diff --git a/.github/workflows/hydration-spike.yml b/.github/workflows/hydration-spike.yml deleted file mode 100644 index 359aafb..0000000 --- a/.github/workflows/hydration-spike.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: hydration-spike -on: - pull_request: - push: - branches: [main] - workflow_dispatch: -permissions: - contents: read -jobs: - experiment: - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - defaults: - run: - working-directory: spikes/hydration/ts - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - - run: npm run fetch - - run: npm ci --ignore-scripts - - run: npm run check - - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f539ef1..998feae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: - run: npm ci --ignore-scripts && npm run check && npm run build && npm test - run: node scripts/package-smoke.mjs - run: node scripts/interop.mjs + - run: node scripts/hydrated-interop.mjs - run: node scripts/go-smoke.mjs "$GITHUB_REF_NAME" - name: Pack release run: | diff --git a/hydrated/ts/test/COMPOSITION.md b/hydrated/ts/test/COMPOSITION.md index 28736e4..0dd7213 100644 --- a/hydrated/ts/test/COMPOSITION.md +++ b/hydrated/ts/test/COMPOSITION.md @@ -62,8 +62,8 @@ to the owning contract/implementation, not hidden by fixture fallback behavior. This is bounded evidence for these APIs, schedules and carriers. It does not establish a law for every generic type, arbitrary partitions, distributed collection, cross-namespace gateways or cross-language domain protocols. The -earlier `spikes/hydration` call-64 retention counterexample remains historical -evidence. The owning production change still requires its full Go/TypeScript, +earlier hydration spike's call-64 retention counterexample (bitruntime `4ead83f`, +since removed) remains historical evidence. The owning production change still requires its full Go/TypeScript, package and interoperability gates and independent review. Run `npm ci --ignore-scripts`, `npm run check`, and `npm test` at the repository diff --git a/spikes/hydration/README.md b/spikes/hydration/README.md deleted file mode 100644 index 8d8c52f..0000000 --- a/spikes/hydration/README.md +++ /dev/null @@ -1,224 +0,0 @@ -# Recursive live-Wire hydration spike - -**Status, 7 October 2026:** disposable TypeScript experiment for -[issue 39](https://github.com/Bitspark/bitruntime/issues/39). This is evidence for -the contract owners, not a released API, a production bitnode router, or an -accepted protocol. The root package exports and ontos value definitions are -unchanged. Production work still follows the -[composition plan](../../docs/COMPOSITION.md). - -## Question and owner direction - -The owner requested a spike of this fiber path: a programmatic API has a -domain-specific adapter to a hydrated Wire, whose messages may recursively -contain live Wires. Shared machinery turns those messages into addressed ground -ontos data. The bitnode network routes that data, and the destination reverses -the transformations. Domain adapters must not allocate references, maintain -export tables, declare reference positions or implement hydration themselves. - -```text -Echo API Echo provider - | domain adapter | domain adapter -live Wire + recursive live values live Wire + live values - | shared hydration | shared hydration -addressed ground ontos ---- opaque middle router ---- addressed ground ontos - | addressless Endpoint | addressless Endpoint - +-------------- local pair or WebSocket --------------+ -``` - -There are two physical hops, caller to middle node and middle node to provider. -The same domain and hydration code runs for both carrier arrangements. -[The domain fixture](ts/src/echo.ts) imports only the live-value representation -and ground Atom constructors; it knows nothing about routing or references. -[Hydration](ts/src/hydration.ts) accepts an AddressedWire and incoming dispatch. -The [test composition](ts/test/harness.mjs) owns Endpoint reception, routing and -scope lifetime. Every hop uses the released addressed facade over a raw Endpoint. - -## What the experiment establishes - -The caller adapter places a reply Wire inside a nested tuple. The provider -receives a usable live proxy and replies through it, including another Wire -in its response. That continuation then carries a third Wire in the opposite -direction. All four traversals use the same hydration machinery. The middle -node forwards a destination and opaque ground value; it creates no reference -entries and inspects no application data. - -A locally exported Wire returned to its originating runtime hydrates to the -original object. Repeated occurrences hydrate to the same imported proxy. -Forwarding an imported proxy preserves its reference rather than registering -a proxy of that proxy. Its representation is send-only: it grants neither -Endpoint receiving nor carrier close ownership. - -The 14 observations in [the hydration tests](ts/test/hydration.test.mjs) cover this -recursive exchange over local pairs and actual loopback WebSockets, data that -resembles a reference, aliasing, exact empty/binary/slash-containing path atoms, -replacement at the same route, withdrawal, carrier-bound scope cleanup, failed -encoding/decoding and finite value, registry and active-work limits. The stale -reference test deliberately reuses an export ID at the same route in a new -scope; it observes refusal and no call to the replacement target. - -Local Windows validation passed all 16 hydration and composition observations -with Node 24.21.0 after type checking. CI repeats both suites on Linux and -Windows. Exact reviewed commits and CI runs belong in the pull request; this source-level account does not imply -that a later revision has passed. Existing repository CI remains required. - -## Composing generic API adapters - -The owner's follow-up asks whether an adapter for `API3` can be reused for -`API3` by composing it with the adapter for `T`. The necessary step is to -lift the entity adapters through API3's operations: - -```text -adapter3 = adapter3_ composed with lift_API3(adapterT) -``` - -[The Slot fixture](ts/src/slot.ts) tests a stronger case than a read-only list: -`Slot` both returns T from `read()` and accepts T in `write(T)`. A produced T -uses `T -> LiveWire`; an accepted T needs `LiveWire -> T`. Its `adaptSlot` lifts -both directions. `slotToWire` only knows `Slot`, and is composed with -the independent entity adapters by `slotOfToWire`. - -[Two composition observations](ts/test/composition.test.mjs) use a separate Text -API as T, through the same two-hop tree over both carriers. Reading returns a -remotely callable Text. Writing passes a caller-provided Text to the provider, -which can call it and return it again. Neither adapter knows the other's -operation protocol or the hydration machinery. - -This is evidence for composition preserving the API's observations, not a proof -for every programmatic type. A covariant API such as a read-only collection -needs only the forward mapping; mixed input/output APIs need both directions. -Generic type syntax alone does not supply those mappings. Object identity and -byte-for-byte encoding equality are not implied by behavioral equivalence. - -## Experimental choices, not new upstream laws - -`LiveValue` is an extension **above** ontos: Atom, Tuple, a recursive LiveTuple, -or a LiveWire. A Wire is never an ontos ground Value. `LiveWire` is a deliberately -small nominal class for this experiment, not a decision about public types or -an additional production Wire interface. A future contract could express the -relationship using parameterized types; that deserves an upstream decision. - -Every live value is encoded as a tagged data node. Atoms and tuples are tagged -as data too, so a data tuple cannot accidentally become a capability because -of its contents. The disposable grammar is: - -```text -message = ("hydration-spike/1", destinationScope, destinationID, liveData) -liveData = (0x00, atom) - | (0x01, (liveData ...)) - | (0x02, ((ownerPath ...), ownerScope, exportID)) -``` - -All fields are ground ontos values; byte tags are one-byte atoms. The outer -destination is passed separately to AddressedWire.send. The addressed facade -then supplies its already-released data framing. These experimental bytes are -not proposed canonical conformance vectors. - -The production owner's alternative, ground data plus an explicit table of -reference positions, can also separate data from capabilities. It avoids a tag -around each ordinary data node but needs rules for duplicate/overlapping -positions and placeholders. This spike chooses the fully tagged form to make -the separation easy to inspect, not to settle that encoding tradeoff. Neither -form requires a new kind in ontos or scanning ordinary payload tuples for -reference-shaped values. - -A reference names an owner at an absolute path in one admitted tree, its -random 16-byte incarnation scope, and a never-reused ID within that scope. -The owner retains the target. All holders can route to that owner without -intermediate export tables. This is a concrete counterexample to requiring -per-hop re-export for every transit node. It supports end-to-end hydration -within the chosen namespace, not transparent rewriting across different -namespaces or live re-rooting. A gateway between namespaces needs an explicit -composition; the experiment does not pick its contract. - -## Lifetime and admission meaning - -The composition owns the hydration scope and closes it with its carrying -attachment. Closing the scope clears its imports and exports and makes its -proxies unusable. The export owner can withdraw a target without closing that -target; a future export gets a fresh ID. Work already admitted into a local -target is allowed to finish. Closing a scope does not cancel a domain operation. - -Imports are interned and retained until scope close; there is no remote release -message. A holder cannot safely revoke other holders just because it discards -its own proxy. Likewise, a lost intermediate connection does not by itself -prove that every holder of an end-to-end reference vanished. This implementation -uses bounded retention and explicit owner withdrawal as the experiment's -policy. Distributed release/collection for long-lived scopes remains a concrete -production requirement. It must be derived from ownership and delegation, not -hidden in the domain adapter or guessed from JavaScript garbage collection. - -**Observed limit:** with the fixture's default limits, one long-lived caller -using one callback completes 63 Echo calls; call 64 rejects with `export limit`. -Both ends then retain 64 exports and 64 imports. Every completed call has left -its reply and continuation associations in those scopes. The adapters have no -reference bookkeeping with which to end a finished reply. The regression -observation records this limit explicitly: bounded retention demonstrates the -construction but is not adequate reclamation for a long-running service. -The production contract must derive a usable target lifetime and import policy -while retaining the domain adapter boundary. - -Encoding and decoding stage registry changes until the entire value validates. -Exports are registered before bytes are admitted so a fast reply can find them. -A carrier send failure leaves those exports retained until owner cleanup; there -is no assumption that the provider has or has not acted. Registry sizes and -active sends/receives are finite, and each value has node, depth and byte limits. -The test router's traces and diagnostics are test observations, not production -queues or evidence of aggregate deployment resource bounds. - -`send` resolving means local admission under the carrying Wire contract. A -remote stale-reference refusal is a host diagnostic, not an automatically -correlated response to that Promise. The Echo adapter chooses a result message -and its own fixture timeout. Invocation, outcomes, cancellation and streaming -still belong to domain conventions above the communication layer. - -## What should change in the owning concepts - -| Owner | Finding and proposed follow-up | -| --- | --- | -| bitwire | Specify the recursive live-value/Wire boundary and its relation to ground Wire/AddressedWire, including unambiguous data encoding and reference lifetime. A manual export table alone does not fulfill the owner's fiber layering. Keep raw/addressed separation, exact byte paths and ontos ground meanings intact. | -| bitruntime | Realize generic hydration together with registries, imported send-only proxies, bounds and cleanup. Carriers remain raw Endpoints; reuse the addressed facade. Production Go and TypeScript, independent upstream cases and interoperability remain necessary. | -| bitnode | Compose those runtime facilities with the selected tree namespace, attachments and admission. Opaque transit routing does not require domain decoding or re-export at every hop. Its concrete routing/binding realization still needs qualification. | -| Domain consumers | Adapt typed APIs to live messages and choose operation/reply semantics. A nested reply Wire replaces manually assigned reply-path/correlation machinery for this fixture. No registry or serialized-reference API is required in that adapter. | - -This revises the incomplete export-only construction, not deixis's tree laws. -Addressing routes a message; hydration carries a live send capability as data -and reconstructs a local proxy. Multiplexed Endpoints remain a separate facility -when consumers need independent receive/close lifetimes on shared carriers. -This example needs no logical Endpoint per exported Wire. - -The tree in this experiment is explicitly admitted test composition. Neither -knowledge of a path nor a random scope authenticates an actor or authorizes a -domain operation. Production attachment/authority integration stays with -bitnode and native archon/thesmos; this test does not introduce another identity -or permission model. It also does not establish browser support, network -partitions, cross-process/cross-language hydration or production throughput. - -## Reproduce and inspected inputs - -From `spikes/hydration/ts`, with Node 24: - -```sh -npm run fetch -npm ci --ignore-scripts -npm run check -npm test -``` - -The private package pins bitwire 0.5.0 and downloads the public bitruntime -0.6.0 release artifact, checking both its pinned SHA-256 and release checksum -list. It imports no sibling checkout source and is excluded from the runtime's -published package. The download helper was written for this experiment. - -- bitwire 0.5.0 source: `5bf2737b16fa6ae8c1f5c955cdba658d5adce364`. -- bitruntime 0.6.0 source: `b19458f6f741a0f78729e89ef099bfd2869900a2`; - artifact SHA-256 `da651aee6f14cc5a1116af55ab1bd575650a562ea6efc08f14dc840cff8d9c19`. -- bitruntime composition baseline: `0bc971fd1512457d0591ace825e2ffd9dd0573cd`. -- The production owner's connection-scoped implementation was inspected at - `a383393` on `lane/02-3b-export`. Its table/importer work is useful lower-level - evidence but lacks recursive hydration. No code from that implementation is - copied here and its pending protocol is not adopted. -- Owner clarification and scope coordination are preserved in platform-board - topic record `40a333f0-cac3-4346-9e43-4136b0fd91f0` and owner response - `b810d7a2-b875-464f-ac22-2565072652f9`. The independent assessment supplied by - the owner agrees with this intended boundary; it is not runtime evidence. diff --git a/spikes/hydration/ts/.npmrc b/spikes/hydration/ts/.npmrc deleted file mode 100644 index c5b81e0..0000000 --- a/spikes/hydration/ts/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@bitspark:registry=https://registry.npmjs.org diff --git a/spikes/hydration/ts/fetch-runtime.mjs b/spikes/hydration/ts/fetch-runtime.mjs deleted file mode 100644 index dae8d14..0000000 --- a/spikes/hydration/ts/fetch-runtime.mjs +++ /dev/null @@ -1,20 +0,0 @@ -// Deliberately installs the immutable released runtime, not this checkout. -import { createHash } from 'node:crypto'; -import { mkdir, writeFile } from 'node:fs/promises'; - -const name = 'bitspark-bitruntime-0.6.0.tgz'; -const hash = 'da651aee6f14cc5a1116af55ab1bd575650a562ea6efc08f14dc840cff8d9c19'; -const base = 'https://github.com/Bitspark/bitruntime/releases/download/v0.6.0'; -const response = await fetch(`${base}/${name}`); -if (!response.ok) throw new Error(`Runtime download failed: ${response.status}`); -const bytes = new Uint8Array(await response.arrayBuffer()); -if (createHash('sha256').update(bytes).digest('hex') !== hash) throw new Error('Runtime hash mismatch'); -const sums = await fetch(`${base}/SHA256SUMS`); -if (!sums.ok) throw new Error(`Release sums unavailable: ${sums.status}`); -if (!(await sums.text()).split('\n').some(line => line.trim().split(/\s+\*?/).join(' ') === `${hash} ${name}`)) { - throw new Error('Release does not attest pinned runtime'); -} -const directory = new URL('./artifacts/', import.meta.url); -await mkdir(directory, { recursive: true }); -await writeFile(new URL(name, directory), bytes); -console.log(`${name} sha256 ${hash}`); diff --git a/spikes/hydration/ts/package-lock.json b/spikes/hydration/ts/package-lock.json deleted file mode 100644 index 52952ed..0000000 --- a/spikes/hydration/ts/package-lock.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "bitruntime-hydration-spike", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bitruntime-hydration-spike", - "dependencies": { - "@bitspark/bitruntime": "file:artifacts/bitspark-bitruntime-0.6.0.tgz", - "@bitspark/bitwire": "0.5.0" - }, - "devDependencies": { - "typescript": "5.9.3" - }, - "engines": { - "node": ">=24" - } - }, - "node_modules/@bitspark/bitruntime": { - "version": "0.6.0", - "resolved": "file:artifacts/bitspark-bitruntime-0.6.0.tgz", - "integrity": "sha512-p/8KiswnLqCERIBVyjumR2pytDbszuQh1o0PFV+54T8tZtbN6XCtMprJhyNYB+H0aKO6kbnfXd2Zwl3tC4Xdaw==", - "license": "Apache-2.0", - "dependencies": { - "@bitspark/bitwire": "0.5.0", - "ws": "8.22.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@bitspark/bitwire": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.5.0.tgz", - "integrity": "sha512-4nFgd/WfGmWTqlor6xKBcerSMqUwupZbd1sso4mRXCnUz/5r+EGjENoxiT+uZ7EzyRUjZNMBi9XtngAnQGXRRg==", - "license": "Apache-2.0" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/spikes/hydration/ts/package.json b/spikes/hydration/ts/package.json deleted file mode 100644 index 5c4d6fc..0000000 --- a/spikes/hydration/ts/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "bitruntime-hydration-spike", - "private": true, - "type": "module", - "engines": { "node": ">=24" }, - "scripts": { - "fetch": "node fetch-runtime.mjs", - "check": "tsc --noEmit", - "build": "tsc", - "test": "npm run build && node --test --test-timeout=15000 test/*.test.mjs" - }, - "dependencies": { - "@bitspark/bitruntime": "file:artifacts/bitspark-bitruntime-0.6.0.tgz", - "@bitspark/bitwire": "0.5.0" - }, - "devDependencies": { - "typescript": "5.9.3" - } -} diff --git a/spikes/hydration/ts/src/echo.ts b/spikes/hydration/ts/src/echo.ts deleted file mode 100644 index 6184dd1..0000000 --- a/spikes/hydration/ts/src/echo.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Domain adapter: no hydration, reference, registry, carrier, or routing imports. -import { atom, Atom } from '@bitspark/bitwire'; -import { live, wire, items, LiveWire, type LiveValue } from './live.js'; - -const ECHO = atom(new TextEncoder().encode('echo')); -const expectWire = (v: LiveValue | undefined) => { - if (!(v instanceof LiveWire)) throw new TypeError('Expected reply wire'); - return v; -}; - -export interface EchoResult { value: Atom; continuation: LiveWire; returned: LiveWire } -export interface Echo { echo(value: Atom, callback: LiveWire): Promise } - -/** Tiny domain fixture whose response carries a further Wire. */ -export function provideEcho(): LiveWire { - return wire(async message => { - const [operation, argument, nested] = items(message); - if (!(operation instanceof Atom) || !operation.equals(ECHO) || !(argument instanceof Atom) || !nested) throw new Error('bad echo'); - const [replyValue, callbackValue] = items(nested); - const reply = expectWire(replyValue), callback = expectWire(callbackValue); - const continuation = wire(async next => { - const [value, returnedReply] = items(next); - if (!value) throw new Error('bad continuation'); - await expectWire(returnedReply).send(value); - }); - await reply.send(live(argument, live(continuation, callback))); - }); -} - -export function echoFromWire(target: LiveWire): Echo { - return { - echo(value, callback) { - // This timeout is a fixture/domain policy, never a generic Wire deadline. - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('echo outcome unknown')), 3000); - const reply = wire(message => { - try { - const [answer, nested] = items(message); - if (!(answer instanceof Atom) || !nested) throw new Error('bad response'); - const [continuation, returned] = items(nested); - resolve({ value: answer, continuation: expectWire(continuation), returned: expectWire(returned) }); - } catch (error) { reject(error); } - finally { clearTimeout(timer); } - }); - void target.send(live(ECHO, value, live(reply, callback))).catch(error => { - clearTimeout(timer); reject(error); - }); - }); - } - }; -} diff --git a/spikes/hydration/ts/src/hydration.ts b/spikes/hydration/ts/src/hydration.ts deleted file mode 100644 index ebf654a..0000000 --- a/spikes/hydration/ts/src/hydration.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { atom, tuple, Atom, Tuple, type Value, type Path, type AddressedWire } from '@bitspark/bitwire'; -import { LiveWire, LiveTuple, type LiveValue } from './live.js'; - -// Disposable experiment grammar. These are not bitwire protocol/version names. -const HEADER = atom(new TextEncoder().encode('hydration-spike/1')); -const DATA = atom([0]), SEQUENCE = atom([1]), REFERENCE = atom([2]); -const hex = (a: Atom) => Array.from(a.bytes(), b => b.toString(16).padStart(2, '0')).join(''); -export const pathKey = (path: Path): string => JSON.stringify(path.map(hex)); - -export interface Reference { readonly path: Path; readonly scope: Atom; readonly id: Atom } -interface Entry { readonly reference: Reference; readonly target: LiveWire } -interface Limits { exports: number; imports: number; work: number; nodes: number; bytes: number; depth: number } -const defaults: Limits = { exports: 64, imports: 64, work: 16, nodes: 4096, bytes: 1024 * 1024, depth: 64 }; -const key = (r: Reference) => `${pathKey(r.path)}:${hex(r.scope)}:${hex(r.id)}`; -const fail = (message: string): never => { throw new Error(message); }; -const asTuple = (v: Value | undefined, length?: number): Tuple => - v instanceof Tuple && (length === undefined || v.length === length) ? v : fail('malformed tuple'); -const asAtom = (v: Value | undefined): Atom => v instanceof Atom ? v : fail('malformed atom'); - -/** One owner at an admitted absolute tree location; no carrier or domain knowledge. */ -export class Hydration { - readonly path: Path; - readonly scope = atom(crypto.getRandomValues(new Uint8Array(16))); - readonly #sender: AddressedWire; - readonly #limits: Limits; - readonly #exports = new Map(); - readonly #byWire = new Map(); - readonly #imports = new Map(); - readonly #proxyRefs = new WeakMap(); - #next = 0n; - #sending = 0; - #receiving = 0; - #closed = false; - - constructor(path: Path, sender: AddressedWire, limits: Partial = {}) { - this.path = Object.freeze([...path]); - this.#sender = sender; - this.#limits = { ...defaults, ...limits }; - for (const n of Object.values(this.#limits)) { - if (!Number.isSafeInteger(n) || n < 1) throw new RangeError('positive finite limits required'); - } - if (path.length > this.#limits.depth || path.some(a => !(a instanceof Atom))) fail('invalid owner path'); - } - - get retained() { return { exports: this.#exports.size, imports: this.#imports.size }; } - #open(): void { if (this.#closed) fail('scope ended'); } - - #newReference(): Reference { - return Object.freeze({ path: this.path, scope: this.scope, id: atom(new TextEncoder().encode(String(++this.#next))) }); - } - - /** Composition bootstrap only. Domain adapters never call this. */ - expose(target: LiveWire): Reference { - this.#open(); - const known = this.#byWire.get(target); - if (known) return known; - if (this.#exports.size >= this.#limits.exports) fail('export limit'); - const ref = this.#newReference(); - this.#retain(ref, target); - return ref; - } - - #retain(reference: Reference, target: LiveWire): void { - this.#exports.set(hex(reference.id), { reference, target }); - this.#byWire.set(target, reference); - } - - /** Explicit owner withdrawal; never closes the borrowed target. */ - withdraw(target: LiveWire): void { - const ref = this.#byWire.get(target); - if (ref) this.#exports.delete(hex(ref.id)); - this.#byWire.delete(target); - } - - /** Composition bootstrap only; normal imports happen during decoding. */ - connect(ref: Reference): LiveWire { - this.#open(); - return this.#import(this.#readReference(this.#writeReference(ref)), new Map(), true); - } - - #import(ref: Reference, staged: Map, commit = false): LiveWire { - if (pathKey(ref.path) === pathKey(this.path)) { - if (!ref.scope.equals(this.scope)) return fail('stale scope'); - return this.#exports.get(hex(ref.id))?.target ?? fail('unknown export'); - } - const name = key(ref); - const known = this.#imports.get(name) ?? staged.get(name); - if (known) return known; - if (this.#imports.size + staged.size >= this.#limits.imports) fail('import limit'); - const proxy = new LiveWire(value => this.#send(ref, value)); - this.#proxyRefs.set(proxy, ref); - if (commit) this.#imports.set(name, proxy); else staged.set(name, proxy); - return proxy; - } - - #writeReference(ref: Reference): Value { return tuple([tuple(ref.path), ref.scope, ref.id]); } - #readReference(value: Value): Reference { - const fields = asTuple(value, 3); - const path = asTuple(fields.at(0)).items().map(asAtom); - const scope = asAtom(fields.at(1)), id = asAtom(fields.at(2)); - if (path.length > this.#limits.depth || scope.length !== 16 || id.length === 0 || id.length > 64) fail('invalid reference'); - return Object.freeze({ path: Object.freeze(path), scope, id }); - } - - #budget(): (depth: number, bytes?: number) => void { - let nodes = 0, total = 0; - return (depth, bytes = 0) => { - if (++nodes > this.#limits.nodes || depth > this.#limits.depth || (total += bytes) > this.#limits.bytes) fail('value limit'); - }; - } - - async #send(destination: Reference, value: LiveValue): Promise { - this.#open(); - if (this.#sending >= this.#limits.work) fail('send limit'); - this.#sending++; - try { - const staged = new Map(); - const budget = this.#budget(); - const encode = (v: LiveValue, depth: number): Value => { - budget(depth, v instanceof Atom ? v.length : 0); - if (v instanceof Atom) return tuple([DATA, v]); - if (v instanceof LiveWire) { - let ref = this.#proxyRefs.get(v) ?? this.#byWire.get(v) ?? staged.get(v); - if (!ref) { - if (this.#exports.size + staged.size >= this.#limits.exports) fail('export limit'); - ref = this.#newReference(); - staged.set(v, ref); - } - for (const a of [...ref.path, ref.scope, ref.id]) budget(depth + 1, a.length); - return tuple([REFERENCE, this.#writeReference(ref)]); - } - const children = v instanceof Tuple ? v.items() : v instanceof LiveTuple ? v.values : fail('not a live value'); - return tuple([SEQUENCE, tuple(children.map(child => encode(child, depth + 1)))]); - }; - const encoded = encode(value, 0); - this.#open(); - // Register before admitting bytes, so immediate replies find the targets. - for (const [target, ref] of staged) this.#retain(ref, target); - await this.#sender.send(destination.path, tuple([HEADER, destination.scope, destination.id, encoded])); - } finally { this.#sending--; } - } - - /** Dispatcher owns reception and reports rejection as a host diagnostic. */ - async deliver(path: Path, message: Value): Promise { - this.#open(); - if (pathKey(path) !== pathKey(this.path)) fail('wrong owner route'); - if (this.#receiving >= this.#limits.work) fail('receive limit'); - this.#receiving++; - try { - const frame = asTuple(message, 4); - if (!HEADER.equals(asAtom(frame.at(0)))) fail('not a hydration frame'); - if (!this.scope.equals(asAtom(frame.at(1)))) fail('stale scope'); - const entry = this.#exports.get(hex(asAtom(frame.at(2)))) ?? fail('unknown export'); - const staged = new Map(); - const budget = this.#budget(); - const decode = (encoded: Value, depth: number): LiveValue => { - const fields = asTuple(encoded, 2), tag = asAtom(fields.at(0)), value = fields.at(1)!; - if (DATA.equals(tag)) { const a = asAtom(value); budget(depth, a.length); return a; } - budget(depth); - if (REFERENCE.equals(tag)) { - const ref = this.#readReference(value); - for (const a of [...ref.path, ref.scope, ref.id]) budget(depth + 1, a.length); - return this.#import(ref, staged); - } - if (!SEQUENCE.equals(tag)) return fail('unknown live tag'); - const children = asTuple(value).items().map(child => decode(child, depth + 1)); - return children.every(v => v instanceof Atom || v instanceof Tuple) ? tuple(children) : new LiveTuple(children); - }; - const value = decode(frame.at(3)!, 0); - this.#open(); - for (const [name, proxy] of staged) this.#imports.set(name, proxy); - await entry.target.send(value); - } finally { this.#receiving--; } - } - - /** Ends the whole bounded association, not the carrier or any borrowed Wire. */ - close(): void { - this.#closed = true; - this.#exports.clear(); - this.#byWire.clear(); - this.#imports.clear(); - } -} diff --git a/spikes/hydration/ts/src/live.ts b/spikes/hydration/ts/src/live.ts deleted file mode 100644 index 0767ad9..0000000 --- a/spikes/hydration/ts/src/live.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Atom, Tuple } from '@bitspark/bitwire'; - -/** Experimental recursive extension above Ontos. Ground Value is unchanged. */ -export type LiveValue = Atom | Tuple | LiveTuple | LiveWire; - -export class LiveTuple { - readonly values: readonly LiveValue[]; - constructor(values: readonly LiveValue[]) { - this.values = Object.freeze([...values]); - Object.freeze(this); - } -} - -/** A send-only live capability. It grants no receive/close or registry access. */ -export class LiveWire { - readonly send: (value: LiveValue) => Promise; - constructor(send: (value: LiveValue) => void | Promise) { - this.send = async value => send(value); - Object.freeze(this); - } -} - -export const live = (...values: LiveValue[]): LiveTuple => new LiveTuple(values); -export const wire = (send: (value: LiveValue) => void | Promise): LiveWire => new LiveWire(send); - -export function items(value: LiveValue): readonly LiveValue[] { - if (value instanceof Tuple) return value.items(); - if (value instanceof LiveTuple) return value.values; - throw new TypeError('Expected tuple'); -} diff --git a/spikes/hydration/ts/src/slot.ts b/spikes/hydration/ts/src/slot.ts deleted file mode 100644 index c1ca7ba..0000000 --- a/spikes/hydration/ts/src/slot.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Consumer fixture: an invariant generic API, with T in both input and output. -// No export table, reference grammar, routing or carrier appears here. -import { atom, Atom } from '@bitspark/bitwire'; -import { live, wire, items, LiveWire, type LiveValue } from './live.js'; - -export interface Slot { read(): Promise; write(value: T): Promise } -export interface Adapters { to(value: A): B; from(value: B): A } - -/** Lift both directions through Slot. A covariant map alone cannot do this. */ -export function adaptSlot(source: Slot, adapters: Adapters): Slot { - return { - read: async () => adapters.to(await source.read()), - write: value => source.write(adapters.from(value)), - }; -} - -const READ = atom([0]), WRITE = atom([1]), OK = atom([2]); -function requireWire(value: LiveValue | undefined): LiveWire { - if (!(value instanceof LiveWire)) throw new Error('expected live Wire'); - return value; -} - -/** adapter3_: Slot -> LiveWire; knows only Slot's operation meaning. */ -export function slotToWire(source: Slot): LiveWire { - return wire(async message => { - const [op, response, value] = items(message), reply = requireWire(response); - if (!(op instanceof Atom)) throw new Error('invalid Slot operation'); - if (op.equals(READ)) await reply.send(await source.read()); - else if (op.equals(WRITE)) { await source.write(requireWire(value)); await reply.send(OK); } - else throw new Error('unknown Slot operation'); - }); -} - -/** Domain-local exchange policy for the fixture, not a shared Wire RPC rule. */ -async function exchange(target: LiveWire, op: Atom, value?: LiveWire): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Slot outcome unknown')), 3000); - const reply = wire(result => { clearTimeout(timer); resolve(result); }); - void target.send(value ? live(op, reply, value) : live(op, reply)).catch(error => { - clearTimeout(timer); reject(error); - }); - }); -} - -export function slotFromWire(target: LiveWire): Slot { - return { - read: async () => requireWire(await exchange(target, READ)), - write: async value => { - const result = await exchange(target, WRITE, value); - if (!(result instanceof Atom) || !result.equals(OK)) throw new Error('invalid Slot response'); - }, - }; -} - -/** adapter3 = adapter3_ composed with the lifting of the entity adapters. */ -export function slotOfToWire(source: Slot, adapters: Adapters): LiveWire { - return slotToWire(adaptSlot(source, adapters)); -} - -export function slotOfFromWire(target: LiveWire, adapters: Adapters): Slot { - return adaptSlot(slotFromWire(target), { to: adapters.from, from: adapters.to }); -} diff --git a/spikes/hydration/ts/test/composition.test.mjs b/spikes/hydration/ts/test/composition.test.mjs deleted file mode 100644 index aee261f..0000000 --- a/spikes/hydration/ts/test/composition.test.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { Atom } from '@bitspark/bitwire'; -import { wire, LiveWire } from '../dist/live.js'; -import { slotOfToWire, slotOfFromWire } from '../dist/slot.js'; -import { tree, bytes } from './harness.mjs'; - -// Leaf API: Text = { read(): Promise }. These adapters know only Text. -function textToWire(source) { - return wire(async reply => { - assert.ok(reply instanceof LiveWire); - await reply.send(await source.read()); - }); -} -function textFromWire(target) { - return { - read: () => new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Text outcome unknown')), 3000); - const reply = wire(value => { - clearTimeout(timer); - if (value instanceof Atom) resolve(value); else reject(new Error('invalid Text value')); - }); - void target.send(reply).catch(error => { clearTimeout(timer); reject(error); }); - }), - }; -} -const textAdapters = { to: textToWire, from: textFromWire }; - -for (const carrier of ['pair', 'websocket']) { - test(`${carrier}: Slot composes Slot and Text adapters in both directions`, async t => { - const network = await tree(t, carrier); - let selected = { read: async () => bytes('provider Text') }; - const source = { - read: async () => selected, - write: async value => { selected = value; }, - }; - const service = slotOfToWire(source, textAdapters); - const ref = network.right.expose(service); // composition bootstrap only - const remote = slotOfFromWire(network.left.connect(ref), textAdapters); - - // Covariant occurrence: read returns a Text adapter over the nested Wire. - assert.ok((await (await remote.read()).read()).equals(await (await source.read()).read())); - - // Contravariant occurrence: write imports a client-provided Text at the far end. - const clientText = { read: async () => bytes('caller Text') }; - await remote.write(clientText); - assert.ok((await (await source.read()).read()).equals(await clientText.read())); - assert.ok((await (await remote.read()).read()).equals(await clientText.read())); - assert.deepEqual(network.faults, []); - }); -} diff --git a/spikes/hydration/ts/test/harness.mjs b/spikes/hydration/ts/test/harness.mjs deleted file mode 100644 index ce5820e..0000000 --- a/spikes/hydration/ts/test/harness.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import { pair, addressed } from '@bitspark/bitruntime/core'; -import { listenWebSocket, connectWebSocket } from '@bitspark/bitruntime/websocket'; -import { atom, isValue } from '@bitspark/bitwire'; -import { Hydration, pathKey } from '../dist/hydration.js'; - -export const bytes = text => atom(new TextEncoder().encode(text)); -export const leftPath = [atom([]), atom([0, 255, 47])]; -export const rightPath = [bytes('branch/inside-one-atom'), atom([255, 0])]; - -async function link(t, carrier) { - if (carrier === 'pair') { - const endpoints = pair(); - t.after(() => endpoints[0].close()); - return endpoints; - } - const accepted = Promise.withResolvers(); - const listener = await listenWebSocket({ host: '127.0.0.1' }, accepted.resolve); - t.after(() => listener.close()); - const client = await connectWebSocket(listener.url); - t.after(() => client.close()); - const server = await accepted.promise; - t.after(() => server.close()); - return [client, server]; -} - -// This middle node knows only routes and ground values. It does not decode the -// payload, instantiate hydration, or hold per-Wire/per-invocation state. -function forward(path, value, routes, trace, faults) { - assert.ok(isValue(value)); - trace.push({ path, value }); - const destination = routes.get(pathKey(path)); - if (!destination) { faults.push(new Error('missing route')); return; } - void destination.send(path, value).catch(error => faults.push(error)); -} - -export async function tree(t, carrier) { - const [a, middleA] = (await link(t, carrier)).map(addressed); - const [c, middleC] = (await link(t, carrier)).map(addressed); - const trace = [], faults = []; - const routes = new Map([[pathKey(leftPath), middleA], [pathKey(rightPath), middleC]]); - middleA.receive((p, v) => forward(p, v, routes, trace, faults)); - middleC.receive((p, v) => forward(p, v, routes, trace, faults)); - const left = new Hydration(leftPath, a); - let right = new Hydration(rightPath, c); - a.receive((p, v) => { void left.deliver(p, v).catch(error => faults.push(error)); }); - c.receive((p, v) => { void right.deliver(p, v).catch(error => faults.push(error)); }); - // Scope ownership belongs to composition, not domain adapters or borrowed Wires. - void a.closed.then(() => left.close()); - void c.closed.then(() => right.close()); - t.after(() => { left.close(); right.close(); }); - return { - left, get right() { return right; }, trace, faults, - replaceRight() { right.close(); right = new Hydration(rightPath, c); return right; }, - closeCarrier: () => a.close(), - }; -} - -export async function until(predicate) { - const deadline = Date.now() + 3000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error('observation did not arrive'); - await new Promise(resolve => setTimeout(resolve, 5)); - } -} diff --git a/spikes/hydration/ts/test/hydration.test.mjs b/spikes/hydration/ts/test/hydration.test.mjs deleted file mode 100644 index a002db3..0000000 --- a/spikes/hydration/ts/test/hydration.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { atom, tuple, Tuple, isValue } from '@bitspark/bitwire'; -import { Hydration } from '../dist/hydration.js'; -import { live, wire, items, LiveWire } from '../dist/live.js'; -import { provideEcho, echoFromWire } from '../dist/echo.js'; -import { tree, bytes, leftPath, rightPath, until } from './harness.mjs'; - -for (const carrier of ['pair', 'websocket']) { - test(`${carrier}: nested reply and continuation Wires cross an opaque router`, async t => { - const network = await tree(t, carrier); - const callback = wire(() => {}); - const service = network.right.expose(provideEcho()); // composition only - const api = echoFromWire(network.left.connect(service)); - const request = atom([0, 255, 10, 47]); - const result = await api.echo(request, callback); - assert.ok(result.value.equals(request)); - assert.equal(result.returned, callback, 'returning home restores the original object'); - assert.ok(result.continuation instanceof LiveWire); - assert.deepEqual(Object.keys(result.continuation), ['send']); - assert.equal('close' in result.continuation, false); - assert.equal('receive' in result.continuation, false); - assert.equal(isValue(result.continuation), false); - let delivered; - await result.continuation.send(live(bytes('third Wire'), wire(v => { delivered = v; }))); - await until(() => delivered !== undefined); - assert.ok(delivered.equals(bytes('third Wire'))); - assert.equal(network.trace.length, 4); - assert.ok(network.trace.every(entry => isValue(entry.value))); - assert.deepEqual(network.trace.map(entry => entry.path), [rightPath, leftPath, rightPath, leftPath]); - assert.deepEqual(network.faults, []); - assert.equal(network.right.retained.exports, 2, 'forwarded proxies are not re-exported'); - }); - - test(`${carrier}: reference-shaped data stays data; aliases stay aliases`, async t => { - const network = await tree(t, carrier); - let observed; - const destination = network.right.expose(wire(v => { observed = v; })); - const target = network.left.connect(destination); - const local = wire(() => {}); - const ref = network.left.expose(local); - const lookalike = tuple([bytes('hydration-spike/1'), tuple(ref.path), ref.scope, ref.id]); - const taggedLookalike = tuple([atom([2]), tuple([tuple(ref.path), ref.scope, ref.id])]); - await target.send(live(lookalike, taggedLookalike, local, live(local))); - await until(() => observed !== undefined); - const [ordinary, tagged, first, nested] = items(observed); - assert.ok(ordinary instanceof Tuple && ordinary.equals(lookalike)); - assert.ok(tagged instanceof Tuple && tagged.equals(taggedLookalike)); - assert.equal(first, items(nested)[0]); - assert.equal(network.right.retained.imports, 1); - assert.equal(network.left.retained.exports, 1); - assert.deepEqual(network.faults, []); - }); - - test(`${carrier}: stale scope cannot reach replacement at the same byte path`, async t => { - const network = await tree(t, carrier); - let oldCalls = 0, newCalls = 0; - const oldTarget = wire(() => { oldCalls++; }); - const oldReference = network.right.expose(oldTarget); - const oldProxy = network.left.connect(oldReference); - const replacement = network.replaceRight(); - const newReference = replacement.expose(wire(() => { newCalls++; })); - assert.ok(oldReference.id.equals(newReference.id), 'IDs may repeat in a different incarnation'); - assert.ok(!oldReference.scope.equals(newReference.scope)); - await oldProxy.send(bytes('late')); // local admission, not remote execution - await until(() => network.faults.length === 1); - assert.match(network.faults[0].message, /stale scope/); - assert.equal(newCalls, 0); - await network.left.connect(newReference).send(bytes('fresh')); - await until(() => newCalls === 1); - await oldTarget.send(bytes('still borrowed')); - assert.equal(oldCalls, 1, 'ending export scope does not close its target'); - }); - - test(`${carrier}: owner withdrawal and carrier loss end associations, not borrowed targets`, async t => { - const network = await tree(t, carrier); - let calls = 0; - const original = wire(() => { calls++; }); - const ref = network.right.expose(original), proxy = network.left.connect(ref); - network.right.withdraw(original); - const next = network.right.expose(original); - assert.ok(!ref.id.equals(next.id), 'withdrawn IDs are never reused in the scope'); - await proxy.send(bytes('retired')); - await until(() => network.faults.length === 1); - assert.match(network.faults[0].message, /unknown export/); - assert.equal(calls, 0); - await network.closeCarrier(); - await assert.rejects(proxy.send(bytes('closed')), /scope ended/); - assert.deepEqual(network.left.retained, { imports: 0, exports: 0 }); - await original.send(bytes('owned elsewhere')); - assert.equal(calls, 1); - }); -} - -const reference = () => ({ path: rightPath, scope: atom(new Uint8Array(16)), id: bytes('1') }); - -test('long-lived Echo stops at call 64: scope retention is not production reclamation', async t => { - const network = await tree(t, 'pair'); - const callback = wire(() => {}); - const api = echoFromWire(network.left.connect(network.right.expose(provideEcho()))); - for (let i = 0; i < 63; i++) await api.echo(bytes('retained until scope close'), callback); - await assert.rejects(api.echo(bytes('64th call'), callback), /export limit/); - assert.deepEqual(network.left.retained, { exports: 64, imports: 64 }); - assert.deepEqual(network.right.retained, { exports: 64, imports: 64 }); - assert.deepEqual(network.faults, []); -}); - -test('failed encoding is atomic; registry limits apply to nested and distinct Wires', async () => { - const sent = []; - const host = new Hydration(leftPath, { send: async (p, v) => { sent.push([p, v]); } }, { exports: 1 }); - const proxy = host.connect(reference()); - const a = wire(() => {}), b = wire(() => {}); - await assert.rejects(proxy.send(live(a, live(b))), /export limit/); - assert.deepEqual(host.retained, { exports: 0, imports: 1 }); - assert.equal(sent.length, 0); - await proxy.send(live(a, a)); - assert.equal(host.retained.exports, 1); - assert.equal(sent.length, 1); - host.close(); -}); - -test('value and pending-send bounds reject before adding unbounded work', async () => { - const pending = Promise.withResolvers(); - const host = new Hydration(leftPath, { send: () => pending.promise }, { work: 1, nodes: 8, bytes: 40, depth: 3 }); - const proxy = host.connect(reference()); - await assert.rejects(proxy.send(atom(new Uint8Array(41))), /value limit/); - const deep = live(live(live(live(bytes('deep'))))); - await assert.rejects(proxy.send(deep), /value limit/); - const first = proxy.send(bytes('one')); - await assert.rejects(proxy.send(bytes('two')), /send limit/); - pending.resolve(); - await first; - host.close(); -}); - -test('malformed trailing data rolls back newly staged imports', async () => { - let calls = 0; - const host = new Hydration(leftPath, { send: async () => {} }); - const destination = host.expose(wire(() => { calls++; })); - const ref = reference(); - const encodedRef = tuple([atom([2]), tuple([tuple(ref.path), ref.scope, ref.id])]); - const invalid = tuple([atom([99]), tuple([])]); - const message = tuple([bytes('hydration-spike/1'), destination.scope, destination.id, - tuple([atom([1]), tuple([encodedRef, invalid])])]); - await assert.rejects(host.deliver(leftPath, message), /unknown live tag/); - assert.equal(host.retained.imports, 0); - assert.equal(calls, 0); - host.close(); -}); - -test('import and in-flight receiver limits are bounded independently of carrier', async () => { - const pending = Promise.withResolvers(); - const host = new Hydration(leftPath, { send: async () => {} }, { imports: 1, work: 1 }); - const destination = host.expose(wire(() => pending.promise)); - const ref = reference(); - host.connect(ref); - assert.throws(() => host.connect({ ...ref, id: bytes('2') }), /import limit/); - const message = tuple([bytes('hydration-spike/1'), destination.scope, destination.id, - tuple([atom([0]), bytes('work')])]); - const admitted = host.deliver(leftPath, message); - await assert.rejects(host.deliver(leftPath, message), /receive limit/); - host.close(); - pending.resolve(); - await admitted; // already admitted work is not canceled by scope close - assert.deepEqual(host.retained, { exports: 0, imports: 0 }); -}); - -test('transport rejection retains admitted exports until owner cleanup', async () => { - const host = new Hydration(leftPath, { send: async () => { throw new Error('carrier ended'); } }); - const proxy = host.connect(reference()); - await assert.rejects(proxy.send(wire(() => {})), /carrier ended/); - assert.equal(host.retained.exports, 1); - host.close(); - assert.equal(host.retained.exports, 0); -}); diff --git a/spikes/hydration/ts/tsconfig.json b/spikes/hydration/ts/tsconfig.json deleted file mode 100644 index d320167..0000000 --- a/spikes/hydration/ts/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "noUncheckedIndexedAccess": true, - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} From dcb26dddaf39118764632bd05bf45e15ad904e6d Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:23:37 +0200 Subject: [PATCH 15/21] hydrated: bound live-to-data conversion before allocation; iterative in TS Wire & Runtime and Fiber Composition found that the codec join expanded a compact value occurrence by occurrence before the shared codec could refuse it: 15 shared tuples under nodes=4 cost 32767 traversals, and 40 levels of tuple(v, v) attempt about 2^41 nodes. - Conversion counts each occurrence as D8 does (one node per atom, tuple and Wire leaf at its tuple depth; atom bytes plus reference material) and refuses with limit before allocating. The probe now stops after 4 traversals. The codec stays authoritative for every bound. - TypeScript conversion is iterative in both directions, so a deep value within the configured depth cannot exhaust the stack (a 15000-deep value round-trips). - Tuples recognize their leaves at construction (D1): hydratedTuple and NewTuple refuse a foreign Wire as unexportable. Being immutable, a tuple needs no traversal on local admission, which now checks only the top level. A closed face may be delivered locally; its sends are refused by its endpoint. New observation 15 cases in both languages: a compact shared tree is refused quickly with no export committed and nothing sent (and delivered locally without a traversal); a deep permitted value round-trips. TS 57/57, go test -race, interop both roles. Co-Authored-By: Claude Opus 5.5 --- hydrated/go/hydrated.go | 81 ++++++++++++++-------- hydrated/go/hydrated_test.go | 72 +++++++++++++++++--- hydrated/ts/src/index.ts | 106 ++++++++++++++++++++--------- hydrated/ts/test/hydrated.test.mjs | 46 +++++++++++-- 4 files changed, 231 insertions(+), 74 deletions(-) diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index 0ec65c8..829b1dd 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -68,8 +68,10 @@ func NewTuple(items ...wire.HydratedValue) (wire.HydratedValue, error) { ground = append(ground, x) case ontos.Tuple: ground = append(ground, x) - case Tuple, wire.HydratedWire: + case Tuple, *face, *proxy: live = true + case wire.HydratedWire: + return nil, Unexportable // only the runtime's own Wires are leaves (D1) default: return nil, errors.New("hydrated: not a value") } @@ -191,22 +193,11 @@ func (f *face) Send(v wire.HydratedValue) error { } // checkLocal validates a value for local delivery as dehydration would for a -// remote one (D1, D7): only runtime-recognized leaves arrive. +// remote one (D1, D7). A Tuple recognized its leaves when NewTuple built it and +// is immutable, so only the top level needs checking; nothing is traversed. func checkLocal(v wire.HydratedValue) error { - switch x := v.(type) { - case ontos.Atom, ontos.Tuple, *proxy: - return nil - case Tuple: - for _, item := range x.items { - if err := checkLocal(item); err != nil { - return err - } - } - return nil - case *face: - if x.e.isClosed() { - return Unexportable - } + switch v.(type) { + case ontos.Atom, ontos.Tuple, Tuple, *face, *proxy: return nil case wire.HydratedWire: return Unexportable @@ -575,7 +566,7 @@ func (s *Scope) stage(dest Reference, v wire.HydratedValue) (ontos.Value, error) s.encodeMu.Lock() defer s.encodeMu.Unlock() staged := map[*Endpoint]ontos.Atom{} - body, err := s.toData(v, 0, staged) + body, err := s.toData(v, 0, staged, &budget{}) if err != nil { return nil, err } @@ -586,30 +577,59 @@ func (s *Scope) stage(dest Reference, v wire.HydratedValue) (ontos.Value, error) return frame, s.commit(staged) } -// toData maps a live value onto the codec's structural data, staging an export -// for each new face. The codec then checks every bound (D8); the depth guard -// here only keeps a pathological value from exhausting the stack first. -func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ontos.Atom) (wire.HydratedData, error) { - if depth > s.limits.Depth { - return nil, Limit +// budget counts a value as D8 does, while converting it. +type budget struct{ nodes, bytes int } + +// refBytes is a Wire leaf's value bytes under D8: its path bytes and 32. +func refBytes(path wire.Path) int { + n := 32 + for _, a := range path { + n += a.Len() + } + return n +} + +func (s *Scope) spend(b *budget, depth, n int) error { + b.nodes++ + b.bytes += n + if b.nodes > s.limits.Nodes || depth > s.limits.Depth || b.bytes > s.limits.Bytes { + return Limit } + return nil +} + +// toData maps a live value onto the codec's structural data, staging an export +// for each new face. It counts each occurrence as D8 does and refuses before +// allocating, so a compact value with shared subtrees cannot expand past the +// bounds. The codec stays authoritative for every bound. +func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ontos.Atom, b *budget) (wire.HydratedData, error) { switch x := v.(type) { case ontos.Atom: + if err := s.spend(b, depth, x.Len()); err != nil { + return nil, err + } return wire.NewHydratedDataAtom(x), nil case ontos.Tuple, Tuple: + if err := s.spend(b, depth, 0); err != nil { + return nil, err + } items, _ := Items(x) - out := make([]wire.HydratedData, len(items)) - for i, item := range items { - var err error - if out[i], err = s.toData(item, depth+1, staged); err != nil { + out := make([]wire.HydratedData, 0, len(items)) + for _, item := range items { + d, err := s.toData(item, depth+1, staged, b) + if err != nil { return nil, err } + out = append(out, d) } t, err := wire.NewHydratedDataTuple(out...) return t, refusal(err) case *Endpoint: - return s.toData(x.face, depth, staged) + return s.toData(x.face, depth, staged, b) case *face: + if err := s.spend(b, depth, refBytes(s.path)); err != nil { + return nil, err + } id, err := s.exportID(x.e, staged) if err != nil { return nil, err @@ -620,6 +640,9 @@ func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ont if x.ns != s.ns { return nil, ForeignNamespace } + if err := s.spend(b, depth, refBytes(x.ref.Path)); err != nil { + return nil, err + } r, err := x.ref.data() return r, refusal(err) case wire.HydratedWire: @@ -652,7 +675,7 @@ func (s *Scope) fromData(d wire.HydratedData, interned map[string]wire.HydratedW // encodeBody and decodeBody are the body halves of a frame. func (s *Scope) encodeBody(v wire.HydratedValue, staged map[*Endpoint]ontos.Atom) (ontos.Value, error) { - d, err := s.toData(v, 0, staged) + d, err := s.toData(v, 0, staged, &budget{}) if err != nil { return nil, err } diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index dcf44a2..e8368e9 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -507,7 +507,11 @@ func TestObservation6RefusalsBeforeAdmission(t *testing.T) { _, svc := serve(t, b, func(wire.HydratedValue, wire.ReceivedContext) {}) target := a.Connect(svc) kept := NewEndpoint(1) - if err := target.Send(tuple(t, kept, funcWire(func(wire.HydratedValue) error { return nil }))); !errors.Is(err, Unexportable) { + foreignWire := funcWire(func(wire.HydratedValue) error { return nil }) + if _, err := NewTuple(kept, foreignWire); !errors.Is(err, Unexportable) { + t.Fatalf("a tuple built with a foreign Wire: %v", err) + } + if err := target.Send(foreignWire); !errors.Is(err, Unexportable) { t.Fatalf("arbitrary wire.HydratedWire: %v", err) } other, _ := NewScope(NewNamespace("other"), wire.Path{text("x")}, nil, DefaultLimits) @@ -1000,13 +1004,8 @@ func TestObservation17LocalEdges(t *testing.T) { if err := local.Wire().Send(foreign); !errors.Is(err, Unexportable) { t.Fatalf("a bare foreign Wire: %v", err) } - if err := local.Wire().Send(tuple(t, text("a"), tuple(t, foreign))); !errors.Is(err, Unexportable) { - t.Fatalf("a nested foreign Wire: %v", err) - } - closed := NewEndpoint(1) - _ = closed.Close() - if err := local.Wire().Send(tuple(t, closed.Wire())); !errors.Is(err, Unexportable) { - t.Fatalf("a closed face: %v", err) + if _, err := NewTuple(text("a"), foreign); !errors.Is(err, Unexportable) { + t.Fatalf("a tuple built with a foreign Wire: %v", err) } if err := local.Wire().Send(tuple(t, text("ok"), NewEndpoint(1))); err != nil { t.Fatal(err) @@ -1018,3 +1017,60 @@ func TestObservation17LocalEdges(t *testing.T) { case <-time.After(50 * time.Millisecond): } } + +// Observation 15, shared trees: a compact value whose subtrees are shared counts +// every occurrence, and is refused before it can expand: no export is +// committed and nothing is sent. +func TestObservation15SharedTreeRefusedBeforeExpansion(t *testing.T) { + rec := &recordingSender{} + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, rec, DefaultLimits) + target := s.Connect(Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + v := tuple(t, NewEndpoint(1)) + for i := 0; i < 40; i++ { // 2^40 occurrences from 41 distinct tuples + v = tuple(t, v, v) + } + start := time.Now() + if err := target.Send(v); !errors.Is(err, Limit) { + t.Fatalf("a shared tree: %v", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("refusal took %v; the tree was expanded", elapsed) + } + if s.Live() != 0 || len(rec.frames) != 0 { + t.Fatalf("a refused send left %d exports and %d frames", s.Live(), len(rec.frames)) + } + // Locally it is delivered as built, without a traversal. + local := NewEndpoint(1) + got := make(chan wire.HydratedValue, 1) + _, _ = local.Receive(func(x wire.HydratedValue, _ wire.ReceivedContext) { got <- x }) + if err := local.Wire().Send(v); err != nil { + t.Fatal(err) + } + <-got +} + +// Observation 15, depth: a deep value within the configured bounds is sent and +// received whole. +func TestObservation15DeepPermittedValue(t *testing.T) { + limits := Limits{Exports: 4, Nodes: 50000, Depth: 20000, Bytes: 1 << 22} + rec := &recordingSender{} + sender, _ := NewScope(NewNamespace("x"), wire.Path{text("s")}, rec, limits) + receiver, _ := NewScope(NewNamespace("x"), wire.Path{text("r")}, nil, limits) + target := NewEndpoint(1) + got := make(chan wire.HydratedValue, 1) + _, _ = target.Receive(func(x wire.HydratedValue, _ wire.ReceivedContext) { got <- x }) + ref, _ := receiver.Expose(target) + v := wire.HydratedValue(text("leaf")) + for i := 0; i < 15000; i++ { + v = tuple(t, v) + } + if err := sender.Connect(ref).Send(v); err != nil { + t.Fatal(err) + } + if err := receiver.Deliver(receiver.path, rec.frames[0], nil); err != nil { + t.Fatal(err) + } + if !(<-got).(ontos.Tuple).Equal(v.(ontos.Value)) { + t.Fatal("the deep value changed") + } +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index 15e00ca..a7dc6c0 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -66,17 +66,13 @@ const sendOnly = (v: HydratedValue): HydratedValue => (v instanceof Endpoint ? v * D7): only runtime-recognized leaves arrive, and an endpoint arrives as its face. */ function local(v: HydratedValue): HydratedValue { - const check = (x: HydratedValue): void => { - if (x instanceof Atom || x instanceof Tuple) return; - if (x instanceof HydratedTuple) { x.items().forEach(check); return; } - if (x instanceof Face) { if (owners.get(x)!.isClosed) throw new Refusal(UNEXPORTABLE); return; } - if (x instanceof Proxy) return; - if (x && typeof (x as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); - throw new TypeError('hydrated: not a value'); - }; + // A tuple recognized its leaves when it was built and is immutable, so only + // the top level needs checking; no traversal is needed. const projected = sendOnly(v); - check(projected); - return projected; + if (projected instanceof Atom || projected instanceof Tuple || projected instanceof HydratedTuple) return projected; + if (projected instanceof Face || projected instanceof Proxy) return projected; + if (projected && typeof (projected as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); + throw new TypeError('hydrated: not a value'); } /** Captures items; returns the ground tuple when no HydratedWire is beneath them. */ @@ -84,7 +80,8 @@ export function hydratedTuple(items: readonly HydratedValue[]): HydratedValue { const captured = items.map(sendOnly); let live = false; for (const item of captured) { - if (item instanceof HydratedTuple || isWire(item)) live = true; + if (item instanceof HydratedTuple || item instanceof Face || item instanceof Proxy) live = true; + else if (isWire(item)) throw new Refusal(UNEXPORTABLE); // only the runtime's own Wires are leaves (D1) else if (!(item instanceof Atom) && !(item instanceof Tuple)) throw new TypeError('hydrated: not a value'); } return live ? new HydratedTuple(captured, CONSTRUCT) : tuple(captured as (Atom | Tuple)[]); @@ -115,6 +112,8 @@ function refusal(error: unknown): never { } const codec = (f: () => T): T => { try { return f(); } catch (error) { return refusal(error); } }; const codecLimits = (l: Limits): HydratedCodecLimits => ({ nodes: l.nodes, depth: l.depth, bytes: l.bytes }); +/** A Wire leaf's value bytes under D8: its path bytes and 32. */ +const refBytes = (path: Path): number => path.reduce((n, a) => n + a.length, 32); /** * A local HydratedEndpoint: one receiver, ordered dispatch never inline with the @@ -327,7 +326,7 @@ export class Scope { async _send(dest: Reference, value: HydratedValue): Promise { if (this.#closed) throw new Refusal(SCOPE_ENDED); const staged = new Map(); - const body = this.#toData(value, 0, staged); + const body = this.#toData(value, staged); const frame = codec(() => packHydratedFrame({ scope: dest.scope, id: dest.id, body }, codecLimits(this.#limits))); this.#commit(staged); await this.#sender.send(dest.path, frame); @@ -338,32 +337,77 @@ export class Scope { * new face. The codec checks every bound (D8); the depth guard only keeps a * pathological value from exhausting the stack first. */ - #toData(v: HydratedValue, depth: number, staged: Map): HydratedData { - if (depth > this.#limits.depth) throw new Refusal(LIMIT); - if (v instanceof Atom) return new HydratedDataAtom(v); - if (v instanceof Tuple || v instanceof HydratedTuple) { - return codec(() => new HydratedDataTuple(items(v)!.map((c) => this.#toData(c, depth + 1, staged)))); - } - if (v instanceof Endpoint) return this.#toData(v.wire, depth, staged); - if (v instanceof Face) return codec(() => new HydratedReference(this.path, this.token, this.#exportId(owners.get(v)!, staged))); - if (v instanceof Proxy) { - if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); - return codec(() => new HydratedReference(v.ref.path, v.ref.scope, v.ref.id)); + #toData(root: HydratedValue, staged: Map): HydratedData { + // Count each occurrence as D8 does and refuse before allocating, so a compact + // value with shared subtrees cannot expand past the bounds; iterate, so a deep + // permitted value cannot exhaust the stack. The codec stays authoritative. + const limits = this.#limits; + let nodes = 0, bytes = 0; + const spend = (depth: number, n: number) => { + nodes++; bytes += n; + if (nodes > limits.nodes || depth > limits.depth || bytes > limits.bytes) throw new Refusal(LIMIT); + }; + const frames: { items: readonly HydratedValue[]; depth: number; out: HydratedData[] }[] = []; + const visit = (value: HydratedValue, depth: number): HydratedData | undefined => { + const v = value instanceof Endpoint ? value.wire : value; + if (v instanceof Atom) { spend(depth, v.length); return new HydratedDataAtom(v); } + if (v instanceof Tuple || v instanceof HydratedTuple) { + spend(depth, 0); + frames.push({ items: items(v)!, depth, out: [] }); + return undefined; + } + if (v instanceof Face) { + spend(depth, refBytes(this.path)); + return codec(() => new HydratedReference(this.path, this.token, this.#exportId(owners.get(v)!, staged))); + } + if (v instanceof Proxy) { + if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); + spend(depth, refBytes(v.ref.path)); + return codec(() => new HydratedReference(v.ref.path, v.ref.scope, v.ref.id)); + } + if (v && typeof (v as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); + throw new TypeError('hydrated: not a value'); + }; + const first = visit(root, 0); + if (first) return first; + for (;;) { + const top = frames[frames.length - 1]!; + if (top.out.length < top.items.length) { + const child = visit(top.items[top.out.length]!, top.depth + 1); + if (child) top.out.push(child); + continue; + } + frames.pop(); + const done = codec(() => new HydratedDataTuple(top.out)); + if (!frames.length) return done; + frames[frames.length - 1]!.out.push(done); } - if (v && typeof (v as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); - throw new TypeError('hydrated: not a value'); } - /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). */ - #fromData(d: HydratedData, interned: Map): HydratedValue { - if (d instanceof HydratedDataAtom) return d.value; - if (d instanceof HydratedDataTuple) return hydratedTuple(d.items().map((c) => this.#fromData(c, interned))); - return this.#import({ path: d.path, scope: d.scope, id: d.id }, interned); + /** Occurrences of one reference within the value share one proxy; nothing is retained (D7). Iterative. */ + #fromData(root: HydratedData, interned: Map): HydratedValue { + const leaf = (d: HydratedDataAtom | HydratedReference): HydratedValue => + d instanceof HydratedDataAtom ? d.value : this.#import({ path: d.path, scope: d.scope, id: d.id }, interned); + if (!(root instanceof HydratedDataTuple)) return leaf(root); + const frames: { items: readonly HydratedData[]; out: HydratedValue[] }[] = [{ items: root.items(), out: [] }]; + for (;;) { + const top = frames[frames.length - 1]!; + if (top.out.length < top.items.length) { + const child = top.items[top.out.length]!; + if (child instanceof HydratedDataTuple) frames.push({ items: child.items(), out: [] }); + else top.out.push(leaf(child)); + continue; + } + frames.pop(); + const done = hydratedTuple(top.out); + if (!frames.length) return done; + frames[frames.length - 1]!.out.push(done); + } } /** @internal The body halves of a frame. */ _encodeBody(v: HydratedValue, staged: Map): Atom | Tuple { - const data = this.#toData(v, 0, staged); + const data = this.#toData(v, staged); return codec(() => packHydratedBody(data, codecLimits(this.#limits))) as Atom | Tuple; } diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index fb123ea..645cf8b 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -111,7 +111,9 @@ test('observation 6: refusals before admission register and send nothing', async const [, svc] = serve(t, n.parts.provider.scope, () => {}); const target = a.connect(svc); const kept = new Endpoint(1); - await assert.rejects(target.send(hydratedTuple([kept, { send: async () => {} }])), (e) => e.reason === UNEXPORTABLE); + const foreignWire = { kind: 'wire', send: async () => {} }; + assert.throws(() => hydratedTuple([kept, foreignWire]), (e) => e.reason === UNEXPORTABLE); + await assert.rejects(target.send(foreignWire), (e) => e.reason === UNEXPORTABLE); const foreign = new Scope(new Namespace('other'), [text('x')], { send: async () => {} }) .connect({ path: [text('y')], scope: atom(new Uint8Array(16)), id: text('1') }); await assert.rejects(target.send(hydratedTuple([kept, foreign])), (e) => e.reason === FOREIGN_NAMESPACE); @@ -285,12 +287,44 @@ test('observation 17, local edges: bounds, failure tracking and recognition on l local.receive((v) => got.push(v)); const foreign = { kind: 'wire', send: async () => {} }; await assert.rejects(local.wire.send(foreign), (e) => e.reason === UNEXPORTABLE); - await assert.rejects(local.wire.send(hydratedTuple([text('a'), hydratedTuple([foreign])])), (e) => e.reason === UNEXPORTABLE); - const closed = new Endpoint(1); - const closedFace = closed.wire; - await closed.close(); - await assert.rejects(local.wire.send(hydratedTuple([closedFace])), (e) => e.reason === UNEXPORTABLE); + assert.throws(() => hydratedTuple([text('a'), foreign]), (e) => e.reason === UNEXPORTABLE); await local.wire.send(hydratedTuple([text('ok'), new Endpoint(1)])); await eventually(() => got.length === 1, 'the valid value was not delivered'); assert.equal(got.length, 1, 'a refused local value was delivered'); }); + +test('observation 15, shared trees: refused before expansion, nothing committed or sent', async () => { + const frames = []; + const s = new Scope(new Namespace('x'), [text('a')], { send: async (_p, f) => { frames.push(f); } }); + const target = s.connect({ path: [text('b')], scope: atom(new Uint8Array(16)), id: atom(new Uint8Array(16)) }); + let v = hydratedTuple([new Endpoint(1)]); + for (let i = 0; i < 40; i++) v = hydratedTuple([v, v]); // 2^40 occurrences, 41 tuples + const start = Date.now(); + await assert.rejects(target.send(v), (e) => e.reason === 'limit'); + assert.ok(Date.now() - start < 2000, 'the tree was expanded before refusal'); + assert.equal(s.live, 0); + assert.equal(frames.length, 0); + const local = new Endpoint(1); + const got = new Promise((r) => local.receive(r)); + await local.wire.send(v); + assert.equal(await got, v, 'a local send traversed or rebuilt the value'); +}); + +test('observation 15, depth: a deep permitted value round-trips without exhausting the stack', async () => { + const limits = { exports: 4, nodes: 50000, depth: 20000, bytes: 1 << 22 }; + const frames = []; + const sender = new Scope(new Namespace('x'), [text('s')], { send: async (_p, f) => { frames.push(f); } }, limits); + const receiver = new Scope(new Namespace('x'), [text('r')], { send: async () => {} }, limits); + const target = new Endpoint(1); + const got = new Promise((r) => target.receive(r)); + const ref = receiver.expose(target); + let v = text('leaf'); + for (let i = 0; i < 15000; i++) v = hydratedTuple([v]); + await sender.connect(ref).send(v); + receiver.deliver(receiver.path, frames[0], undefined); + // Compare iteratively: ontos's recursive Tuple.equals is not part of this check. + let x = await got, depth = 0; + while (x instanceof Tuple) { assert.equal(x.length, 1); x = x.at(0); depth++; } + assert.equal(depth, 15000); + assert.ok(x.equals(text('leaf')), 'the deep value changed'); +}); From 9ea405fb0d836ca7a80c40a6ceaa5aeb12738c0f Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:34:08 +0200 Subject: [PATCH 16/21] hydrated: bound conversion allocation for wide tuples and repeated references Fiber Composition's replay of dcb26dd: two allocation paths remained before the budget or codec could refuse. - Wide tuples: conversion copied the source tuple (Go Items, TS items()) and reserved its full size before counting children. Tuples are now indexed, and the output grows as children are counted. - Repeated references: each occurrence of a face or proxy copied its path into a fresh descriptor, and empty path segments add nothing to D8's value bytes. Each distinct face or proxy now gets one descriptor per conversion, shared by its occurrences, and the least encoded size of each copied path counts against the byte bound before the copy (a lower bound on the frame, so no valid value is refused). Observation 15 allocation cases: a 200000-atom tuple under nodes=4 is refused within 64 KiB (Go TotalAlloc) and without a copy (TS); 300 occurrences of a 20000-segment empty path are refused within 32 MiB. TS 58/58, go test -race, interop both roles. Co-Authored-By: Claude Opus 5.5 --- hydrated/go/hydrated.go | 74 ++++++++++++++++++++++++------ hydrated/go/hydrated_test.go | 44 ++++++++++++++++++ hydrated/ts/src/index.ts | 32 ++++++++++--- hydrated/ts/test/hydrated.test.mjs | 20 ++++++++ 4 files changed, 149 insertions(+), 21 deletions(-) diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index 829b1dd..ccd4600 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -577,8 +577,47 @@ func (s *Scope) stage(dest Reference, v wire.HydratedValue) (ontos.Value, error) return frame, s.commit(staged) } -// budget counts a value as D8 does, while converting it. -type budget struct{ nodes, bytes int } +// budget counts a value as D8 does, while converting it. refs holds one +// descriptor per distinct face or proxy, shared by all its occurrences, and +// paths the least encoded size of the paths those descriptors copied. +type budget struct { + nodes, bytes, paths int + refs map[wire.HydratedWire]wire.HydratedReference +} + +// leastPathBytes is a lower bound on a path's encoded size: a tag and a length +// octet per segment, plus its bytes. A frame holding the path is at least this +// large, so refusing beyond the byte bound never refuses a valid value. +func leastPathBytes(path wire.Path) int { + n := 0 + for _, a := range path { + n += 2 + a.Len() + } + return n +} + +// reference returns w's descriptor, copying its path once per conversion. +func (s *Scope) reference(b *budget, w wire.HydratedWire, path wire.Path, scope ontos.Atom, id func() (ontos.Atom, error)) (wire.HydratedData, error) { + if r, ok := b.refs[w]; ok { + return r, nil + } + if b.paths += leastPathBytes(path); b.paths > s.limits.Bytes { + return nil, Limit + } + i, err := id() + if err != nil { + return nil, err + } + r, err := wire.NewHydratedReference(path, scope, i) + if err != nil { + return nil, refusal(err) + } + if b.refs == nil { + b.refs = map[wire.HydratedWire]wire.HydratedReference{} + } + b.refs[w] = r + return r, nil +} // refBytes is a Wire leaf's value bytes under D8: its path bytes and 32. func refBytes(path wire.Path) int { @@ -613,10 +652,12 @@ func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ont if err := s.spend(b, depth, 0); err != nil { return nil, err } - items, _ := Items(x) - out := make([]wire.HydratedData, 0, len(items)) - for _, item := range items { - d, err := s.toData(item, depth+1, staged, b) + // Index the tuple and grow the output as children are counted: nothing + // is copied or reserved at the tuple's size before the budget allows it. + n, at := tupleIndex(x) + var out []wire.HydratedData + for i := 0; i < n; i++ { + d, err := s.toData(at(i), depth+1, staged, b) if err != nil { return nil, err } @@ -630,12 +671,7 @@ func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ont if err := s.spend(b, depth, refBytes(s.path)); err != nil { return nil, err } - id, err := s.exportID(x.e, staged) - if err != nil { - return nil, err - } - r, err := wire.NewHydratedReference(s.path, s.token, id) - return r, refusal(err) + return s.reference(b, x, s.path, s.token, func() (ontos.Atom, error) { return s.exportID(x.e, staged) }) case *proxy: if x.ns != s.ns { return nil, ForeignNamespace @@ -643,14 +679,24 @@ func (s *Scope) toData(v wire.HydratedValue, depth int, staged map[*Endpoint]ont if err := s.spend(b, depth, refBytes(x.ref.Path)); err != nil { return nil, err } - r, err := x.ref.data() - return r, refusal(err) + return s.reference(b, x, x.ref.Path, x.ref.Scope, func() (ontos.Atom, error) { return x.ref.ID, nil }) case wire.HydratedWire: return nil, Unexportable } return nil, errors.New("hydrated: not a value") } +// tupleIndex gives a tuple's length and indexed access without copying it. +func tupleIndex(v wire.HydratedValue) (int, func(int) wire.HydratedValue) { + switch x := v.(type) { + case ontos.Tuple: + return x.Len(), func(i int) wire.HydratedValue { return x.At(i) } + case Tuple: + return len(x.items), func(i int) wire.HydratedValue { return x.items[i] } + } + return 0, nil +} + // fromData maps decoded structural data onto live values. Occurrences of one // reference within the value share one proxy; nothing is retained across // messages (D7). diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index e8368e9..0baf12c 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "os" + "runtime" "strings" "sync" "testing" @@ -1074,3 +1075,46 @@ func TestObservation15DeepPermittedValue(t *testing.T) { t.Fatal("the deep value changed") } } + +// allocated reports the bytes allocated while f runs. +func allocated(f func()) uint64 { + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + f() + runtime.ReadMemStats(&after) + return after.TotalAlloc - before.TotalAlloc +} + +// Observation 15, allocation: a refused value costs memory bounded by the +// limits, not by the value. A wide tuple is neither copied nor reserved at its +// size, and a repeated reference copies its path once. +func TestObservation15RefusalAllocationIsBounded(t *testing.T) { + dest := Reference{wire.Path{text("b")}, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))} + tight, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, &recordingSender{}, Limits{Exports: 4, Nodes: 4, Depth: 8, Bytes: 4096}) + wide := make([]ontos.Value, 200000) + for i := range wide { + wide[i] = text("w") + } + wideTuple := ontos.NewTuple(wide...) + to := tight.Connect(dest) + var err error + if n := allocated(func() { err = to.Send(wideTuple) }); !errors.Is(err, Limit) || n > 64<<10 { + t.Fatalf("a wide tuple under nodes 4: %v after %d bytes", err, n) + } + + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, &recordingSender{}, DefaultLimits) + empty := make(wire.Path, 20000) + for i := range empty { + empty[i] = ontos.NewAtom(nil) + } + far := s.Connect(Reference{empty, ontos.NewAtom(make([]byte, 16)), ontos.NewAtom(make([]byte, 16))}) + occurrences := make([]wire.HydratedValue, 300) + for i := range occurrences { + occurrences[i] = far + } + v := tuple(t, occurrences...) + if n := allocated(func() { err = s.Connect(dest).Send(v) }); !errors.Is(err, Limit) || n > 32<<20 { + t.Fatalf("a repeated long reference path: %v after %d bytes", err, n) + } +} diff --git a/hydrated/ts/src/index.ts b/hydrated/ts/src/index.ts index a7dc6c0..63f7dd6 100644 --- a/hydrated/ts/src/index.ts +++ b/hydrated/ts/src/index.ts @@ -114,6 +114,11 @@ const codec = (f: () => T): T => { try { return f(); } catch (error) { return const codecLimits = (l: Limits): HydratedCodecLimits => ({ nodes: l.nodes, depth: l.depth, bytes: l.bytes }); /** A Wire leaf's value bytes under D8: its path bytes and 32. */ const refBytes = (path: Path): number => path.reduce((n, a) => n + a.length, 32); +/** + * A lower bound on a path's encoded size (a tag and a length octet per segment, + * plus its bytes): a frame holding the path is at least this large. + */ +const leastPathBytes = (path: Path): number => path.reduce((n, a) => n + 2 + a.length, 0); /** * A local HydratedEndpoint: one receiver, ordered dispatch never inline with the @@ -342,28 +347,41 @@ export class Scope { // value with shared subtrees cannot expand past the bounds; iterate, so a deep // permitted value cannot exhaust the stack. The codec stays authoritative. const limits = this.#limits; - let nodes = 0, bytes = 0; + let nodes = 0, bytes = 0, paths = 0; + // One descriptor per distinct face or proxy, shared by its occurrences; its + // path is copied once and counted against the byte bound before copying. + const refs = new Map(); + const reference = (w: HydratedWire, path: Path, scope: Atom, id: () => Atom): HydratedReference => { + const known = refs.get(w); + if (known) return known; + paths += leastPathBytes(path); + if (paths > limits.bytes) throw new Refusal(LIMIT); + const r = codec(() => new HydratedReference(path, scope, id())); + refs.set(w, r); + return r; + }; const spend = (depth: number, n: number) => { nodes++; bytes += n; if (nodes > limits.nodes || depth > limits.depth || bytes > limits.bytes) throw new Refusal(LIMIT); }; - const frames: { items: readonly HydratedValue[]; depth: number; out: HydratedData[] }[] = []; + // Tuples are indexed, never copied; outputs grow as children are counted. + const frames: { tuple: Tuple | HydratedTuple; depth: number; out: HydratedData[] }[] = []; const visit = (value: HydratedValue, depth: number): HydratedData | undefined => { const v = value instanceof Endpoint ? value.wire : value; if (v instanceof Atom) { spend(depth, v.length); return new HydratedDataAtom(v); } if (v instanceof Tuple || v instanceof HydratedTuple) { spend(depth, 0); - frames.push({ items: items(v)!, depth, out: [] }); + frames.push({ tuple: v, depth, out: [] }); return undefined; } if (v instanceof Face) { spend(depth, refBytes(this.path)); - return codec(() => new HydratedReference(this.path, this.token, this.#exportId(owners.get(v)!, staged))); + return reference(v, this.path, this.token, () => this.#exportId(owners.get(v)!, staged)); } if (v instanceof Proxy) { if (v.ns !== this.ns) throw new Refusal(FOREIGN_NAMESPACE); spend(depth, refBytes(v.ref.path)); - return codec(() => new HydratedReference(v.ref.path, v.ref.scope, v.ref.id)); + return reference(v, v.ref.path, v.ref.scope, () => v.ref.id); } if (v && typeof (v as HydratedWire).send === 'function') throw new Refusal(UNEXPORTABLE); throw new TypeError('hydrated: not a value'); @@ -372,8 +390,8 @@ export class Scope { if (first) return first; for (;;) { const top = frames[frames.length - 1]!; - if (top.out.length < top.items.length) { - const child = visit(top.items[top.out.length]!, top.depth + 1); + if (top.out.length < top.tuple.length) { + const child = visit(top.tuple.at(top.out.length)!, top.depth + 1); if (child) top.out.push(child); continue; } diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 645cf8b..606f7f6 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -328,3 +328,23 @@ test('observation 15, depth: a deep permitted value round-trips without exhausti assert.equal(depth, 15000); assert.ok(x.equals(text('leaf')), 'the deep value changed'); }); + +test('observation 15, allocation: wide tuples are not copied; a repeated reference path is copied once', async () => { + const dest = { path: [text('b')], scope: atom(new Uint8Array(16)), id: atom(new Uint8Array(16)) }; + const tight = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }, { exports: 4, nodes: 4, depth: 8, bytes: 4096 }); + const wide = tuple(Array.from({ length: 200000 }, () => text('w'))); + let copies = 0; + const original = Tuple.prototype.items; + Tuple.prototype.items = function () { copies++; return original.call(this); }; + try { + await assert.rejects(tight.connect(dest).send(wide), (e) => e.reason === 'limit'); + } finally { Tuple.prototype.items = original; } + assert.equal(copies, 0, 'the wide tuple was copied before the budget refused it'); + + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }); + const far = s.connect({ path: Array.from({ length: 20000 }, () => atom(new Uint8Array(0))), scope: atom(new Uint8Array(16)), id: atom(new Uint8Array(16)) }); + const v = hydratedTuple(Array.from({ length: 300 }, () => far)); + const start = Date.now(); + await assert.rejects(s.connect(dest).send(v), (e) => e.reason === 'limit'); + assert.ok(Date.now() - start < 5000, 'the repeated path was expanded'); +}); From 7910a94e3b888f0d698327c210056e1fef7759c7 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:36:22 +0200 Subject: [PATCH 17/21] release: prepare bitruntime 0.7.0 (hydrated layer over bitwire 0.6.0) Version 0.7.0 in package.json and package-lock.json; CHANGELOG 0.7.0, docs/RELEASE.md, RELEASING.md and README describe the hydrated layer (hydrated/go, /hydrated), its bitwire 0.6.0 requirement and the new gates. The bitwire 0.6.0 dependency pin follows once that version is published; the lockfile is unchanged otherwise. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++++++++++++++++++--------- README.md | 6 ++++-- RELEASING.md | 8 ++++---- docs/COMPOSITION.md | 4 +++- docs/RELEASE.md | 32 ++++++++++++++++++++++++-------- package-lock.json | 4 ++-- package.json | 2 +- 7 files changed, 56 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b35bb1..39a980b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,26 @@ # Changelog -## Unreleased +## 0.7.0 -- Add `hydrated/{go,ts}`, the realization of bitwire decision 0019 (hydrated - wire protocol, first edition). - - Messages may carry live Wires; references are `(owner path, scope, id)`, +- Require bitwire 0.6.0, with its public hydrated declarations and shared pure + hydrated codec. +- Add `hydrated/{go,ts}` (TypeScript subpath `/hydrated`), the realization of + bitwire decision 0019, the hydrated wire protocol's first edition. + - Messages may carry live Wires. References are `(owner path, scope, id)`, with 16-octet unpredictable ids, valid end to end in one namespace. - - Exports are bound to a local Endpoint's lifetime, and liveness is judged on - send. + - Exports are bound to a local Endpoint's lifetime. An endpoint travels only + as its sending face, on every path. Liveness is judged on send. - Received context is delivered beside the value. - - Bounds use one counting domain in both directions. -- Go/TypeScript hydrated interoperability, and the composition conformance - suite (Cell, Text, Counter). + - Endpoint termination is bitwire's `Termination`. A receiver failure ends + the endpoint and its export. + - The shared codec owns the grammar and bounds. Conversion counts each + occurrence and refuses before allocating, so a compact value with shared + subtrees, a wide tuple or a repeated reference path cannot grow past the + bounds. +- CI and release run the Go/TypeScript hydrated interoperability. The package + and Go smokes exercise the hydrated layer from installed artifacts. +- Add the composition conformance suite (Cell, Text, Counter). +- Remove the hydration spike. ## 0.6.0 diff --git a/README.md b/README.md index 073560c..59cdad8 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,17 @@ A raw endpoint can send an atom directly. The addressed facade works on the same `/core` exports `pair`, `addressed`, `bind`, `under`, `asAddressed`, `compose`, `select` and `route`. Binding captures a path and returns send authority only; `under` captures a prefix. `asAddressed` selects a WireNode node and invokes its own sender once. MissingPathError differs from refusal by that sender. `route(tree,path,message)` does the analogous explicit selection for receiver trees. Structural construction and selection invoke no own capabilities. +`/hydrated` realizes bitwire's hydrated layer (decision 0019): `Scope`, `Endpoint`, `Namespace`, `hydratedTuple` and `items`. A message may carry live Wires. A participant's dispatcher hands its `Scope` each value addressed to its path, with the received context. An `Endpoint`'s `wire` is the only exportable local Wire, and closing the endpoint ends its export. + `/websocket` exports `connectWebSocket`, `listenWebSocket` and `bindWebSocketServer`. A binding owns accepted endpoints and leaves a supplied HTTP/HTTPS server open. A convenience listener owns its HTTP server too. WebSocket negotiates `bitwire.ontos.v2` and carries whole ontos-codec-v1 values. The optional addressed layer encodes `bitwire/addressed/1` as an ordinary value above any carrier. ## Go ```sh -go get github.com/Bitspark/bitruntime@v0.6.0 +go get github.com/Bitspark/bitruntime@v0.7.0 ``` -Use `core/go.NewPair` and `websocket/go.Dial`, `Accept`, `NewServer` or `Listen`. They return bitwire Endpoints. `core/go.Addressed`, `Bind`, `Under` and `AsAddressed` implement the same layering as TypeScript; absent tree paths return ErrMissingPath. A receiver runs on its endpoint's dispatcher. Close does not wait for or cancel dispatched application work. +Use `core/go.NewPair` and `websocket/go.Dial`, `Accept`, `NewServer` or `Listen`. They return bitwire Endpoints. `core/go.Addressed`, `Bind`, `Under` and `AsAddressed` implement the same layering as TypeScript; absent tree paths return ErrMissingPath. `hydrated/go` provides `NewScope`, `NewEndpoint`, `NewNamespace` and `NewTuple` over bitwire's hydrated declarations. A receiver runs on its endpoint's dispatcher. Close does not wait for or cancel dispatched application work. ## Limits and ownership diff --git a/RELEASING.md b/RELEASING.md index 277a5d5..e7c18cf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,12 @@ # Releasing -One root `vX.Y.Z` tag versions the Go module and TypeScript package together. bitruntime 0.6.0 implements bitwire 0.5.0: raw ontos-codec-v1 messages, the optional `bitwire/addressed/1` layer and WebSocket `bitwire.ontos.v2`. +One root `vX.Y.Z` tag versions the Go module and TypeScript package together. bitruntime 0.7.0 implements bitwire 0.6.0: raw ontos-codec-v1 messages, the optional `bitwire/addressed/1` layer, WebSocket `bitwire.ontos.v2` and the hydrated layer `bitwire/hydrated/1` (decision 0019). -- Go module: `github.com/Bitspark/bitruntime`, packages `core/go` and `websocket/go`. -- TypeScript package: `@bitspark/bitruntime`, subpaths `/core` and `/websocket`. +- Go module: `github.com/Bitspark/bitruntime`, packages `core/go`, `websocket/go` and `hydrated/go`. +- TypeScript package: `@bitspark/bitruntime`, subpaths `/core`, `/websocket` and `/hydrated`. - TypeScript distribution: GitHub release asset `bitspark-bitruntime-.tgz`, covered by `SHA256SUMS`. This is an npm-compatible tarball, not a registry publication. URL installs with npm 12 require `--allow-remote=root`. -Before tagging, land a reviewed green PR on main. CI requires Go formatting/vet/race tests, TypeScript checking/build/tests, bitwire conformance observations, a fresh installed package consumer and Go/TypeScript WebSocket interoperability. Run the same checks locally where supported. Confirm bitwire's exact dependency release is publicly installable, with no file dependencies, module replacements or private registries. Run `node scripts/go-smoke.mjs ` before tagging. +Before tagging, land a reviewed green PR on main. CI requires Go formatting/vet/race tests, TypeScript checking/build/tests, bitwire conformance observations, a fresh installed package consumer, and Go/TypeScript WebSocket and hydrated interoperability. Run the same checks locally where supported. Confirm bitwire's exact dependency release is publicly installable, with no file dependencies, module replacements or private registries. Run `node scripts/go-smoke.mjs ` before tagging. Keep package.json, package-lock.json and docs/RELEASE.md consistent. Create and push an annotated version tag on the verified main commit. The tag workflow repeats checks, verifies a fresh public Go consumer, packs the TypeScript artifact and publishes the GitHub release. Verify the workflow, remote commit/tag, downloaded artifact checksum and fresh installed consumers before reporting completion. diff --git a/docs/COMPOSITION.md b/docs/COMPOSITION.md index bcdcc2e..bc335e5 100644 --- a/docs/COMPOSITION.md +++ b/docs/COMPOSITION.md @@ -2,7 +2,9 @@ **Status, 7 October 2026:** implementation plan, with no new runtime code or API. The released baseline is bitruntime 0.6.0 with bitwire 0.5.0. The current -[realization record](WIRE-RUNTIME.md) describes that implementation. The +[realization record](WIRE-RUNTIME.md) describes that implementation. bitruntime +0.7.0, with bitwire 0.6.0, adds the hydrated layer (`hydrated/{go,ts}`), which +realizes the exported-Wire facility as bitwire decision 0019. The [bitwire composition architecture](https://github.com/Bitspark/bitwire/blob/main/docs/wire/composition.md) owns the target semantics and acceptance obligations; this page owns how bitruntime should realize them. Future protocol records must settle the pending diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 77a1d58..2f9c8bd 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,11 +1,27 @@ -# bitruntime 0.6.0 +# bitruntime 0.7.0 -This breaking release implements bitwire 0.5.0's addressless Wire and Endpoint in Go and TypeScript. Local pairs and WebSocket carry opaque ground ontos values without mandatory IDs, paths or request-response fields. +This release adds the hydrated wire layer, realizing bitwire decision 0019 over +bitwire 0.6.0's public hydrated declarations and shared pure codec. A message may +carry live Wires inside its structure. A Wire travels as a reference that its +receiver turns back into a usable send-only Wire. A reference returning to its +owner restores the original Wire. Routers between participants see only ground +values. -One addressed layer works over either carrier. bind captures a path into a send-only Wire; under composes prefixes; asAddressed derives access from a complete WireNode. Selection and reconstruction preserve own capabilities without invoking them. Missing paths remain distinct from refused operations. +`hydrated/go` and the TypeScript subpath `/hydrated` provide: +- **Scopes.** One per participant incarnation, with a random token. Export ids + are 16 unpredictable octets, so one reference never reveals another. +- **Endpoints.** Their sending faces are the only exportable local Wires. An + export ends when its endpoint closes, a receiver failure terminates the + endpoint, and termination is reported as bitwire's `Termination`. +- **Proxies.** Recognized throughout the namespace, so forwarding never wraps a + proxy in another. +- **Liveness and context.** Liveness is judged on send. The composition's + received context is delivered beside each value, never inside it. +- **Bounds.** Nodes, depth and bytes are counted one way in both directions. + Conversion refuses before allocating, and incoming frames count whole. -The generic envelope API and decoder are removed. WebSocket negotiates bitwire.ontos.v2 and carries ontos-codec-v1 values. Addressed access uses bitwire/addressed/1 above that raw boundary. Consumers own their service exchange fields. - -Existing local admission, ordering, bounded queues, exclusive detachable receiving and resource-release guarantees remain. Facades retain the underlying endpoint's ownership and lifetime. - -Validation covers contract-owned raw and addressed observations, exact byte paths, independent bytes, Go race tests, TLS/origin checks, both Go/TypeScript connection roles and fresh installed consumers. TypeScript ships as the release tarball with SHA256SUMS; Go ships through the root tag. +Validation covers decision 0019's observations in Go and TypeScript, the +independent vectors, the composition conformance suite, Go race tests, +Go/TypeScript hydrated interoperability in both roles and fresh installed +consumers. The core and WebSocket guarantees of 0.6.0 are unchanged. TypeScript +ships as the release tarball with SHA256SUMS; Go ships through the root tag. diff --git a/package-lock.json b/package-lock.json index 3ded5e4..ac5e04a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitspark/bitruntime", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitspark/bitruntime", - "version": "0.6.0", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { "@bitspark/bitwire": "0.5.0", diff --git a/package.json b/package.json index 42b8c77..3ab575d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitspark/bitruntime", - "version": "0.6.0", + "version": "0.7.0", "description": "Addressless endpoints, addressed access, byte-keyed trees, local pairs and WebSocket carriers.", "license": "Apache-2.0", "type": "module", From 9b66d692a0d4d10ba148564a0ebdd5418a2010ad Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:40:40 +0200 Subject: [PATCH 18/21] deps: pin bitwire 0.6.0 (public hydrated declarations and shared codec) Go github.com/Bitspark/bitwire v0.6.0 and npm @bitspark/bitwire 0.6.0 (registry.npmjs.org, integrity in the lockfile), the public release at 42bc3d3. No checkout replacement, go.work or file dependency remains. Gates on this state: gofmt, naming, go vet, go test -race ./..., npm ci, check, build, 58/58 TypeScript tests, fresh package smoke (including a hydrated reply), generic and hydrated Go/TypeScript interop. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 073911d..995a648 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Bitspark/bitruntime go 1.26.0 require ( - github.com/Bitspark/bitwire v0.5.0 + github.com/Bitspark/bitwire v0.6.0 github.com/bitspark/deixis v0.6.0 github.com/coder/websocket v1.8.15 ) diff --git a/go.sum b/go.sum index e603cae..a417cb1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Bitspark/bitwire v0.5.0 h1:qNDUMFTmMI2fVThPgR4UkOp/fTO2J6h1Q7pjhUrL9DY= -github.com/Bitspark/bitwire v0.5.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= +github.com/Bitspark/bitwire v0.6.0 h1:Da4R/tRW+hw615DrsaPJ2KwFz0s/kagKdgN9nXfOf1s= +github.com/Bitspark/bitwire v0.6.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= github.com/bitspark/deixis v0.6.0 h1:YAbZiEB+wlQtZoTwLd2+3xGagsfVqmOEH0Fh+fzzaOA= github.com/bitspark/deixis v0.6.0/go.mod h1:jw08wxGevbjoJPBnUaDX549ayvjIOOlLk5GXhIkxu5A= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= diff --git a/package-lock.json b/package-lock.json index ac5e04a..fb0ce55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "Apache-2.0", "dependencies": { - "@bitspark/bitwire": "0.5.0", + "@bitspark/bitwire": "0.6.0", "ws": "8.22.0" }, "devDependencies": { @@ -23,9 +23,9 @@ } }, "node_modules/@bitspark/bitwire": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.5.0.tgz", - "integrity": "sha512-4nFgd/WfGmWTqlor6xKBcerSMqUwupZbd1sso4mRXCnUz/5r+EGjENoxiT+uZ7EzyRUjZNMBi9XtngAnQGXRRg==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.6.0.tgz", + "integrity": "sha512-rWkRP9I4Thej53yZpyKYIHr98RPcYPRrYSjnB0yWRfCn1s2vFJ1xGro2fGwOVUldR6+vZuK87brOMYOKyQFNNw==", "license": "Apache-2.0" }, "node_modules/@bitspark/deixis-core": { diff --git a/package.json b/package.json index 3ab575d..56fe123 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test": "npm run build && node --test --test-timeout=30000 \"core/ts/test/*.test.mjs\" \"websocket/ts/test/*.test.mjs\" \"hydrated/ts/test/*.test.mjs\"" }, "dependencies": { - "@bitspark/bitwire": "0.5.0", + "@bitspark/bitwire": "0.6.0", "ws": "8.22.0" }, "devDependencies": { From 63c53fb349542077f611194b94cf0d9bfeeee896 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:42:43 +0200 Subject: [PATCH 19/21] docs: reconcile the 0.7.0 release documentation with the hydrated layer Wire & Runtime's final-docs review: - README: bitwire 0.6.0 and decision 0019, the 0.7.0 install line, and scripts/hydrated-interop.mjs in the check list. - docs/COMPOSITION.md: the exported-Wire facility is implemented in 0.7.0 as the hydrated layer (status line, component table, export section, stage 3 and the evidence paragraph link the actual contract and tests); routing, multiplexing and the stream carrier stay planned and unrun. No bitnode deployment or multiplexed logical Endpoint is claimed. - The interop script and TS observations no longer call 0019 proposed. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- docs/COMPOSITION.md | 25 +++++++++++++++++++------ hydrated/ts/test/hydrated.test.mjs | 2 +- scripts/hydrated-interop.mjs | 2 +- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 59cdad8..8456b9b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bitruntime -Go and TypeScript implementations of [bitwire 0.5.0](https://github.com/Bitspark/bitwire/tree/v0.5.0): addressless endpoints, addressed access, complete byte-keyed trees, local pairs and WebSocket carriers. +Go and TypeScript implementations of [bitwire 0.6.0](https://github.com/Bitspark/bitwire/tree/v0.6.0): addressless endpoints, addressed access, complete byte-keyed trees, local pairs and WebSocket carriers, and the hydrated layer of [decision 0019](https://github.com/Bitspark/bitwire/blob/v0.6.0/docs/decisions/0019-hydrated-wire-protocol.md) ([hydrated/README.md](hydrated/README.md)). Wire grants sending ground ontos values. Endpoint adds one detachable receive owner, termination and closure. AddressedWire adds a separate path parameter; an AddressedEndpoint presents that layer over an existing endpoint. WireNode is a complete deixis tree whose own values are send capabilities. @@ -9,7 +9,7 @@ Wire grants sending ground ontos values. Endpoint adds one detachable receive ow Install the release package and shared contract: ```sh -npm install --allow-remote=root --@bitspark:registry=https://registry.npmjs.org @bitspark/bitwire@0.5.0 https://github.com/Bitspark/bitruntime/releases/download/v0.6.0/bitspark-bitruntime-0.6.0.tgz +npm install --allow-remote=root --@bitspark:registry=https://registry.npmjs.org @bitspark/bitwire@0.6.0 https://github.com/Bitspark/bitruntime/releases/download/v0.7.0/bitspark-bitruntime-0.7.0.tgz ``` ```ts @@ -62,6 +62,7 @@ npm ci --ignore-scripts npm run check npm test node scripts/interop.mjs +node scripts/hydrated-interop.mjs node scripts/package-smoke.mjs ``` diff --git a/docs/COMPOSITION.md b/docs/COMPOSITION.md index bc335e5..9df7da2 100644 --- a/docs/COMPOSITION.md +++ b/docs/COMPOSITION.md @@ -1,6 +1,8 @@ # Runtime composition plan -**Status, 7 October 2026:** implementation plan, with no new runtime code or API. +**Status, 7 October 2026:** implementation plan. Its exported-Wire facility is +implemented in bitruntime 0.7.0 as the hydrated layer; routing, multiplexing and +the stream carrier remain planned, with no runtime code or API. The released baseline is bitruntime 0.6.0 with bitwire 0.5.0. The current [realization record](WIRE-RUNTIME.md) describes that implementation. bitruntime 0.7.0, with bitwire 0.6.0, adds the hydrated layer (`hydrated/{go,ts}`), which @@ -32,9 +34,9 @@ at the peer. These are cooperating facilities, not aliases for one operation. | Proposed stream component | Read/write framing, decode bounds, shutdown and concrete stream bindings. | A new bitwire stream-framing contract. | | Proposed routing component | Explicit mount registration, dispatch/forwarding, binding generations, return-route bindings and bounded route state. | A specified routing/mount profile; no implicit changes to DeixisNode selection. | | Proposed multiplexing component | Open/accept/refuse logical connections, own the carrier receive attachment, manage channel state/queues and release. | A specified bitwire multiplexing protocol. | -| Proposed reference component | Export/import Wire references, forward sends, reject stale references and release registry entries. | A specified bitwire wire-export protocol. | +| Reference component, implemented in 0.7.0 as `hydrated/{go,ts}` | Export/import Wire references inside hydrated values, forward sends, refuse stale and unknown references, and end exports with their endpoints. | bitwire decision 0019 (`bitwire/hydrated/1`) and bitwire 0.6.0's public declarations and pure codec. | -Names and public signatures for proposed components remain to be selected. +Names and public signatures for the remaining proposed components remain to be selected. Follow component-first paths and the two runtime languages when code is added. Reuse endpoint machinery where semantics match; do not copy an old RPC engine, envelope interface or second generic Wire. bitwire supplies meaning, canonical @@ -91,6 +93,13 @@ A proxy grants Wire.send, not Endpoint ownership. Duplicate exports, re-export through another instance and release/send races require stated outcomes. A channel per exported Wire is optional; examine addressed reference routing first. +bitruntime 0.7.0 realizes export as decision 0019 states it. References are +end-to-end `(owner path, scope, id)` with unpredictable ids, valid within one +namespace, so transit participants keep no export state and forwarding copies a +reference. An export lives exactly as long as its local Endpoint. No channel per +exported Wire and no multiplexed logical Endpoint is involved. Gateways between +namespaces are not defined. + ### Both composition orders | Composition | Mechanism | Additional requirement | @@ -110,7 +119,7 @@ equivalent to one concatenated path. | --- | --- | | 1. Protocol records | bitwire records routing/mounts, multiplexing, wire export and composition choices, expected outcomes and independent vectors. Each implementation follows its own settled record. | | 2. Routing realization | Implement explicit bindings and routing in Go and TypeScript; demonstrate local then multi-process traversal, cross-branch replies under a declared service convention, refusal and stale-binding isolation. | -| 3. Multiplexing and exports | Implement logical endpoints and export/import against the protocols. Exercise endpoint observations plus allocation, authority, aliasing, release and aggregate resource cases. | +| 3. Multiplexing and exports | Implement logical endpoints and export/import against the protocols. Exercise endpoint observations plus allocation, authority, aliasing, release and aggregate resource cases. Export is delivered in 0.7.0 (the hydrated layer); multiplexing remains. | | 4. Additional carrier | Define stream framing in bitwire and implement a TCP/TLS binding. Verify arbitrary chunks, EOF/truncation, limits and release. This can proceed alongside stages 2 and 3 after its own contract exists. | | 5. Combined consumer | Run the same machinery over local, WebSocket and the new stream carrier, in both language roles and fresh packaged consumers. Then a host or `Pages` consumer can adopt it explicitly. | @@ -143,8 +152,12 @@ Add these separately reported families: - Native Go/TypeScript observations, Go race checks, cross-language and multi-process runs, and independently installed public package consumers. -These new families are unrun. Existing 0.6.0 endpoint checks do not establish -multi-hop safety, multiplexing or capability transfer. A documentation CI pass +The export family runs in 0.7.0: decision 0019's observations in Go and +TypeScript, its independent vectors, the composition conformance suite and +Go/TypeScript hydrated interoperability ([hydrated/README.md](../hydrated/README.md)). +Its multi-hop cases run around an opaque test router, not a deployed host. The +routing, multiplexing and stream families remain unrun. Existing endpoint checks +do not establish multi-hop routing safety or multiplexing. A documentation CI pass does not change that status. Each delivered stage must name its exact source, protocol version, environments, observations and remaining limits here or in its linked realization record before a release claims it. diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 606f7f6..9a166b1 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -1,4 +1,4 @@ -// Observations of bitwire decision 0019 (proposed) for the TypeScript evidence; +// Observations of bitwire decision 0019 for the TypeScript realization; // hydrated/go runs all thirteen, this suite the ones a second language must repeat. import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/scripts/hydrated-interop.mjs b/scripts/hydrated-interop.mjs index 1715194..19f1752 100644 --- a/scripts/hydrated-interop.mjs +++ b/scripts/hydrated-interop.mjs @@ -1,4 +1,4 @@ -// Observation 14 of bitwire decision 0019 (proposed): Go and TypeScript peers +// Observation 16 of bitwire decision 0019: Go and TypeScript peers // exchange bitwire/hydrated/1 frames over a real WebSocket, each in both roles. // The exchange: a request carrying a reply Wire, a reply carrying a continuation // Wire, and a third Wire sent back through the continuation. From 6817e40690c5947f4973a690a808b8f6972fda5a Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:42:58 +0200 Subject: [PATCH 20/21] hydrated(ts): correct the observation-suite header Co-Authored-By: Claude Opus 5.5 --- hydrated/ts/test/hydrated.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 9a166b1..63bead6 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -1,5 +1,5 @@ // Observations of bitwire decision 0019 for the TypeScript realization; -// hydrated/go runs all thirteen, this suite the ones a second language must repeat. +// hydrated/go runs the Go observations; this suite repeats those a second language must. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; From ff1c8ead41f8634c75618374011ef2848be89b39 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Wed, 7 Oct 2026 18:59:46 +0200 Subject: [PATCH 21/21] hydrated: the first termination is final; 0.6.0 baseline and hydrated records Fiber Composition's final review at 6817e40 found that a receiver failing after Close, while it was running, turned a published "closed" termination into "failed". Close and a receiver failure now go through one guarded transition: the first sets the termination before Closed is closed and withdraws the export, and later ones change nothing. TypeScript already behaved so; both languages now carry a regression. The hydrated README's status describes the implementation over bitwire 0.6.0's public declarations and shared codec. WIRE-RUNTIME.md is labelled as the 0.6.0 baseline, with the hydrated record linked above it; its historical evidence is unchanged. AGENTS.md and README link both records. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +-- README.md | 2 +- docs/WIRE-RUNTIME.md | 10 +++++- hydrated/README.md | 9 +++--- hydrated/go/hydrated.go | 34 ++++++++++---------- hydrated/go/hydrated_test.go | 50 ++++++++++++++++++++++++++++++ hydrated/ts/test/hydrated.test.mjs | 22 +++++++++++++ 7 files changed, 107 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5499ead..3c3132f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # Working here as an agent -Read [CHARTER.md](CHARTER.md), [the current realization](docs/WIRE-RUNTIME.md), and +Read [CHARTER.md](CHARTER.md), [the baseline realization](docs/WIRE-RUNTIME.md), [the hydrated layer](hydrated/README.md), and bitwire decision 0015 with its wire/carrier contracts at the pinned dependency. Read [the composition plan](docs/COMPOSITION.md) before adding routing across -instances, multiplexing, wire export or stream carriers. Its pending protocol +instances, multiplexing or stream carriers; wire export is the hydrated layer +(bitwire decision 0019). Its pending protocol decisions are prerequisites, not implemented APIs or permission to infer laws. - Implement addressless Endpoints in carriers and one reusable addressed layer above them. Wire grants sending; Endpoint adds receive/close ownership; WireNode adds complete structure. Change missing or incorrect laws in bitwire first. diff --git a/README.md b/README.md index 8456b9b..6e08cb5 100644 --- a/README.md +++ b/README.md @@ -66,4 +66,4 @@ node scripts/hydrated-interop.mjs node scripts/package-smoke.mjs ``` -Tests use bitwire-owned observations, independent bytes, native carrier failures, TLS, resource release and cross-language peers. Packed-consumer tests install into a fresh project. [RELEASING.md](RELEASING.md) defines the public Go tag and GitHub package asset process. [The realization record](docs/WIRE-RUNTIME.md) states the contract and evidence. +Tests use bitwire-owned observations, independent bytes, native carrier failures, TLS, resource release and cross-language peers. Packed-consumer tests install into a fresh project. [RELEASING.md](RELEASING.md) defines the public Go tag and GitHub package asset process. [The realization record](docs/WIRE-RUNTIME.md) states the 0.6.0 baseline's contract and evidence; [hydrated/README.md](hydrated/README.md) states the hydrated layer's. diff --git a/docs/WIRE-RUNTIME.md b/docs/WIRE-RUNTIME.md index ac47ac5..50fb844 100644 --- a/docs/WIRE-RUNTIME.md +++ b/docs/WIRE-RUNTIME.md @@ -1,5 +1,12 @@ # Layered wire realization +**Scope:** this record is the bitruntime 0.6.0 baseline: addressless endpoints, +the addressed facade, local pairs and WebSocket over bitwire 0.5.0. bitruntime +0.7.0 keeps that baseline unchanged and adds the hydrated layer, which carries +exported live Wires as bitwire decision 0019 specifies; its record is +[hydrated/README.md](../hydrated/README.md). Statements below about what is +absent describe the 0.6.0 baseline. + **Target decided 2026-10-05; implemented.** This realizes bitwire decision 0015 and deixis's structural identity under the owner's cross-repository correction. It replaces the generic envelope realization without retaining its @@ -91,7 +98,8 @@ These are the two implemented runtime languages. The other bitwire presentations declare interfaces and check package consumption; no concrete tree implementation or cross-package runtime conformance is claimed for them. Their first concrete implementations must run the same structural observations. Multi-hop relays, -mount-crossing exchanges, cancellation and exported live wires remain absent: +mount-crossing exchanges, cancellation and exported live wires remain absent +(exported live Wires arrive in 0.7.0 as the hydrated layer; see the scope above): their R25b lifecycle/relay families are required when their separately specified protocols are introduced, under decision 0015's triggers. Existing endpoint queue saturation, close and disconnect checks are not evidence for a relay chain. diff --git a/hydrated/README.md b/hydrated/README.md index 844f12e..3c1f225 100644 --- a/hydrated/README.md +++ b/hydrated/README.md @@ -1,10 +1,11 @@ # Hydrated wires **Status, 7 October 2026:** the realization of bitwire decision 0019, the hydrated -wire protocol's first edition, accepted at bitwire `5b1de82`. It is proposed for -the next bitruntime release. Before release it must import bitwire's public -hydrated declarations and its shared pure codec instead of the private types -and grammar traversal here, and keep every observation below green. +wire protocol's first edition, accepted at bitwire `5b1de82`, for bitruntime +0.7.0. It implements bitwire 0.6.0's public hydrated declarations +(`HydratedValue`, `HydratedWire`, `HydratedEndpoint`, `ReceivedContext`) and +encodes and decodes through bitwire's shared pure hydrated codec, which owns the +grammar and its bounds. No private copy of either remains here. [The Go package](go/hydrated.go) and [the TypeScript module](ts/src/index.ts) hold the stateful half of the protocol: diff --git a/hydrated/go/hydrated.go b/hydrated/go/hydrated.go index ccd4600..5cc861a 100644 --- a/hydrated/go/hydrated.go +++ b/hydrated/go/hydrated.go @@ -167,8 +167,8 @@ type Endpoint struct { queue []delivery limit int handler func(wire.HydratedValue, wire.ReceivedContext) - gen uint64 // receiver generation: a detach removes only its own receiver - failure error // set when a receiver failed and so terminated the endpoint + gen uint64 // receiver generation: a detach removes only its own receiver + end wire.Termination // the first termination, final once closed is set closed bool done chan struct{} wake chan struct{} @@ -246,17 +246,12 @@ func (e *Endpoint) Receive(handler func(wire.HydratedValue, wire.ReceivedContext // Termination reports how the endpoint ended, as bitwire's Endpoint contract // does: "failed" with the receiver's failure, or "closed". It is the zero value -// while the endpoint is open, and stable once Closed is closed. +// while the endpoint is open. The first termination is final: it is set before +// Closed is closed, and a receiver failing after Close leaves it "closed". func (e *Endpoint) Termination() wire.Termination { e.mu.Lock() defer e.mu.Unlock() - switch { - case e.failure != nil: - return wire.Termination{Kind: "failed", Message: e.failure.Error()} - case e.closed: - return wire.Termination{Kind: "closed"} - } - return wire.Termination{} + return e.end } // Closed is closed when the endpoint closes. @@ -264,12 +259,20 @@ func (e *Endpoint) Closed() <-chan struct{} { return e.done } // Close ends the endpoint and withdraws its export from every scope. func (e *Endpoint) Close() error { + e.terminate(wire.Termination{Kind: "closed"}) + return nil +} + +// terminate ends the endpoint once: the first termination, by Close or by a +// receiver failure, is the one reported, and later ones change nothing. +func (e *Endpoint) terminate(end wire.Termination) { e.mu.Lock() if e.closed { e.mu.Unlock() - return nil + return } e.closed = true + e.end = end exports := e.exports e.exports = map[*Scope]ontos.Atom{} e.queue = nil @@ -278,7 +281,6 @@ func (e *Endpoint) Close() error { for s, id := range exports { s.withdraw(e, id) } - return nil } func (e *Endpoint) admit(v wire.HydratedValue, ctx wire.ReceivedContext) error { @@ -316,11 +318,9 @@ func (e *Endpoint) dispatch() { e.queue = e.queue[1:] e.mu.Unlock() if err := deliverTo(h, d); err != nil { - // A receiver failure terminates the endpoint, which ends its export. - e.mu.Lock() - e.failure = err - e.mu.Unlock() - _ = e.Close() + // A receiver failure terminates the endpoint, which ends its + // export, unless the endpoint has already ended. + e.terminate(wire.Termination{Kind: "failed", Message: err.Error()}) return } } diff --git a/hydrated/go/hydrated_test.go b/hydrated/go/hydrated_test.go index 0baf12c..0e416ef 100644 --- a/hydrated/go/hydrated_test.go +++ b/hydrated/go/hydrated_test.go @@ -899,6 +899,56 @@ func TestObservation17EndpointLaws(t *testing.T) { } } +// The first termination is final: a receiver that fails after Close, while it +// was running, leaves the endpoint closed, and the export stays withdrawn. +// Reproduced by Fiber Composition's final review at 6817e40. +func TestTerminationIsFinalAfterClose(t *testing.T) { + s, _ := NewScope(NewNamespace("x"), wire.Path{text("a")}, nil, DefaultLimits) + e := NewEndpoint(1) + entered, proceed, left := make(chan struct{}), make(chan struct{}), make(chan struct{}) + if _, err := e.Receive(func(wire.HydratedValue, wire.ReceivedContext) { + close(entered) + <-proceed + defer close(left) + panic("late receiver failure") + }); err != nil { + t.Fatal(err) + } + ref, _ := s.Expose(e) + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("x")), nil); err != nil { + t.Fatal(err) + } + <-entered + _ = e.Close() + <-e.Closed() + closed := e.Termination() + if closed.Kind != "closed" || s.Live() != 0 { + t.Fatalf("after Close: %+v with %d live exports", closed, s.Live()) + } + close(proceed) + <-left + for deadline := time.Now().Add(100 * time.Millisecond); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if now := e.Termination(); now != closed { + t.Fatalf("termination changed after Closed: %+v, then %+v", closed, now) + } + } + if err := s.Deliver(s.path, ontos.NewTuple(header, ref.Scope, ref.ID, text("y")), nil); !errors.Is(err, UnknownExport) { + t.Fatalf("after termination: %v", err) + } + + // And the other order: a failure first stays the termination after Close. + failing := NewEndpoint(1) + if _, err := failing.Receive(func(wire.HydratedValue, wire.ReceivedContext) { panic("receiver fault") }); err != nil { + t.Fatal(err) + } + _ = failing.Send(text("x")) + <-failing.Closed() + _ = failing.Close() + if got := failing.Termination(); got.Kind != "failed" { + t.Fatalf("a failed endpoint reports %+v after Close", got) + } +} + // Observation 15, symmetry: under the same bounds, the sender accepts a value // exactly when the receiver does, at tight node, depth and byte budgets. func TestObservation15SenderAndReceiverCountAlike(t *testing.T) { diff --git a/hydrated/ts/test/hydrated.test.mjs b/hydrated/ts/test/hydrated.test.mjs index 63bead6..1ae4ae8 100644 --- a/hydrated/ts/test/hydrated.test.mjs +++ b/hydrated/ts/test/hydrated.test.mjs @@ -226,6 +226,28 @@ test('observation 17: Endpoint laws on every path', async () => { assert.deepEqual(await quiet.closed, { kind: 'closed' }); }); +// The first termination is final: a receiver that fails after its endpoint was +// closed leaves it closed, as Go's TestTerminationIsFinalAfterClose shows. +test('the first termination is final', async () => { + const s = new Scope(new Namespace('x'), [text('a')], { send: async () => {} }); + const e = new Endpoint(1); + e.receive(() => { void e.close(); throw new Error('late receiver failure'); }); + const ref = s.expose(e); + s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('x')]), undefined); + assert.deepEqual(await e.closed, { kind: 'closed' }); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(e.termination, { kind: 'closed' }); + assert.equal(s.live, 0); + assert.throws(() => s.deliver(s.path, tuple([text('bitwire/hydrated/1'), ref.scope, ref.id, text('y')]), undefined), (err) => err.reason === UNKNOWN_EXPORT); + + const failing = new Endpoint(1); + failing.receive(() => { throw new Error('receiver fault'); }); + await failing.send(text('x')); + assert.equal((await failing.closed).kind, 'failed'); + await failing.close(); + assert.equal(failing.termination.kind, 'failed'); +}); + test('observation 15, symmetry: sender and receiver count a value alike', async () => { const face = new Endpoint(1); let nested = text('x');