From d35594761c7dc86471987d26837e0b77bc4e364f Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:15:58 +0200 Subject: [PATCH 01/39] port: import Nightseam v0.6.0 runtime and transport sources verbatim Byte-identical copies from github.com/Bitspark/nightseam at 5cc9723a24646c40ed1861f892b2b23eb6d785d7 (tag v0.6.0), placed at their bitruntime destinations so later commits show every adaptation as a diff. This commit does not build; the next commits adapt it to Bitwire 0.3.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/addressed.go | 322 ++++++ core/go/addressed_test.go | 474 ++++++++ core/go/invocation.go | 476 ++++++++ core/go/invocation_experiment_test.go | 567 ++++++++++ core/go/invocation_test.go | 519 +++++++++ core/go/meta.go | 95 ++ core/go/pair.go | 471 ++++++++ core/go/pair_test.go | 330 ++++++ core/go/publication.go | 57 + core/go/publication_test.go | 187 ++++ core/go/trace.go | 70 ++ core/go/trace_test.go | 233 ++++ dispatch/go/bitwire_test.go | 62 + dispatch/go/dispatcher.go | 291 +++++ dispatch/go/dispatcher_ownership_test.go | 195 ++++ dispatch/go/dispatcher_test.go | 85 ++ dispatch/go/wire_cancel_reservation_test.go | 211 ++++ dispatch/go/wire_event_context_test.go | 161 +++ dispatch/go/wire_namespace_test.go | 406 +++++++ dispatch/go/wire_options_test.go | 99 ++ dispatch/go/wire_send_test.go | 130 +++ dispatch/go/wire_test.go | 453 ++++++++ dispatch/go/wire_validation_test.go | 70 ++ engine/go/backpressure_test.go | 344 ++++++ engine/go/carriage_test.go | 341 ++++++ engine/go/peer.go | 1120 +++++++++++++++++++ engine/go/peer_pacing_test.go | 141 +++ engine/go/peer_test.go | 612 ++++++++++ engine/go/prepare_test.go | 170 +++ engine/go/seam_test.go | 236 ++++ engine/go/serial_test.go | 151 +++ engine/go/unicode_peer_test.go | 38 + engine/go/wire.go | 867 ++++++++++++++ engine/websocket/go/websocket.go | 204 ++++ internal/profile/go/json.go | 67 ++ internal/profile/go/json_test.go | 69 ++ internal/profile/go/scalar.go | 141 +++ internal/profile/go/unicode_test.go | 82 ++ transports/go/pipe.go | 123 ++ transports/go/pipe_test.go | 16 + transports/go/transport.go | 136 +++ transports/go/transporttest/conformance.go | 153 +++ transports/websocket/go/websocket.go | 127 +++ transports/websocket/go/websocket_test.go | 45 + 44 files changed, 11147 insertions(+) create mode 100644 core/go/addressed.go create mode 100644 core/go/addressed_test.go create mode 100644 core/go/invocation.go create mode 100644 core/go/invocation_experiment_test.go create mode 100644 core/go/invocation_test.go create mode 100644 core/go/meta.go create mode 100644 core/go/pair.go create mode 100644 core/go/pair_test.go create mode 100644 core/go/publication.go create mode 100644 core/go/publication_test.go create mode 100644 core/go/trace.go create mode 100644 core/go/trace_test.go create mode 100644 dispatch/go/bitwire_test.go create mode 100644 dispatch/go/dispatcher.go create mode 100644 dispatch/go/dispatcher_ownership_test.go create mode 100644 dispatch/go/dispatcher_test.go create mode 100644 dispatch/go/wire_cancel_reservation_test.go create mode 100644 dispatch/go/wire_event_context_test.go create mode 100644 dispatch/go/wire_namespace_test.go create mode 100644 dispatch/go/wire_options_test.go create mode 100644 dispatch/go/wire_send_test.go create mode 100644 dispatch/go/wire_test.go create mode 100644 dispatch/go/wire_validation_test.go create mode 100644 engine/go/backpressure_test.go create mode 100644 engine/go/carriage_test.go create mode 100644 engine/go/peer.go create mode 100644 engine/go/peer_pacing_test.go create mode 100644 engine/go/peer_test.go create mode 100644 engine/go/prepare_test.go create mode 100644 engine/go/seam_test.go create mode 100644 engine/go/serial_test.go create mode 100644 engine/go/unicode_peer_test.go create mode 100644 engine/go/wire.go create mode 100644 engine/websocket/go/websocket.go create mode 100644 internal/profile/go/json.go create mode 100644 internal/profile/go/json_test.go create mode 100644 internal/profile/go/scalar.go create mode 100644 internal/profile/go/unicode_test.go create mode 100644 transports/go/pipe.go create mode 100644 transports/go/pipe_test.go create mode 100644 transports/go/transport.go create mode 100644 transports/go/transporttest/conformance.go create mode 100644 transports/websocket/go/websocket.go create mode 100644 transports/websocket/go/websocket_test.go diff --git a/core/go/addressed.go b/core/go/addressed.go new file mode 100644 index 0000000..021ed14 --- /dev/null +++ b/core/go/addressed.go @@ -0,0 +1,322 @@ +package duplex + +import ( + "errors" + "sort" + "strconv" + "strings" + "sync" + "unicode/utf8" + + bitwire "github.com/Bitspark/bitwire/wire/go" +) + +// ProfileKind is one of the profile's four frame kinds. Correlation and +// validation remain the peer's; a wire only carries the frame. +type ProfileKind = bitwire.ProfileKind + +const ( + ProfileRequest = bitwire.ProfileRequest + ProfileResponse = bitwire.ProfileResponse + ProfileEvent = bitwire.ProfileEvent + ProfileCancel = bitwire.ProfileCancel +) + +// ProfileError is public error data, without a runtime error dependency. +type ProfileError = bitwire.ProfileError + +// ProfileFrame carries a profile frame. The Send path is the request method or +// event name; keeping it outside this value prevents contradictory names. +// Payloads retain their JSON representation, including numeric precision. +type ProfileFrame = bitwire.ProfileFrame + +// ReturnAddress is a local address with stable pointer identity, even when its +// Wire implementation is not comparable. It is never an envelope member. +type ReturnAddress = bitwire.ReturnAddress + +// Message preserves a frame and its local return capability through routing. +type Message = bitwire.Message + +// Receiver receives deliveries relative to its wire's origin, and an ending. +// A root owns asynchronous dispatch; composition does not invoke Message itself. +type Receiver = bitwire.Receiver + +// Wire grants send access without receive attachment or lifecycle control. +type Wire = bitwire.Wire + +// Endpoint owns one receive attachment and its lifecycle. Path dispatch and +// sharing among selected receiving views belong to an explicit dispatcher. +type Endpoint = bitwire.Endpoint + +var ( + ErrPath = errors.New("invalid wire path") + ErrNoRoute = errors.New("wire path has no destination") + ErrReceiverExists = errors.New("endpoint already has a receiver") +) + +// EncodePath concatenates UTF-8 byte-length-prefixed scalar-string segments. +// The empty path is "", while a single empty segment is "0:". +func EncodePath(path []string) (string, error) { + var encoded strings.Builder + for _, segment := range path { + if !utf8.ValidString(segment) { + return "", ErrPath + } + encoded.WriteString(strconv.Itoa(len(segment))) + encoded.WriteByte(':') + encoded.WriteString(segment) + } + return encoded.String(), nil +} + +// DecodePath accepts only the canonical form of EncodePath, without Unicode +// normalization or interpretation of dots, slashes or empty segments. +func DecodePath(encoded string) ([]string, error) { + path := []string{} + for encoded != "" { + colon := strings.IndexByte(encoded, ':') + if colon <= 0 { + return nil, ErrPath + } + digits := encoded[:colon] + if len(digits) > 1 && digits[0] == '0' { + return nil, ErrPath + } + for _, digit := range digits { + if digit < '0' || digit > '9' { + return nil, ErrPath + } + } + length, err := strconv.ParseUint(digits, 10, 64) + encoded = encoded[colon+1:] + if err != nil || length > uint64(len(encoded)) { + return nil, ErrPath + } + segment := encoded[:int(length)] + if !utf8.ValidString(segment) { + return nil, ErrPath + } + path = append(path, segment) + encoded = encoded[int(length):] + } + return path, nil +} + +type selectedWire struct { + root Wire + prefix []string +} + +// At selects a relative path without allocating a peer, channel or queue. +// The selection grants only send access, even when path is empty. +func At(root Wire, path []string) Wire { + return &selectedWire{root: root, prefix: append([]string{}, path...)} +} + +func (w *selectedWire) path(path []string) []string { + return append(append([]string{}, w.prefix...), path...) +} +func (w *selectedWire) Send(path []string, message Message) error { + return w.root.Send(w.path(path), message) +} + +type mountedWire struct { + children map[string]Endpoint + mu sync.Mutex + closed bool + current *mountedReceiver +} +type mountedReceiver struct { + receiver Receiver + active bool + children []*mountedChild + remaining int +} +type mountedChild struct { + detach func() + ended bool +} + +// Mount consumes one path segment and delegates to that child. The map is +// copied. A mount has no leaf at []; [""] can select an empty-string key. +// Its single receive attachment borrows one attachment from each child. +// Closing a mount detaches those attachments and leaves every child usable. +func Mount(children map[string]Endpoint) Endpoint { + w := &mountedWire{children: make(map[string]Endpoint, len(children))} + for key, child := range children { + w.children[key] = child + } + return w +} + +func (w *mountedWire) destination(path []string) (Endpoint, error) { + if w.closed { + return nil, ErrClosed + } + if len(path) == 0 { + return nil, ErrNoRoute + } + if _, err := EncodePath(path); err != nil { + return nil, err + } + child := w.children[path[0]] + if child == nil { + return nil, ErrNoRoute + } + return child, nil +} +func (w *mountedWire) Send(path []string, message Message) error { + w.mu.Lock() + child, err := w.destination(path) + w.mu.Unlock() + if err != nil { + return err + } + return child.Send(append([]string{}, path[1:]...), message) +} +func (w *mountedWire) Receive(receiver Receiver) (func(), error) { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return nil, ErrClosed + } + if w.current != nil { + w.mu.Unlock() + return nil, ErrReceiverExists + } + keys := make([]string, 0, len(w.children)) + for key, child := range w.children { + if child != nil { + keys = append(keys, key) + } + } + sort.Strings(keys) + attachment := &mountedReceiver{receiver: receiver, active: true, remaining: len(keys)} + for range keys { + attachment.children = append(attachment.children, &mountedChild{}) + } + w.current = attachment + w.mu.Unlock() + + for i, key := range keys { + slot := attachment.children[i] + w.mu.Lock() + active := attachment.active + w.mu.Unlock() + if !active { + return nil, ErrClosed + } + detach, err := w.children[key].Receive(Receiver{ + Message: func(path []string, message Message) { + // The child owns capture of accepted invocations. A retained + // delivery, including cancellation, keeps its original receiver. + if receiver.Message != nil { + receiver.Message(append([]string{key}, path...), message) + } + }, + Closed: func(code Code, reason string) { w.childEnded(attachment, slot, code, reason) }, + }) + w.mu.Lock() + active = attachment.active && !slot.ended + if err == nil && active { + slot.detach = detach + } + w.mu.Unlock() + if err != nil || !active { + // Close may happen while the child's Receive is returning. Its + // late disposer is still ours, even after the attachment ended. + if detach != nil { + detach() + } + w.remove(attachment) + if err != nil { + return nil, err + } + return nil, ErrClosed + } + } + w.mu.Lock() + active := attachment.active + w.mu.Unlock() + if !active { + return nil, ErrClosed + } + return func() { w.remove(attachment) }, nil +} + +// releaseLocked retires only this attachment. Clear its ownership before +// invoking borrowed disposers or callbacks, which may reenter the mount. +func (w *mountedWire) releaseLocked(attachment *mountedReceiver) []func() { + attachment.active = false + if w.current == attachment { + w.current = nil + } + var detaches []func() + for _, child := range attachment.children { + if child.detach != nil { + detaches = append(detaches, child.detach) + child.detach = nil + } + } + return detaches +} + +func (w *mountedWire) remove(attachment *mountedReceiver) { + w.mu.Lock() + if !attachment.active { + w.mu.Unlock() + return + } + detaches := w.releaseLocked(attachment) + w.mu.Unlock() + for _, detach := range detaches { + detach() + } +} + +func (w *mountedWire) childEnded(attachment *mountedReceiver, child *mountedChild, code Code, reason string) { + w.mu.Lock() + if !attachment.active || child.ended { + w.mu.Unlock() + return + } + child.ended = true + attachment.remaining-- + last := attachment.remaining == 0 + var detaches []func() + if last { + detaches = w.releaseLocked(attachment) + } else if child.detach != nil { + detaches = append(detaches, child.detach) + child.detach = nil + } + w.mu.Unlock() + for _, detach := range detaches { + detach() + } + if last && attachment.receiver.Closed != nil { + attachment.receiver.Closed(code, reason) + } +} + +func (w *mountedWire) Close(code Code, reason string) error { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return nil + } + w.closed = true + attachment := w.current + var detaches []func() + if attachment != nil { + detaches = w.releaseLocked(attachment) + } + w.mu.Unlock() + for _, detach := range detaches { + detach() + } + if attachment != nil && attachment.receiver.Closed != nil { + attachment.receiver.Closed(code, reason) + } + return nil +} diff --git a/core/go/addressed_test.go b/core/go/addressed_test.go new file mode 100644 index 0000000..dcdd3a9 --- /dev/null +++ b/core/go/addressed_test.go @@ -0,0 +1,474 @@ +package duplex_test + +import ( + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + + "github.com/Bitspark/nightseam/duplex/go" +) + +func TestPathEncodingIsCanonicalAndComposable(t *testing.T) { + paths := [][]string{{}, {""}, {"a", "b"}, {"a.b"}, {"a", "b:c"}, {"รฉ", "e\u0301", "๐Ÿ˜€", "\ufeff", "\x00"}} + seen := map[string]bool{} + for _, path := range paths { + encoded, err := duplex.EncodePath(path) + if err != nil { + t.Fatal(err) + } + if seen[encoded] { + t.Fatalf("paths alias at %q", encoded) + } + seen[encoded] = true + decoded, err := duplex.DecodePath(encoded) + if err != nil || !reflect.DeepEqual(decoded, path) { + t.Fatalf("%q: %#v, %v", encoded, decoded, err) + } + for _, suffix := range paths { + b, _ := duplex.EncodePath(suffix) + combined, _ := duplex.EncodePath(append(append([]string{}, path...), suffix...)) + if combined != encoded+b { + t.Fatal("prefixing did not compose by concatenation") + } + } + } + if got, _ := duplex.EncodePath([]string{"a", "๐Ÿ˜€", ""}); got != "1:a4:๐Ÿ˜€0:" { + t.Fatal(got) + } + for _, malformed := range []string{"01:a", "00:", "1", ":", "-1:a", "2:a", "1:รฉ", "99999999999999999999999999999:x", "1:\xff"} { + if _, err := duplex.DecodePath(malformed); err == nil { + t.Fatalf("accepted %q", malformed) + } + } + if _, err := duplex.EncodePath([]string{"\xff"}); err == nil { + t.Fatal("accepted non-scalar UTF-8") + } +} + +// queuedRoot is a deterministic endpoint fixture. Only drain executes queued +// deliveries, so composition cannot pass the asynchronous check by timing luck. +type queuedRoot struct { + mu sync.Mutex + queue []queuedDelivery + current *rootAttachment + closed bool + closes int +} +type rootAttachment struct{ receiver duplex.Receiver } +type queuedDelivery struct { + path []string + message duplex.Message +} +type nonComparableRoot struct { + *queuedRoot + marker []int +} + +func newRoot() *queuedRoot { return &queuedRoot{} } +func (r *queuedRoot) Send(path []string, message duplex.Message) error { + if _, err := duplex.EncodePath(path); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return duplex.ErrClosed + } + r.queue = append(r.queue, queuedDelivery{append([]string{}, path...), message}) + return nil +} +func (r *queuedRoot) Receive(receiver duplex.Receiver) (func(), error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil, duplex.ErrClosed + } + if r.current != nil { + return nil, duplex.ErrReceiverExists + } + attachment := &rootAttachment{receiver} + r.current = attachment + return func() { + r.mu.Lock() + if r.current == attachment { + r.current = nil + } + r.mu.Unlock() + }, nil +} +func (r *queuedRoot) attached() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.current != nil +} +func (r *queuedRoot) captured() duplex.Receiver { + r.mu.Lock() + defer r.mu.Unlock() + return r.current.receiver +} +func (r *queuedRoot) Close(code duplex.Code, reason string) error { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return nil + } + r.closed = true + r.closes++ + attachment := r.current + r.current = nil + r.mu.Unlock() + if attachment != nil && attachment.receiver.Closed != nil { + attachment.receiver.Closed(code, reason) + } + return nil +} +func (r *queuedRoot) drain() { + for { + r.mu.Lock() + if len(r.queue) == 0 { + r.mu.Unlock() + return + } + d := r.queue[0] + r.queue = r.queue[1:] + attachment := r.current + r.mu.Unlock() + if attachment != nil && attachment.receiver.Message != nil { + attachment.receiver.Message(d.path, d.message) + } + } +} + +type sendOnly func([]string, duplex.Message) error + +func (s sendOnly) Send(path []string, message duplex.Message) error { return s(path, message) } + +func TestWireSelectionsGrantOnlySendAccess(t *testing.T) { + root := newRoot() + prefix := []string{"a.b"} + selected := duplex.At(sendOnly(root.Send), prefix) + prefix[0] = "changed" + for _, view := range []duplex.Wire{selected, duplex.At(root, nil), duplex.At(selected, []string{"๐Ÿ˜€"})} { + if _, ok := view.(interface { + Receive(duplex.Receiver) (func(), error) + }); ok { + t.Fatal("selection grants receive authority") + } + if _, ok := view.(interface { + Close(duplex.Code, string) error + }); ok { + t.Fatal("selection grants lifecycle authority") + } + } + path := []string{"call"} + if err := duplex.At(selected, []string{"๐Ÿ˜€"}).Send(path, duplex.Message{}); err != nil { + t.Fatal(err) + } + path[0] = "changed" + if !reflect.DeepEqual(root.queue[0].path, []string{"a.b", "๐Ÿ˜€", "call"}) { + t.Fatal(root.queue) + } +} + +func TestMountPreservesPathsFramesAndReturnCapability(t *testing.T) { + left, right, reply := newRoot(), newRoot(), newRoot() + children := map[string]duplex.Endpoint{"left": left, "": right} + mounted := duplex.Mount(children) + children["left"] = reply + address := &duplex.ReturnAddress{Wire: nonComparableRoot{reply, []int{1}}} + var paths [][]string + var received []duplex.Message + _, err := mounted.Receive(duplex.Receiver{Message: func(path []string, message duplex.Message) { + paths = append(paths, path) + received = append(received, message) + }}) + if err != nil { + t.Fatal(err) + } + frames := []duplex.ProfileFrame{ + {Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage(`{"n":9007199254740993}`), Meta: map[string]string{"tag": "value"}}, + {Version: 1, Kind: duplex.ProfileResponse, ID: "c:1", Error: &duplex.ProfileError{Code: "refused", Message: "No", Data: json.RawMessage(`{"why":"test"}`)}}, + {Version: 1, Kind: duplex.ProfileEvent, Data: json.RawMessage(`null`)}, + {Version: 1, Kind: duplex.ProfileCancel, ID: "c:1"}, + } + view := duplex.At(duplex.At(mounted, []string{"left"}), []string{"๐Ÿ˜€"}) + for _, frame := range frames { + if err := view.Send([]string{"call"}, duplex.Message{Frame: frame, Return: address}); err != nil { + t.Fatal(err) + } + } + if len(received) != 0 || len(left.queue) != 4 || len(reply.queue) != 0 { + t.Fatal("composition changed dispatch ownership") + } + for _, delivery := range left.queue { + if !reflect.DeepEqual(delivery.path, []string{"๐Ÿ˜€", "call"}) { + t.Fatal(delivery.path) + } + } + left.drain() + for i, frame := range frames { + if !reflect.DeepEqual(paths[i], []string{"left", "๐Ÿ˜€", "call"}) || !reflect.DeepEqual(received[i].Frame, frame) || received[i].Return != address { + t.Fatal(paths[i], received[i]) + } + } + if err := mounted.Send([]string{""}, duplex.Message{}); err != nil { + t.Fatal(err) + } + right.drain() + if !reflect.DeepEqual(paths[4], []string{""}) { + t.Fatal(paths[4]) + } + for _, path := range [][]string{nil, {"missing"}} { + if err := mounted.Send(path, duplex.Message{}); !errors.Is(err, duplex.ErrNoRoute) { + t.Fatal(err) + } + } + if err := mounted.Send([]string{"left", "\xff"}, duplex.Message{}); !errors.Is(err, duplex.ErrPath) { + t.Fatal(err) + } +} + +func TestMountRefusesDuplicateAttachmentAndRebindsWithoutStealingCapturedDeliveries(t *testing.T) { + root, reply := newRoot(), newRoot() + mounted := duplex.Mount(map[string]duplex.Endpoint{"service": root}) + address := &duplex.ReturnAddress{Wire: duplex.At(reply, nil)} + var old, fresh []duplex.ProfileKind + var oldPaths [][]string + closed := 0 + detach, err := mounted.Receive(duplex.Receiver{ + Message: func(path []string, message duplex.Message) { + oldPaths = append(oldPaths, path) + old = append(old, message.Frame.Kind) + if message.Return != address { + t.Fatal("return capability changed") + } + }, + Closed: func(duplex.Code, string) { closed++ }, + }) + if err != nil { + t.Fatal(err) + } + captured := root.captured() + if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatal(err) + } + captured.Message([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileRequest}, Return: address}) + detach() + detach() + if root.attached() || root.closed { + t.Fatal("detach retained ownership or closed borrowed root") + } + freshDetach, err := mounted.Receive(duplex.Receiver{Message: func(_ []string, message duplex.Message) { fresh = append(fresh, message.Frame.Kind) }}) + if err != nil { + t.Fatal(err) + } + detach() // The old token must never remove the new attachment. + captured.Closed(duplex.CodeNormal, "stale close") + captured.Message([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileCancel}, Return: address}) + if err := address.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileResponse}}); err != nil { + t.Fatal(err) + } + if err := mounted.Send([]string{"service", "new"}, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileEvent}}); err != nil { + t.Fatal(err) + } + root.drain() + if !reflect.DeepEqual(old, []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileCancel}) || !reflect.DeepEqual(fresh, []duplex.ProfileKind{duplex.ProfileEvent}) || closed != 0 || len(reply.queue) != 1 { + t.Fatal(old, fresh, closed, reply.queue) + } + if !reflect.DeepEqual(oldPaths, [][]string{{"service", "wait"}, {"service", "wait"}}) { + t.Fatal(oldPaths) + } + freshDetach() + _ = mounted.Close(duplex.CodeNormal, "done") + if closed != 0 || root.closed { + t.Fatal("detached owner or borrowed child was closed") + } +} + +func TestMountAttachmentFailureRollsBackOnlyItsBorrowedAttachments(t *testing.T) { + for _, duplicate := range []bool{false, true} { + t.Run(map[bool]string{false: "occupied-child", true: "aliased-child"}[duplicate], func(t *testing.T) { + first, occupied := newRoot(), newRoot() + if duplicate { + occupied = first + } else { + if _, err := occupied.Receive(duplex.Receiver{}); err != nil { + t.Fatal(err) + } + } + mounted := duplex.Mount(map[string]duplex.Endpoint{"a": first, "z": occupied}) + if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatal(err) + } + if first.attached() || first.closed || occupied.closed { + t.Fatal("failed acquisition leaked or closed a child") + } + if !duplicate && !occupied.attached() { + t.Fatal("rollback removed another owner") + } + if _, err := first.Receive(duplex.Receiver{}); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestMountedChildEndKeepsHealthySiblingAndEndsOwnerOnce(t *testing.T) { + left, right := newRoot(), newRoot() + mounted := duplex.Mount(map[string]duplex.Endpoint{"left": left, "right": right}) + closed, deliveries := 0, 0 + _, err := mounted.Receive(duplex.Receiver{ + Message: func(path []string, _ duplex.Message) { + if !reflect.DeepEqual(path, []string{"right", "call"}) { + t.Fatal(path) + } + deliveries++ + }, + Closed: func(code duplex.Code, reason string) { + closed++ + if code != duplex.CodeNormal || reason != "last" { + t.Fatal(code, reason) + } + }, + }) + if err != nil { + t.Fatal(err) + } + stale := left.captured() + _ = left.Close(duplex.CodeNormal, "first") + stale.Closed(duplex.CodeNormal, "duplicate") + if closed != 0 { + t.Fatal("one child ended the mount attachment") + } + if err := mounted.Send([]string{"right", "call"}, duplex.Message{}); err != nil { + t.Fatal(err) + } + right.drain() + _ = right.Close(duplex.CodeNormal, "last") + if closed != 1 || deliveries != 1 { + t.Fatal(closed, deliveries) + } + if err := mounted.Send(nil, duplex.Message{}); !errors.Is(err, duplex.ErrNoRoute) { + t.Fatal("child ending permanently closed mount", err) + } + if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrClosed) { + t.Fatal(err) + } + _ = mounted.Close(duplex.CodeNormal, "mount") + if closed != 1 { + t.Fatal(closed) + } +} + +func TestMountCloseDetachesOwnAttachmentAndPreservesChildren(t *testing.T) { + root := newRoot() + mounted := duplex.Mount(map[string]duplex.Endpoint{"": root}) + closed := 0 + _, err := mounted.Receive(duplex.Receiver{Closed: func(code duplex.Code, reason string) { + closed++ + if code != duplex.CodeNormal || reason != "mount ended" { + t.Error(code, reason) + } + _ = mounted.Close(code, reason) + if _, err := root.Receive(duplex.Receiver{}); err != nil { + t.Error("child was not released before closure callback", err) + } + }}) + if err != nil { + t.Fatal(err) + } + _ = mounted.Close(duplex.CodeNormal, "mount ended") + _ = mounted.Close(duplex.CodeNormal, "again") + if closed != 1 || root.closes != 0 || !root.attached() { + t.Fatal(closed, root.closes, root.attached()) + } + if err := mounted.Send([]string{""}, duplex.Message{}); !errors.Is(err, duplex.ErrClosed) { + t.Fatal(err) + } + if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrClosed) { + t.Fatal(err) + } + if err := root.Send(nil, duplex.Message{}); err != nil { + t.Fatal(err) + } +} + +type registeringRoot struct { + *queuedRoot + registered chan struct{} + resume chan struct{} +} + +func (r *registeringRoot) Receive(receiver duplex.Receiver) (func(), error) { + detach, err := r.queuedRoot.Receive(receiver) + close(r.registered) + <-r.resume + return detach, err +} +func TestMountCloseDuringReceiveDisposesLateChildAttachment(t *testing.T) { + root := ®isteringRoot{newRoot(), make(chan struct{}), make(chan struct{})} + mounted := duplex.Mount(map[string]duplex.Endpoint{"x": root}) + finished := make(chan error, 1) + closed := 0 + go func() { + _, err := mounted.Receive(duplex.Receiver{Closed: func(duplex.Code, string) { closed++ }}) + finished <- err + }() + <-root.registered + _ = mounted.Close(duplex.CodeNormal, "done") + close(root.resume) + if err := <-finished; !errors.Is(err, duplex.ErrClosed) { + t.Fatal(err) + } + if root.attached() || root.closes != 0 || closed != 1 { + t.Fatal(root.attached(), root.closes, closed) + } +} + +type endingRoot struct{ *queuedRoot } + +func (r *endingRoot) Receive(receiver duplex.Receiver) (func(), error) { + detach, err := r.queuedRoot.Receive(receiver) + if err == nil { + _ = r.Close(duplex.CodeNormal, "ended during acquisition") + } + return detach, err +} +func TestMountChildEndingDuringAcquisitionRollsBackHealthySibling(t *testing.T) { + healthy := newRoot() + ending := &endingRoot{newRoot()} + mounted := duplex.Mount(map[string]duplex.Endpoint{"a": healthy, "z": ending}) + if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrClosed) { + t.Fatal(err) + } + if healthy.attached() || ending.attached() || healthy.closed { + t.Fatal("partial acquisition retained a child") + } + if _, err := healthy.Receive(duplex.Receiver{}); err != nil { + t.Fatal(err) + } +} + +func TestEmptyMountStillOwnsOneDetachableAttachment(t *testing.T) { + mounted := duplex.Mount(nil) + detach, err := mounted.Receive(duplex.Receiver{}) + if err != nil { + t.Fatal(err) + } + if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatal(err) + } + detach() + closed := 0 + _, err = mounted.Receive(duplex.Receiver{Closed: func(duplex.Code, string) { closed++ }}) + if err != nil { + t.Fatal(err) + } + detach() + _ = mounted.Close(duplex.CodeNormal, "done") + if closed != 1 { + t.Fatal(closed) + } +} diff --git a/core/go/invocation.go b/core/go/invocation.go new file mode 100644 index 0000000..706b6d8 --- /dev/null +++ b/core/go/invocation.go @@ -0,0 +1,476 @@ +package runtime + +import ( + "errors" + "strconv" + "sync" + "sync/atomic" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// An admitted request's return capability is the invocation, presented as a +// Wire. The empty path carries its outcome, as it always has; these operations +// carry its lifecycle. They are ordinary events of the profile โ€” a layer's own +// vocabulary, as `channel.` is the tunnel's โ€” and a participant needs nothing +// of Nightseam's to speak them but the Wire it was already handed. +// +// The capture or body a verb is about is one opaque segment after the +// operation, because a path is what addresses a thing. The verbs never reach a +// peer root and never cross a physical hop, so they take no built-in family and +// reserve no namespace there; a return capability's path space is the +// invocation's alone. +const ( + // InvocationCapture claims one immutable routing decision for this + // traversal. Its message carries the capture's control sink as the return + // address. Admission is the capture; a refusal is an error from Send. + InvocationCapture = "invocation.capture" + // InvocationReady says the captured request has been delivered. A control + // latched before this arrives is pushed to the sink now. + InvocationReady = "invocation.ready" + // InvocationRelease drops a capture whose traversal wants no more controls. + InvocationRelease = "invocation.release" + // InvocationBegin takes one execution lease. Admission is the lease. + InvocationBegin = "invocation.begin" + // InvocationDone reports that an executing body actually finished. + InvocationDone = "invocation.done" + // InvocationControl relays a cancellation into the invocation, which + // latches it and pushes it to every ready capture exactly once. + InvocationControl = "invocation.control" +) + +// DefaultInvocationCaptures bounds the captures one admitted invocation may +// take. It bounds traversal depth and shallow fan-out together, since a +// capture is taken once per routing boundary crossed. +const DefaultInvocationCaptures = 64 + +// DefaultInvocationBodies bounds the execution leases one admitted invocation +// may take. +const DefaultInvocationBodies = 64 + +var ( + // ErrInvocationUnsupported is the refusal a participant receives from a + // return capability that does not speak this vocabulary. A dispatcher + // answers it explicitly rather than routing with weaker guarantees. + ErrInvocationUnsupported = errors.New("return capability does not carry an invocation lifecycle") + // ErrInvocationEnded refuses participation once the invocation retired. + ErrInvocationEnded = errors.New("invocation no longer admits participation") + // ErrInvocationLimit refuses participation beyond a bound. + ErrInvocationLimit = errors.New("invocation participation limit reached") + // ErrInvocationDuplicate refuses a participant identifier already in use. + ErrInvocationDuplicate = errors.New("invocation participant already exists") +) + +// InvocationLimits bounds the total captures and execution leases of one +// admitted invocation. Both are totals, so neither depth nor fan-out can grow +// the state an invocation retains. +type InvocationLimits struct{ Captures, Bodies int } + +// DefaultInvocationLimits are the bounds an admitting runtime uses when it +// states none of its own. +func DefaultInvocationLimits() InvocationLimits { + return InvocationLimits{Captures: DefaultInvocationCaptures, Bodies: DefaultInvocationBodies} +} + +var invocationIdentifier atomic.Uint64 + +func nextInvocationIdentifier() string { + return strconv.FormatUint(invocationIdentifier.Add(1), 10) +} + +func invocationEvent() duplex.ProfileFrame { + return duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")} +} + +// Invocation is the lifecycle an admitting runtime keeps for one admitted +// request, and the answer its return capability gives to the vocabulary above. +// A runtime that is not Nightseam's composes it โ€” or answers the same paths +// itself โ€” and the same participants work against either. +type Invocation struct { + mu sync.Mutex + limits InvocationLimits + captures map[string]*invocationCapture + bodies map[string]struct{} + taken int + begun int + unready int + running int + controls int + control *duplex.Message + settled bool + dispatchDone bool + retired bool + onRetired func() +} + +type invocationCapture struct { + sink duplex.Wire + ready bool + notified bool +} + +// NewInvocation creates the lifecycle of one admitted request. onRetired runs +// once, outside every lifecycle lock, when no permitted future participation +// and no already admitted control can still need the captures. +func NewInvocation(limits InvocationLimits, onRetired func()) *Invocation { + if limits.Captures < 1 { + limits.Captures = DefaultInvocationCaptures + } + if limits.Bodies < 1 { + limits.Bodies = DefaultInvocationBodies + } + return &Invocation{ + limits: limits, + captures: map[string]*invocationCapture{}, + bodies: map[string]struct{}{}, + unready: 1, + onRetired: onRetired, + } +} + +// Deliver answers one operation of the invocation vocabulary. A return +// capability routes every nonempty path here; the empty path stays its own. +func (v *Invocation) Deliver(path []string, message duplex.Message) error { + if v == nil { + return ErrInvocationUnsupported + } + if len(path) == 0 { + return ErrInvocationUnsupported + } + switch path[0] { + case InvocationControl: + if len(path) != 1 || message.Frame.Kind != duplex.ProfileCancel { + return ErrInvocationUnsupported + } + v.latch(message) + return nil + case InvocationCapture, InvocationReady, InvocationRelease, InvocationBegin, InvocationDone: + default: + return ErrInvocationUnsupported + } + if len(path) != 2 || message.Frame.Kind != duplex.ProfileEvent { + return ErrInvocationUnsupported + } + identifier := path[1] + switch path[0] { + case InvocationCapture: + if message.Return == nil || message.Return.Wire == nil { + return ErrInvocationUnsupported + } + return v.capture(identifier, message.Return.Wire) + case InvocationReady: + v.ready(identifier) + case InvocationRelease: + v.release(identifier) + case InvocationBegin: + return v.begin(identifier) + case InvocationDone: + v.done(identifier) + } + return nil +} + +// Settle fixes the outcome. It neither finishes a body nor drains a control. +func (v *Invocation) Settle() { + if v == nil { + return + } + v.mu.Lock() + v.settled = true + retire := v.retireLocked() + v.mu.Unlock() + invocationNotify(retire) +} + +// DispatchDone reports that the admitted request's own delivery has returned. +func (v *Invocation) DispatchDone() { + if v == nil { + return + } + v.mu.Lock() + if !v.dispatchDone { + v.dispatchDone = true + v.unready-- + } + retire := v.retireLocked() + v.mu.Unlock() + invocationNotify(retire) +} + +// Retired reports whether the invocation has released its captures. +func (v *Invocation) Retired() bool { + if v == nil { + return true + } + v.mu.Lock() + defer v.mu.Unlock() + return v.retired +} + +func (v *Invocation) capture(identifier string, sink duplex.Wire) error { + v.mu.Lock() + defer v.mu.Unlock() + if v.retired { + return ErrInvocationEnded + } + if _, exists := v.captures[identifier]; exists { + return ErrInvocationDuplicate + } + if v.taken >= v.limits.Captures { + return ErrInvocationLimit + } + v.taken++ + v.unready++ + v.captures[identifier] = &invocationCapture{sink: sink} + return nil +} + +func (v *Invocation) ready(identifier string) { + v.mu.Lock() + capture := v.captures[identifier] + if capture == nil || capture.ready { + v.mu.Unlock() + return + } + capture.ready = true + v.unready-- + var sinks []duplex.Wire + var control duplex.Message + if v.control != nil && !capture.notified { + capture.notified = true + sinks, control = []duplex.Wire{capture.sink}, *v.control + v.controls++ + } + retire := v.retireLocked() + v.mu.Unlock() + invocationNotify(retire) + if sinks != nil { + v.push(sinks, control) + } +} + +func (v *Invocation) release(identifier string) { + v.mu.Lock() + capture := v.captures[identifier] + if capture == nil { + v.mu.Unlock() + return + } + if !capture.ready { + capture.ready = true + v.unready-- + } + delete(v.captures, identifier) + retire := v.retireLocked() + v.mu.Unlock() + invocationNotify(retire) +} + +func (v *Invocation) begin(identifier string) error { + v.mu.Lock() + defer v.mu.Unlock() + if v.retired { + return ErrInvocationEnded + } + if _, exists := v.bodies[identifier]; exists { + return ErrInvocationDuplicate + } + if v.begun >= v.limits.Bodies { + return ErrInvocationLimit + } + v.begun++ + v.running++ + v.bodies[identifier] = struct{}{} + return nil +} + +func (v *Invocation) done(identifier string) { + v.mu.Lock() + if _, exists := v.bodies[identifier]; !exists { + v.mu.Unlock() + return + } + delete(v.bodies, identifier) + v.running-- + retire := v.retireLocked() + v.mu.Unlock() + invocationNotify(retire) +} + +// latch records the first cancellation and pushes it to every capture that +// is already ready. A capture installed while it is latched receives it when +// its own request delivery becomes ready. Further controls coalesce. +func (v *Invocation) latch(message duplex.Message) { + v.mu.Lock() + if v.retired || v.control != nil { + v.mu.Unlock() + return + } + v.control = &message + var sinks []duplex.Wire + for _, capture := range v.captures { + if capture.ready && !capture.notified { + capture.notified = true + sinks = append(sinks, capture.sink) + } + } + v.controls++ + v.mu.Unlock() + v.push(sinks, message) +} + +// push runs participant code outside the lock and retains one control +// reservation until every selected continuation has returned, so that +// retirement cannot reclaim a capture a control is still reaching. +func (v *Invocation) push(sinks []duplex.Wire, message duplex.Message) { + defer func() { + v.mu.Lock() + v.controls-- + retire := v.retireLocked() + v.mu.Unlock() + invocationNotify(retire) + }() + for _, sink := range sinks { + func() { + defer func() { _ = recover() }() + _ = sink.Send(nil, message) + }() + } +} + +func (v *Invocation) retireLocked() func() { + if v.retired || !v.settled || v.unready != 0 || v.running != 0 || v.controls != 0 { + return nil + } + v.retired = true + v.captures, v.bodies, v.control = nil, nil, nil + callback := v.onRetired + v.onRetired = nil + return callback +} + +func invocationNotify(callback func()) { + if callback != nil { + callback() + } +} + +// invocationSink is the Wire a capture is pushed its control through. It +// accepts the invocation's cancellation at its own origin and nothing else. +type invocationSink struct{ control func(duplex.Message) } + +func (s *invocationSink) Send(path []string, message duplex.Message) error { + if len(path) != 0 || message.Frame.Kind != duplex.ProfileCancel { + return errors.New("an invocation control sink carries cancellation only") + } + s.control(message) + return nil +} + +func invocationWire(message duplex.Message) (duplex.Wire, error) { + if message.Return == nil || message.Return.Wire == nil { + return nil, ErrInvocationUnsupported + } + return message.Return.Wire, nil +} + +// InvocationCaptureHandle is one immutable routing decision a participant took +// for one traversal of one admitted invocation. Repeated traversal of the same +// dispatcher takes a fresh handle, so no two traversals share a slot. +type InvocationCaptureHandle struct { + wire duplex.Wire + identifier string + once sync.Once + released sync.Once +} + +// CaptureInvocation claims a routing decision for this traversal and supplies +// the sink the invocation pushes its cancellation to. The sink receives the +// control at most once, after Ready and never before. +// +// A return capability that does not speak the vocabulary refuses, and the +// refusal is the caller's to answer: routing an invocation-aware request with +// weaker cancellation guarantees is exactly what this reports instead. +func CaptureInvocation(message duplex.Message, control func(duplex.Message)) (*InvocationCaptureHandle, error) { + wire, err := invocationWire(message) + if err != nil { + return nil, err + } + if control == nil { + return nil, errors.New("an invocation capture requires a control sink") + } + handle := &InvocationCaptureHandle{wire: wire, identifier: nextInvocationIdentifier()} + sent := duplex.Message{Frame: invocationEvent(), Return: &duplex.ReturnAddress{Wire: &invocationSink{control: control}}} + if err := wire.Send([]string{InvocationCapture, handle.identifier}, sent); err != nil { + return nil, err + } + return handle, nil +} + +// Ready says the captured request has been delivered. A cancellation latched +// while the capture was being installed reaches the sink now. +func (c *InvocationCaptureHandle) Ready() { + if c == nil { + return + } + c.once.Do(func() { + _ = c.wire.Send([]string{InvocationReady, c.identifier}, duplex.Message{Frame: invocationEvent()}) + }) +} + +// Release drops the capture. Ready and Release are each idempotent, and a +// release after Ready gives up only this traversal's remaining controls. +func (c *InvocationCaptureHandle) Release() { + if c == nil { + return + } + c.released.Do(func() { + _ = c.wire.Send([]string{InvocationRelease, c.identifier}, duplex.Message{Frame: invocationEvent()}) + }) +} + +// InvocationBodyHandle is one execution lease of one admitted invocation. +type InvocationBodyHandle struct { + wire duplex.Wire + identifier string + once sync.Once +} + +// BeginInvocationBody takes an execution lease for work this participant owns. +// The invocation does not retire while the lease is held, so an early answer +// to the caller โ€” a deadline, a withdrawal โ€” never retires an invocation whose +// body is still running. +func BeginInvocationBody(message duplex.Message) (*InvocationBodyHandle, error) { + wire, err := invocationWire(message) + if err != nil { + return nil, err + } + handle := &InvocationBodyHandle{wire: wire, identifier: nextInvocationIdentifier()} + if err := wire.Send([]string{InvocationBegin, handle.identifier}, duplex.Message{Frame: invocationEvent()}); err != nil { + return nil, err + } + return handle, nil +} + +// Done reports that the body actually finished. It is idempotent and releases +// only the lease it took: a participant cannot finish another owner's work. +func (b *InvocationBodyHandle) Done() { + if b == nil { + return + } + b.once.Do(func() { + _ = b.wire.Send([]string{InvocationDone, b.identifier}, duplex.Message{Frame: invocationEvent()}) + }) +} + +// RelayInvocationControl hands a cancellation to the invocation it names, which +// latches it and pushes it to the traversals that captured it. A router that +// receives a control frame relays it here rather than resolving a route of its +// own: the capture, not the current registration, decides where it goes. +func RelayInvocationControl(message duplex.Message) error { + wire, err := invocationWire(message) + if err != nil { + return err + } + return wire.Send([]string{InvocationControl}, message) +} diff --git a/core/go/invocation_experiment_test.go b/core/go/invocation_experiment_test.go new file mode 100644 index 0000000..d8fe7b2 --- /dev/null +++ b/core/go/invocation_experiment_test.go @@ -0,0 +1,567 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +// ledgerEndpoint is the second independent integration. It shares no ledger +// with the first and composes none of Nightseam's lifecycle: it answers the +// invocation vocabulary itself, out of its own state, using the operation +// paths and nothing else. If the dispatcher works against this, the boundary +// is public in fact and not only in name. +type ledgerEndpoint struct { + mu sync.Mutex + receiver *duplex.Receiver + captureCap int + bodyCap int + calls map[string]*ledgerCall + next atomic.Uint64 + retirements atomic.Int64 + refusals atomic.Int64 +} + +type ledgerCall struct { + mu sync.Mutex + owner *ledgerEndpoint + address *duplex.ReturnAddress + outcome chan duplex.Message + sinks map[string]duplex.Wire + delivered map[string]bool + told map[string]bool + bodies map[string]bool + takenCaps int + takenBody int + pending int + control *duplex.Message + settled bool + retired bool +} + +func newLedgerEndpoint(captures, bodies int) *ledgerEndpoint { + return &ledgerEndpoint{captureCap: captures, bodyCap: bodies, calls: map[string]*ledgerCall{}} +} + +func (e *ledgerEndpoint) Send([]string, duplex.Message) error { return nil } +func (e *ledgerEndpoint) Close(code duplex.Code, reason string) error { + e.mu.Lock() + receiver := e.receiver + e.receiver = nil + e.mu.Unlock() + if receiver != nil && receiver.Closed != nil { + receiver.Closed(code, reason) + } + return nil +} +func (e *ledgerEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.receiver != nil { + return nil, duplex.ErrReceiverExists + } + held := receiver + e.receiver = &held + return func() { + e.mu.Lock() + if e.receiver == &held { + e.receiver = nil + } + e.mu.Unlock() + }, nil +} + +// ledgerReturn answers the outcome at its own origin and the invocation +// vocabulary everywhere else, refusing any path it does not implement. +type ledgerReturn struct{ call *ledgerCall } + +func (r *ledgerReturn) Send(path []string, message duplex.Message) error { + call := r.call + if len(path) == 0 { + if message.Frame.Kind != duplex.ProfileResponse { + return errors.New("invalid outcome") + } + select { + case call.outcome <- message: + default: + } + call.settle() + return nil + } + switch path[0] { + case ws.InvocationControl: + if len(path) != 1 || message.Frame.Kind != duplex.ProfileCancel { + return errors.New("unknown invocation operation") + } + call.latch(message) + return nil + case ws.InvocationCapture: + if len(path) != 2 || message.Return == nil || message.Return.Wire == nil { + return errors.New("unknown invocation operation") + } + return call.capture(path[1], message.Return.Wire) + case ws.InvocationReady: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + call.markReady(path[1]) + return nil + case ws.InvocationRelease: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + call.release(path[1]) + return nil + case ws.InvocationBegin: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + return call.begin(path[1]) + case ws.InvocationDone: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + call.done(path[1]) + return nil + } + return errors.New("unknown invocation operation") +} + +func (c *ledgerCall) capture(identifier string, sink duplex.Wire) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.retired { + return errors.New("retired") + } + if c.takenCaps >= c.owner.captureCap { + c.owner.refusals.Add(1) + return errors.New("capture bound reached") + } + c.takenCaps++ + c.pending++ + c.sinks[identifier] = sink + return nil +} + +func (c *ledgerCall) markReady(identifier string) { + c.mu.Lock() + sink := c.sinks[identifier] + if sink == nil || c.delivered[identifier] { + c.mu.Unlock() + return + } + c.delivered[identifier] = true + c.pending-- + var deliver duplex.Wire + var control duplex.Message + if c.control != nil && !c.told[identifier] { + c.told[identifier] = true + deliver, control = sink, *c.control + } + c.mu.Unlock() + if deliver != nil { + _ = deliver.Send(nil, control) + } + c.retire() +} + +func (c *ledgerCall) release(identifier string) { + c.mu.Lock() + if _, exists := c.sinks[identifier]; !exists { + c.mu.Unlock() + return + } + if !c.delivered[identifier] { + c.pending-- + } + delete(c.sinks, identifier) + delete(c.delivered, identifier) + c.mu.Unlock() + c.retire() +} + +func (c *ledgerCall) begin(identifier string) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.retired { + return errors.New("retired") + } + if c.takenBody >= c.owner.bodyCap { + c.owner.refusals.Add(1) + return errors.New("body bound reached") + } + c.takenBody++ + c.bodies[identifier] = true + return nil +} + +func (c *ledgerCall) done(identifier string) { + c.mu.Lock() + if !c.bodies[identifier] { + c.mu.Unlock() + return + } + delete(c.bodies, identifier) + c.mu.Unlock() + c.retire() +} + +func (c *ledgerCall) latch(message duplex.Message) { + c.mu.Lock() + if c.retired || c.control != nil { + c.mu.Unlock() + return + } + c.control = &message + var sinks []duplex.Wire + for identifier, sink := range c.sinks { + if c.delivered[identifier] && !c.told[identifier] { + c.told[identifier] = true + sinks = append(sinks, sink) + } + } + c.mu.Unlock() + for _, sink := range sinks { + _ = sink.Send(nil, message) + } +} + +func (c *ledgerCall) settle() { + c.mu.Lock() + c.settled = true + c.mu.Unlock() + c.retire() +} + +func (c *ledgerCall) retire() { + c.mu.Lock() + if c.retired || !c.settled || c.pending != 0 || len(c.bodies) != 0 { + c.mu.Unlock() + return + } + c.retired = true + c.sinks, c.delivered, c.told, c.control = nil, nil, nil, nil + c.mu.Unlock() + c.owner.retirements.Add(1) +} + +func (e *ledgerEndpoint) admit(path []string, params json.RawMessage) (*ledgerCall, chan duplex.Message) { + identifier := fmt.Sprintf("l:%d", e.next.Add(1)) + call := &ledgerCall{owner: e, outcome: make(chan duplex.Message, 1), sinks: map[string]duplex.Wire{}, + delivered: map[string]bool{}, told: map[string]bool{}, bodies: map[string]bool{}, pending: 1} + call.address = &duplex.ReturnAddress{Wire: &ledgerReturn{call: call}} + e.mu.Lock() + e.calls[identifier] = call + receiver := e.receiver + e.mu.Unlock() + if receiver != nil && receiver.Message != nil { + receiver.Message(path, duplex.Message{ + Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier, Params: params}, + Return: call.address, + }) + } + // The request's own delivery has returned. + call.mu.Lock() + call.pending-- + call.mu.Unlock() + call.retire() + return call, call.outcome +} + +func (c *ledgerCall) cancel(identifier string) { + _ = c.address.Wire.Send([]string{ws.InvocationControl}, duplex.Message{ + Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: identifier}, + Return: c.address, + }) +} + +func (c *ledgerCall) isRetired() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.retired +} + +// TestASecondIntegrationParticipatesWithNoSharedLedger runs Nightseam's +// dispatcher and its generated-binder registration over an endpoint that +// implements the lifecycle itself, through an opaque wrapper, with no shared +// state and no concrete type recognized on either side. +func TestASecondIntegrationParticipatesWithNoSharedLedger(t *testing.T) { + endpoint := newLedgerEndpoint(8, 8) + dispatch, err := ws.NewDispatcher(opaqueEndpoint{endpoint}) + if err != nil { + t.Fatal(err) + } + view, err := ws.NewDispatcher(dispatch.Select([]string{"space"})) + if err != nil { + t.Fatal(err) + } + started, release := make(chan struct{}, 1), make(chan struct{}) + observed := make(chan error, 1) + if _, err := ws.HandleWire(view, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + started <- struct{}{} + <-release + observed <- ctx.Err() + return "answer", nil + }); err != nil { + t.Fatal(err) + } + call, outcome := endpoint.admit([]string{"space", "read"}, nil) + <-started + if call.isRetired() { + t.Fatal("retired while the body was running") + } + call.cancel("l:1") + close(release) + if err := <-observed; err == nil { + t.Fatal("cancellation did not reach the captured traversal of the second integration") + } + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + for range 500 { + if call.isRetired() { + break + } + time.Sleep(2 * time.Millisecond) + } + if !call.isRetired() { + t.Fatal("the second integration never retired its invocation") + } + if endpoint.retirements.Load() != 1 { + t.Fatalf("retirements: %d", endpoint.retirements.Load()) + } +} + +// conduitEndpoint is half of a pure route: what is sent on one half is +// delivered to the other half's attachment, verbatim, with the message's +// return capability untouched. It correlates nothing and admits nothing, so a +// composition over it is pure forwarding rather than a carrier hop. +type conduitEndpoint struct { + mu sync.Mutex + receiver *duplex.Receiver + other *conduitEndpoint +} + +func newConduit() (*conduitEndpoint, *conduitEndpoint) { + near, far := &conduitEndpoint{}, &conduitEndpoint{} + near.other, far.other = far, near + return near, far +} + +func (c *conduitEndpoint) Send(path []string, message duplex.Message) error { + c.other.mu.Lock() + receiver := c.other.receiver + c.other.mu.Unlock() + if receiver == nil || receiver.Message == nil { + return ws.ErrClosed + } + receiver.Message(path, message) + return nil +} +func (c *conduitEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.receiver != nil { + return nil, duplex.ErrReceiverExists + } + held := receiver + c.receiver = &held + return func() { + c.mu.Lock() + if c.receiver == &held { + c.receiver = nil + } + c.mu.Unlock() + }, nil +} +func (c *conduitEndpoint) Close(duplex.Code, string) error { return nil } + +// TestForwardingPreservesLifecycleParticipation admits on one integration and +// forwards through an opaque wrapper and a pure route into a dispatcher on the +// far side. The lifecycle travels with the preserved return capability rather +// than being reconstructed at the boundary, and detach and rebind on the far +// side leave the captured traversal owning the control. +func TestForwardingPreservesLifecycleParticipation(t *testing.T) { + origin := newLedgerEndpoint(8, 8) + near, far := newConduit() + stop, err := ws.ForwardWire(opaqueEndpoint{origin}, opaqueEndpoint{near}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(stop) + dispatch, err := ws.NewDispatcher(far) + if err != nil { + t.Fatal(err) + } + controls, requests := make(chan duplex.Message, 4), make(chan duplex.Message, 4) + detach, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + if m.Frame.Kind == duplex.ProfileCancel { + controls <- m + return + } + requests <- m + }}) + if err != nil { + t.Fatal(err) + } + call, _ := origin.admit([]string{"read"}, nil) + var admitted duplex.Message + select { + case admitted = <-requests: + case <-time.After(5 * time.Second): + t.Fatal("the forwarded request never arrived") + } + if admitted.Return != call.address { + t.Fatal("forwarding did not preserve the original return capability") + } + detach() + rebound := make(chan duplex.Message, 4) + if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { rebound <- m }}); err != nil { + t.Fatal(err) + } + call.cancel("l:1") + select { + case m := <-controls: + if m.Frame.Kind != duplex.ProfileCancel { + t.Fatalf("control was %q", m.Frame.Kind) + } + case <-time.After(5 * time.Second): + t.Fatal("the control did not follow the captured traversal across the forwarder") + } + select { + case <-rebound: + t.Fatal("the control reached the rebound registration") + default: + } +} + +// TestAQueuedControlCannotReachAReusedIdentity is the first identity-reuse +// race: a control already queued against one invocation keeps that invocation, +// so a later invocation reusing the same textual identifier is untouched. +func TestAQueuedControlCannotReachAReusedIdentity(t *testing.T) { + endpoint := newLedgerEndpoint(8, 8) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + seen := make(chan string, 8) + if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + seen <- string(m.Frame.Kind) + ":" + m.Frame.ID + }}); err != nil { + t.Fatal(err) + } + first, _ := endpoint.admit([]string{"read"}, nil) + if got := <-seen; got != "request:l:1" { + t.Fatalf("first request: %s", got) + } + // The first invocation settles and retires before its old control is + // released. Its control ticket is the invocation itself, not a key. + first.settle() + stale := first.address + second, _ := endpoint.admit([]string{"read"}, nil) + if got := <-seen; got != "request:l:2" { + t.Fatalf("second request: %s", got) + } + // The stale control names the first invocation's identifier and travels on + // the first invocation's own return capability. It reaches nothing. + _ = stale.Wire.Send([]string{ws.InvocationControl}, duplex.Message{ + Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: "l:1"}, + Return: stale, + }) + select { + case got := <-seen: + t.Fatalf("a stale control was delivered: %s", got) + case <-time.After(200 * time.Millisecond): + } + second.cancel("l:2") + select { + case got := <-seen: + if got != "cancel:l:2" { + t.Fatalf("live control: %s", got) + } + case <-time.After(5 * time.Second): + t.Fatal("the live invocation's control never arrived") + } +} + +// TestAResponseRacingAQueuedControlRetiresOnce drives a response and a control +// at one invocation concurrently, many times, under the race detector. +func TestAResponseRacingAQueuedControlRetiresOnce(t *testing.T) { + endpoint := newLedgerEndpoint(8, 8) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + if _, err := ws.HandleWire(dispatch, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + return "answer", nil + }); err != nil { + t.Fatal(err) + } + for i := range 64 { + call, outcome := endpoint.admit([]string{"read"}, nil) + var wait sync.WaitGroup + wait.Add(1) + go func() { defer wait.Done(); call.cancel(fmt.Sprintf("l:%d", i+1)) }() + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + wait.Wait() + } + for range 500 { + if endpoint.retirements.Load() == 64 { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("retirements after 64 raced completions: %d", endpoint.retirements.Load()) +} + +// TestCaptureBoundRefusesRatherThanGrowing checks the refusal a bounded +// integration gives, and that the dispatcher answers it instead of routing. +func TestCaptureBoundRefusesRatherThanGrowing(t *testing.T) { + endpoint := newLedgerEndpoint(1, 8) + outer, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + inner, err := ws.NewDispatcher(outer.Select([]string{"a"})) + if err != nil { + t.Fatal(err) + } + delivered := make(chan struct{}, 1) + if _, err := inner.Register([]string{"read"}, duplex.Receiver{Message: func([]string, duplex.Message) { delivered <- struct{}{} }}); err != nil { + t.Fatal(err) + } + _, outcome := endpoint.admit([]string{"a", "read"}, nil) + select { + case answer := <-outcome: + // The refusal is this integration's own: a dispatcher reports busy for + // a bound it can recognize as one, and invalid_message for a refusal + // whose reason a facility did not spell in the agreed vocabulary. + if answer.Frame.Error == nil || answer.Frame.Error.Code != "invalid_message" { + t.Fatalf("a traversal past the capture bound answered %+v", answer.Frame.Error) + } + case <-time.After(5 * time.Second): + t.Fatal("no refusal") + } + select { + case <-delivered: + t.Fatal("the inner receiver was reached past the bound") + default: + } + if endpoint.refusals.Load() == 0 { + t.Fatal("the bound was never exercised") + } +} diff --git a/core/go/invocation_test.go b/core/go/invocation_test.go new file mode 100644 index 0000000..1e4e2f7 --- /dev/null +++ b/core/go/invocation_test.go @@ -0,0 +1,519 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +// invocationEndpoint is an endpoint written against the public contract alone. +// It admits requests, answers the invocation vocabulary out of its own ledger, +// and imports nothing of Nightseam's but the vocabulary's paths. It is one of +// the two independent integrations #439 requires. +type invocationEndpoint struct { + mu sync.Mutex + receiver *duplex.Receiver + closed bool + limits ws.InvocationLimits + admitted map[string]*ws.Invocation + returns map[string]*duplex.ReturnAddress + outcomes map[string]chan duplex.Message + retirements atomic.Int64 + next atomic.Uint64 +} + +func newInvocationEndpoint(limits ws.InvocationLimits) *invocationEndpoint { + return &invocationEndpoint{limits: limits, admitted: map[string]*ws.Invocation{}, + returns: map[string]*duplex.ReturnAddress{}, outcomes: map[string]chan duplex.Message{}} +} + +// invocationReturn is this endpoint's return capability. The empty path is the +// outcome; every other path is the invocation's own vocabulary. +type invocationReturn struct { + owner *invocationEndpoint + identifier string + invocation *ws.Invocation +} + +func (r *invocationReturn) Send(path []string, message duplex.Message) error { + if len(path) != 0 { + return r.invocation.Deliver(path, message) + } + if message.Frame.Kind != duplex.ProfileResponse { + return errors.New("invalid outcome") + } + r.owner.mu.Lock() + outcome := r.owner.outcomes[r.identifier] + r.owner.mu.Unlock() + if outcome != nil { + select { + case outcome <- message: + default: + } + } + r.invocation.Settle() + return nil +} + +// Send loops back into this endpoint's own attachment, asynchronously, so a +// composition above it can be traversed more than once in one invocation +// without running destination code on the sender's stack. +func (e *invocationEndpoint) Send(path []string, message duplex.Message) error { + go e.deliver(path, message) + return nil +} + +func (e *invocationEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil, ws.ErrClosed + } + if e.receiver != nil { + return nil, duplex.ErrReceiverExists + } + held := receiver + e.receiver = &held + return func() { + e.mu.Lock() + if e.receiver == &held { + e.receiver = nil + } + e.mu.Unlock() + }, nil +} + +func (e *invocationEndpoint) Close(code duplex.Code, reason string) error { + e.mu.Lock() + if e.closed { + e.mu.Unlock() + return nil + } + e.closed = true + receiver := e.receiver + e.receiver = nil + e.mu.Unlock() + if receiver != nil && receiver.Closed != nil { + receiver.Closed(code, reason) + } + return nil +} + +// admit delivers one request through the attached receiver with a fresh +// invocation, and returns the channel its outcome arrives on. +func (e *invocationEndpoint) admit(path []string, params json.RawMessage) (string, chan duplex.Message) { + identifier := fmt.Sprintf("x:%d", e.next.Add(1)) + outcome := make(chan duplex.Message, 1) + invocation := ws.NewInvocation(e.limits, func() { e.retirements.Add(1) }) + address := &duplex.ReturnAddress{Wire: &invocationReturn{owner: e, identifier: identifier, invocation: invocation}} + e.mu.Lock() + e.admitted[identifier] = invocation + e.returns[identifier] = address + e.outcomes[identifier] = outcome + receiver := e.receiver + e.mu.Unlock() + message := duplex.Message{ + Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier, Params: params}, + Return: address, + } + if receiver != nil && receiver.Message != nil { + receiver.Message(path, message) + } + invocation.DispatchDone() + return identifier, outcome +} + +// deliver hands a message to the attachment exactly as it arrived, without +// admitting an invocation of this endpoint's own. +func (e *invocationEndpoint) deliver(path []string, message duplex.Message) { + e.mu.Lock() + receiver := e.receiver + e.mu.Unlock() + if receiver != nil && receiver.Message != nil { + receiver.Message(path, message) + } +} + +func (e *invocationEndpoint) cancel(identifier string) { + e.mu.Lock() + invocation, address := e.admitted[identifier], e.returns[identifier] + e.mu.Unlock() + if invocation == nil { + return + } + _ = invocation.Deliver([]string{ws.InvocationControl}, duplex.Message{ + Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: identifier}, + Return: address, + }) +} + +func (e *invocationEndpoint) invocation(identifier string) *ws.Invocation { + e.mu.Lock() + defer e.mu.Unlock() + return e.admitted[identifier] +} + +// opaqueEndpoint wraps another endpoint with nothing but the contract. It +// passes the complete message, its return capability and its relative path +// through, and recognizes no concrete type on either side. +type opaqueEndpoint struct{ inner duplex.Endpoint } + +func (o opaqueEndpoint) Send(path []string, message duplex.Message) error { + return o.inner.Send(path, message) +} +func (o opaqueEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + return o.inner.Receive(receiver) +} +func (o opaqueEndpoint) Close(code duplex.Code, reason string) error { + return o.inner.Close(code, reason) +} + +func TestIndependentEndpointParticipatesThroughThePublicVocabulary(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(opaqueEndpoint{endpoint}) + if err != nil { + t.Fatal(err) + } + started, release := make(chan struct{}, 1), make(chan struct{}) + cancelled := make(chan error, 1) + if _, err := ws.HandleWire(dispatch, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + started <- struct{}{} + <-release + cancelled <- ctx.Err() + return "answer", nil + }); err != nil { + t.Fatal(err) + } + identifier, outcome := endpoint.admit([]string{"read"}, nil) + <-started + invocation := endpoint.invocation(identifier) + if invocation.Retired() { + t.Fatal("an invocation retired while its body was still running") + } + endpoint.cancel(identifier) + close(release) + if err := <-cancelled; err == nil { + t.Fatal("cancellation did not reach the captured traversal") + } + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + waitRetired(t, invocation) + if endpoint.retirements.Load() != 1 { + t.Fatalf("retirements: %d", endpoint.retirements.Load()) + } +} + +func TestCapturedTraversalSurvivesDetachAndRebind(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + first, second := make(chan duplex.Message, 4), make(chan duplex.Message, 4) + detach, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { first <- m }}) + if err != nil { + t.Fatal(err) + } + identifier, _ := endpoint.admit([]string{"read"}, nil) + if got := (<-first).Frame.Kind; got != duplex.ProfileRequest { + t.Fatalf("first receiver saw %q", got) + } + detach() + if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { second <- m }}); err != nil { + t.Fatal(err) + } + endpoint.cancel(identifier) + select { + case m := <-first: + if m.Frame.Kind != duplex.ProfileCancel || m.Frame.ID != identifier { + t.Fatalf("captured receiver got %q %q", m.Frame.Kind, m.Frame.ID) + } + case <-time.After(5 * time.Second): + t.Fatal("cancellation did not reach the receiver that was captured") + } + select { + case <-second: + t.Fatal("cancellation reached the rebound receiver") + default: + } +} + +func TestEachTraversalOfOneDispatcherCapturesSeparately(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + // One dispatcher visited twice in one traversal: its outer route forwards + // back into its own inner route. Each visit is a capture of its own. + seen := make(chan string, 8) + if _, err := dispatch.Register([]string{"outer"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + if m.Frame.Kind == duplex.ProfileRequest { + go func() { _ = dispatch.Send([]string{"inner"}, m) }() + } + seen <- "outer:" + string(m.Frame.Kind) + }}); err != nil { + t.Fatal(err) + } + if _, err := dispatch.Register([]string{"inner"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + seen <- "inner:" + string(m.Frame.Kind) + }}); err != nil { + t.Fatal(err) + } + identifier, _ := endpoint.admit([]string{"outer"}, nil) + collect(t, seen, 2, map[string]bool{"outer:request": true, "inner:request": true}) + endpoint.cancel(identifier) + collect(t, seen, 2, map[string]bool{"outer:cancel": true, "inner:cancel": true}) +} + +func TestLatchedCancellationReachesACaptureInstalledAfterIt(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + inner, err := ws.NewDispatcher(dispatch.Select([]string{"a"})) + if err != nil { + t.Fatal(err) + } + controls := make(chan duplex.Message, 4) + var identifier atomic.Value + identifier.Store("") + // The outer receiver cancels the invocation before the inner capture is + // installed. The latch is what carries the control to the later capture. + if _, err := inner.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + if m.Frame.Kind == duplex.ProfileRequest { + endpoint.cancel(m.Frame.ID) + return + } + controls <- m + }}); err != nil { + t.Fatal(err) + } + endpoint.admit([]string{"a", "read"}, nil) + _ = identifier + select { + case m := <-controls: + if m.Frame.Kind != duplex.ProfileCancel { + t.Fatalf("latched control was %q", m.Frame.Kind) + } + case <-time.After(5 * time.Second): + t.Fatal("a capture installed while cancellation was latched never received it") + } +} + +func TestInvocationBoundsCapturesAndBodies(t *testing.T) { + endpoint := newInvocationEndpoint(ws.InvocationLimits{Captures: 2, Bodies: 1}) + identifier, _ := endpoint.admit([]string{"read"}, nil) + invocation := endpoint.invocation(identifier) + message := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier}, + Return: &duplex.ReturnAddress{Wire: &invocationReturn{owner: endpoint, identifier: identifier, invocation: invocation}}} + for i := range 2 { + if _, err := ws.CaptureInvocation(message, func(duplex.Message) {}); err != nil { + t.Fatalf("capture %d refused: %v", i, err) + } + } + if _, err := ws.CaptureInvocation(message, func(duplex.Message) {}); !errors.Is(err, ws.ErrInvocationLimit) { + t.Fatalf("capture beyond the bound: %v", err) + } + body, err := ws.BeginInvocationBody(message) + if err != nil { + t.Fatal(err) + } + if _, err := ws.BeginInvocationBody(message); !errors.Is(err, ws.ErrInvocationLimit) { + t.Fatalf("body beyond the bound: %v", err) + } + // A released capture gives back no slot: the bound is a total, so neither + // depth nor shallow fan-out can grow what one invocation retains. + body.Done() + if _, err := ws.BeginInvocationBody(message); !errors.Is(err, ws.ErrInvocationLimit) { + t.Fatalf("a finished body returned its slot: %v", err) + } +} + +func TestRetirementWaitsForTheBodyAndTheControlDrain(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + identifier, _ := endpoint.admit([]string{"read"}, nil) + invocation := endpoint.invocation(identifier) + message := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier}, + Return: &duplex.ReturnAddress{Wire: &invocationReturn{owner: endpoint, identifier: identifier, invocation: invocation}}} + capture, err := ws.CaptureInvocation(message, func(duplex.Message) {}) + if err != nil { + t.Fatal(err) + } + body, err := ws.BeginInvocationBody(message) + if err != nil { + t.Fatal(err) + } + invocation.Settle() + if invocation.Retired() { + t.Fatal("retired with an undelivered capture and a running body") + } + capture.Ready() + if invocation.Retired() { + t.Fatal("retired while the body was still running") + } + body.Done() + if !invocation.Retired() { + t.Fatal("did not retire once settled with nothing outstanding") + } + if _, err := ws.CaptureInvocation(message, func(duplex.Message) {}); !errors.Is(err, ws.ErrInvocationEnded) { + t.Fatalf("a retired invocation admitted a capture: %v", err) + } + if _, err := ws.BeginInvocationBody(message); !errors.Is(err, ws.ErrInvocationEnded) { + t.Fatalf("a retired invocation admitted a body: %v", err) + } +} + +func TestSequentialCompletionsBeyondCapacityRetainNothing(t *testing.T) { + endpoint := newInvocationEndpoint(ws.InvocationLimits{Captures: 2, Bodies: 2}) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + if _, err := ws.HandleWire(dispatch, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return "answer", nil }); err != nil { + t.Fatal(err) + } + var invocations []*ws.Invocation + for range 32 { + identifier, outcome := endpoint.admit([]string{"read"}, nil) + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + invocations = append(invocations, endpoint.invocation(identifier)) + } + for i, invocation := range invocations { + waitRetired(t, invocation) + if i == 0 { + continue + } + } + if got := endpoint.retirements.Load(); got != 32 { + t.Fatalf("retirements after 32 sequential completions: %d", got) + } +} + +// A bound the runtime's own facility keeps is a busy refusal: something to +// try again at, not a request that was malformed. +func TestATraversalPastTheCaptureBoundIsRefusedAsBusy(t *testing.T) { + endpoint := newInvocationEndpoint(ws.InvocationLimits{Captures: 1, Bodies: 8}) + outer, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + inner, err := ws.NewDispatcher(outer.Select([]string{"a"})) + if err != nil { + t.Fatal(err) + } + delivered := make(chan struct{}, 1) + if _, err := inner.Register([]string{"read"}, duplex.Receiver{Message: func([]string, duplex.Message) { delivered <- struct{}{} }}); err != nil { + t.Fatal(err) + } + _, outcome := endpoint.admit([]string{"a", "read"}, nil) + select { + case answer := <-outcome: + if answer.Frame.Error == nil || answer.Frame.Error.Code != "busy" { + t.Fatalf("a traversal past the bound answered %+v", answer.Frame.Error) + } + case <-time.After(5 * time.Second): + t.Fatal("no refusal") + } + select { + case <-delivered: + t.Fatal("the inner receiver was reached past the bound") + default: + } +} + +func TestADispatcherRefusesAnInvocationWithoutALifecycle(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + delivered := make(chan struct{}, 1) + if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func([]string, duplex.Message) { delivered <- struct{}{} }}); err != nil { + t.Fatal(err) + } + answered := make(chan duplex.Message, 1) + bare := &bareReturn{answer: func(m duplex.Message) { answered <- m }} + // A request arrives through the contract alone, with a return capability + // that carries no lifecycle. It is refused explicitly, on its own original + // return capability, rather than routed with weaker guarantees. + endpoint.deliver([]string{"read"}, duplex.Message{ + Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "b:1", Params: []byte("null")}, + Return: &duplex.ReturnAddress{Wire: bare}, + }) + select { + case refusal := <-answered: + if refusal.Frame.Error == nil || refusal.Frame.Error.Code != "invalid_message" { + t.Fatalf("refusal was %+v", refusal.Frame.Error) + } + case <-time.After(5 * time.Second): + t.Fatal("an unmanaged invocation was neither routed nor refused") + } + select { + case <-delivered: + t.Fatal("an unmanaged invocation was routed with weaker guarantees") + default: + } + if bare.uses.Load() != 1 { + t.Fatalf("the refusal did not use the original return capability once: %d", bare.uses.Load()) + } +} + +type bareReturn struct { + answer func(duplex.Message) + uses atomic.Int64 +} + +func (b *bareReturn) Send(path []string, message duplex.Message) error { + if len(path) != 0 { + return errors.New("this return capability carries no lifecycle") + } + b.uses.Add(1) + b.answer(message) + return nil +} + +func waitRetired(t *testing.T, invocation *ws.Invocation) { + t.Helper() + for range 500 { + if invocation.Retired() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("invocation never retired") +} + +func collect(t *testing.T, seen chan string, count int, want map[string]bool) { + t.Helper() + got := map[string]bool{} + for range count { + select { + case value := <-seen: + got[value] = true + case <-time.After(5 * time.Second): + t.Fatalf("saw %v, wanted %v", got, want) + } + } + for value := range want { + if !got[value] { + t.Fatalf("saw %v, wanted %v", got, want) + } + } +} diff --git a/core/go/meta.go b/core/go/meta.go new file mode 100644 index 0000000..6ec0277 --- /dev/null +++ b/core/go/meta.go @@ -0,0 +1,95 @@ +package runtime + +import ( + "context" + "encoding/json" + "maps" + "strings" +) + +// metaReserved prefixes the meta keys the profile and its components keep for +// themselves โ€” a deadline, a cause โ€” so that a consumer's key and one defined +// later never collide. This version defines none, so every key under it is +// refused, on the way out as on the way in. +const metaReserved = "nightseam." + +// Meta is what a frame carries about a call rather than of it: a flat map of +// strings โ€” a tenant, an idempotency key, a credential that is per request โ€” +// which the profile carries verbatim and reads nothing into. +type Meta = map[string]string + +// A carriage travels one way at a time. The meta a frame arrived with and the +// meta the next frame sent from this context will carry are separate values +// under separate keys, so that a handler's outgoing call carries the caller's +// credential only where the handler said to: WithMeta(ctx, MetaFrom(ctx)) is +// how a handler forwards what it received, and nothing forwards it silently. +type outgoingMetaKey struct{} +type incomingMetaKey struct{} + +// WithMeta says what the requests and events sent from ctx carry. The map is +// copied, so a later write to the caller's does not reach a frame already +// sent; a nil or empty map carries nothing. Keys under the reserved prefix are +// the profile's and are dropped rather than sent, since the peer at the far +// end refuses a frame carrying one. +func WithMeta(ctx context.Context, meta Meta) context.Context { + carried := make(Meta, len(meta)) + for key, value := range meta { + if !strings.HasPrefix(key, metaReserved) { + carried[key] = value + } + } + if len(carried) == 0 { + return context.WithValue(ctx, outgoingMetaKey{}, Meta(nil)) + } + return context.WithValue(ctx, outgoingMetaKey{}, carried) +} + +// MetaFrom is the meta of the frame whose handler ctx runs under, and nil +// where the frame carried none or ctx is no handler's. The map is a copy: a +// handler may read it, and what it writes reaches no frame. +func MetaFrom(ctx context.Context) Meta { + meta, _ := ctx.Value(incomingMetaKey{}).(Meta) + if len(meta) == 0 { + return nil + } + return maps.Clone(meta) +} + +// withIncomingMeta places what a frame carried on the context its handler runs +// under. An absent member and an empty carriage are alike to a handler, which +// reads nil for both. +func withIncomingMeta(ctx context.Context, meta Meta) context.Context { + if len(meta) == 0 { + return ctx + } + return context.WithValue(ctx, incomingMetaKey{}, meta) +} + +// outgoingMeta is what a frame sent from ctx carries, and nil where nothing +// said. It is read once per frame, so that a context changed between two calls +// is obeyed by each. +func outgoingMeta(ctx context.Context) Meta { + meta, _ := ctx.Value(outgoingMetaKey{}).(Meta) + if len(meta) == 0 { + return nil + } + return meta +} + +// validMeta holds meta to what a carriage is, reading the member as it was +// spelled rather than as the frame holds it: an object, since a member spelled +// null is not an absent one; every value a string, which map[string]string +// cannot tell from a null it would read as the empty one; and no key of the +// reserved prefix. +func validMeta(raw json.RawMessage) bool { + var values map[string]json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil || values == nil { + return false + } + for key, value := range values { + if strings.HasPrefix(key, metaReserved) || len(value) == 0 || value[0] != '"' { + return false + } + } + return true +} diff --git a/core/go/pair.go b/core/go/pair.go new file mode 100644 index 0000000..30c26f6 --- /dev/null +++ b/core/go/pair.go @@ -0,0 +1,471 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "sync" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// NewWirePair constructs a bounded local carrier with two relative origins. +// Sending on either endpoint delivers to receivers on the other. It allocates +// no Peer and preserves structured frames and verified local request context. +// The limits, propagator and observer in options apply to both directions. +func NewWirePair(options Options) (left, right duplex.Endpoint, err error) { + o, err := options.normalized() + if err != nil { + return nil, nil, err + } + if len(o.Handlers) != 0 || len(o.Events) != 0 || o.Prepare != nil { + return nil, nil, errors.New("local wires install receivers through Receive") + } + p := &localWirePair{options: o, done: make(chan struct{})} + for i := range p.ends { + p.ends[i] = &localWire{pair: p, wake: make(chan struct{}, 1), calls: map[returnKey]*localWireCall{}} + } + p.ends[0].other, p.ends[1].other = p.ends[1], p.ends[0] + for _, end := range p.ends { + go end.run() + } + return p.ends[0], p.ends[1], nil +} + +type localWirePair struct { + mu sync.Mutex + options Options + ends [2]*localWire + done chan struct{} + closed bool +} + +type localRegistration struct { + receiver duplex.Receiver + active bool +} +type localDelivery struct { + path []string + message duplex.Message + call *localWireCall + refusal error +} +type localWire struct { + pair *localWirePair + other *localWire + wake chan struct{} + queue []localDelivery + dataQueued int + active int + eventTimer *time.Timer + calls map[returnKey]*localWireCall + receiver *localRegistration +} +type localWireCall struct { + key returnKey + path []string + message duplex.Message + returning *duplex.ReturnAddress + registration *localRegistration + dispatch *wireDispatchContext + invocation *Invocation + cancel context.CancelFunc + timer *time.Timer + completed bool + responded bool + active bool + cancelQueued bool + cancelled bool +} + +func (w *localWire) Send(path []string, message duplex.Message) error { + name, err := duplex.EncodePath(path) + if err != nil { + return err + } + if err := validateWireFrame(name, message.Frame, w.pair.options.MaxFrameBytes); err != nil { + return err + } + if message.Frame.Kind != duplex.ProfileRequest && message.Frame.Kind != duplex.ProfileEvent && message.Frame.Kind != duplex.ProfileCancel { + return errors.New("a response is sent to its request's return address") + } + if message.Frame.Kind != duplex.ProfileEvent && (message.Return == nil || message.Return.Wire == nil) { + return errors.New("a wire request or cancellation requires a return address") + } + message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) + message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) + message.Frame.Meta = maps.Clone(message.Frame.Meta) + return w.other.admit(append([]string(nil), path...), message) +} + +func (w *localWire) admit(path []string, message duplex.Message) error { + p := w.pair + key := returnKey{message.Return, message.Frame.ID} + delivery := localDelivery{path: path, message: message} + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return ErrClosed + } + if message.Frame.Kind == duplex.ProfileCancel { + call := w.calls[key] + if call == nil || call.completed || call.cancelQueued || call.cancelled { + p.mu.Unlock() + return nil + } + call.cancelQueued = true + delivery.call = call + delivery.message.Return = call.returning + } else { + if w.dataQueued >= p.options.QueueCapacity { + depth := w.dataQueued + p.mu.Unlock() + p.observe(Backpressure{At: time.Now(), Queued: depth, Stalled: true, Deadline: p.options.WriteTimeout}) + p.end(duplex.CodeDuplex, "local wire queue limit reached") + return ErrBackpressure + } + w.dataQueued++ + if message.Frame.Kind == duplex.ProfileRequest { + if w.calls[key] != nil { + delivery.refusal = &PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} + } else if len(w.calls) >= p.options.MaxPendingRequests { + delivery.refusal = &PublicError{Code: "busy", Message: "Outstanding call limit reached"} + } else { + call := &localWireCall{key: key, path: path, message: message, invocation: NewInvocation(DefaultInvocationLimits(), nil)} + call.returning = &duplex.ReturnAddress{Wire: &localReturn{wire: w, call: call}} + w.calls[key] = call + delivery.call, delivery.message.Return = call, call.returning + } + } + } + w.queue = append(w.queue, delivery) + p.mu.Unlock() + w.signal() + return nil +} + +func (w *localWire) signal() { + select { + case w.wake <- struct{}{}: + default: + } +} +func (w *localWire) retireLocked(call *localWireCall) { + if !call.completed || call.cancelQueued || w.calls[call.key] != call { + return + } + delete(w.calls, call.key) + call.invocation.Settle() + call.invocation.DispatchDone() +} +func (w *localWire) complete(call *localWireCall) { + w.pair.mu.Lock() + if !call.completed { + call.completed = true + if call.active { + w.active-- + call.active = false + } + if call.timer != nil { + call.timer.Stop() + } + if call.cancel != nil { + call.cancel() + } + } + w.retireLocked(call) + w.pair.mu.Unlock() +} + +func (w *localWire) next() (localDelivery, bool) { + w.pair.mu.Lock() + defer w.pair.mu.Unlock() + if w.pair.closed || len(w.queue) == 0 { + return localDelivery{}, false + } + delivery := w.queue[0] + w.queue[0] = localDelivery{} + w.queue = w.queue[1:] + if delivery.message.Frame.Kind != duplex.ProfileCancel { + w.dataQueued-- + } + return delivery, true +} +func (w *localWire) run() { + for { + delivery, ok := w.next() + if !ok { + select { + case <-w.pair.done: + return + case <-w.wake: + continue + } + } + if delivery.refusal != nil { + sendWireResponse(delivery.message, nil, delivery.refusal) + continue + } + if delivery.message.Frame.Kind == duplex.ProfileCancel { + w.deliverCancel(delivery.call, delivery.message) + continue + } + w.pair.mu.Lock() + registration := w.receiver + if registration != nil && registration.receiver.Message == nil { + registration = nil + } + if w.pair.closed { + w.pair.mu.Unlock() + return + } + if delivery.call != nil { + if registration == nil || w.active >= w.pair.options.MaxConcurrentHandlers { + w.pair.mu.Unlock() + code, message := "method_not_found", "Unknown method" + if registration != nil { + code, message = "busy", "Too many concurrent requests" + } + sendWireResponse(delivery.message, nil, &PublicError{Code: code, Message: message}) + continue + } + call := delivery.call + call.registration, call.active = registration, true + w.active++ + base := context.Background() + if source, ok := call.key.address.Wire.(interface{ wireDispatch() *wireDispatchContext }); ok { + call.dispatch = source.wireDispatch() + if call.dispatch != nil { + base = call.dispatch.ctx + } + } + if call.dispatch == nil { + base = w.pair.options.Propagator.Extract(base, Trace{Parent: delivery.message.Frame.Traceparent, State: delivery.message.Frame.Tracestate}) + } + ctx, cancel := context.WithCancel(base) + call.cancel = cancel + if call.dispatch != nil { + copied := *call.dispatch + copied.ctx = ctx + call.dispatch = &copied + } else { + name, _ := duplex.EncodePath(delivery.path) + call.dispatch = &wireDispatchContext{ctx: ctx, maxFrameBytes: w.pair.options.MaxFrameBytes, panic: func(value any) { + w.pair.observe(HandlerPanic{At: time.Now(), Method: name, Value: fmt.Sprint(value), Family: w.pair.options.Families[name]}) + }} + } + call.timer = time.AfterFunc(w.pair.options.RequestTimeout, func() { w.timeout(call) }) + } + w.pair.mu.Unlock() + if registration == nil { + continue + } + if delivery.message.Frame.Kind == duplex.ProfileEvent { + if _, associated := eventContextOf(delivery.message); !associated { + ctx := w.pair.options.Propagator.Extract(context.Background(), Trace{Parent: delivery.message.Frame.Traceparent, State: delivery.message.Frame.Tracestate}) + delivery.message = withWireEventContext(delivery.message, ctx) + } + w.pair.mu.Lock() + w.eventTimer = time.AfterFunc(w.pair.options.WriteTimeout, func() { + w.pair.mu.Lock() + closed, depth := w.pair.closed, w.dataQueued + w.pair.mu.Unlock() + if !closed { + w.pair.observe(Backpressure{At: time.Now(), Queued: depth, Stalled: true, Deadline: w.pair.options.WriteTimeout}) + w.pair.end(duplex.CodeDuplex, "local wire event consumer stalled") + } + }) + w.pair.mu.Unlock() + } + w.deliver(registration, delivery.path, delivery.message) + if delivery.message.Frame.Kind == duplex.ProfileEvent { + w.pair.mu.Lock() + w.eventTimer.Stop() + w.eventTimer = nil + w.pair.mu.Unlock() + } + } +} + +func (w *localWire) deliver(registration *localRegistration, path []string, message duplex.Message) { + defer func() { + if value := recover(); value != nil { + name, _ := duplex.EncodePath(path) + w.pair.observe(HandlerPanic{At: time.Now(), Method: name, Value: fmt.Sprint(value), Family: w.pair.options.Families[name]}) + if message.Frame.Kind == duplex.ProfileRequest { + sendWireResponse(message, nil, errors.New("wire receiver panic")) + } else { + w.pair.end(duplex.CodeDuplex, "wire event receiver failed") + } + } + }() + registration.receiver.Message(path, message) +} + +func (w *localWire) deliverCancel(call *localWireCall, message duplex.Message) { + w.pair.mu.Lock() + call.cancelQueued, call.cancelled = false, true + registration, completed := call.registration, call.completed + if call.cancel != nil { + call.cancel() + } + w.retireLocked(call) + w.pair.mu.Unlock() + if !completed && registration != nil { + w.deliver(registration, call.path, message) + } +} + +func (w *localWire) timeout(call *localWireCall) { + w.pair.mu.Lock() + if w.pair.closed || call.completed { + w.pair.mu.Unlock() + return + } + if call.cancel != nil { + call.cancel() + } + if !call.cancelQueued && !call.cancelled { + call.cancelQueued = true + w.queue = append(w.queue, localDelivery{path: call.path, message: duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: call.message.Frame.ID}, Return: call.returning}, call: call}) + } + respond := !call.responded + call.responded = true + w.pair.mu.Unlock() + w.signal() + // A timeout answers the caller but retains the handler's budget until its + // actual response. An application ignoring cancellation cannot spawn an + // unbounded number of replacement handlers by repeatedly timing out. + if respond { + sendWireResponse(call.message, nil, context.DeadlineExceeded) + } +} + +func (w *localWire) Receive(receiver duplex.Receiver) (func(), error) { + registration := &localRegistration{receiver: receiver, active: true} + w.pair.mu.Lock() + defer w.pair.mu.Unlock() + if w.pair.closed { + return nil, ErrClosed + } + if w.receiver != nil { + return nil, duplex.ErrReceiverExists + } + w.receiver = registration + return func() { + w.pair.mu.Lock() + if w.receiver == registration { + w.receiver = nil + registration.active = false + } + w.pair.mu.Unlock() + }, nil +} +func (w *localWire) Close(code duplex.Code, reason string) error { + w.pair.end(code, reason) + return nil +} +func (p *localWirePair) observe(event ObserverEvent) { + if p.options.Observer != nil { + func() { defer func() { _ = recover() }(); p.options.Observer.Observe(event) }() + } +} +func (p *localWirePair) end(code duplex.Code, reason string) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return + } + p.closed = true + close(p.done) + var receivers []duplex.Receiver + var requests []duplex.Message + for _, end := range p.ends { + if end.eventTimer != nil { + end.eventTimer.Stop() + } + if end.receiver != nil { + end.receiver.active = false + receivers = append(receivers, end.receiver.receiver) + } + for _, call := range end.calls { + if call.timer != nil { + call.timer.Stop() + } + if call.cancel != nil { + call.cancel() + } + if !call.responded { + requests = append(requests, call.message) + call.responded = true + } + call.completed = true + call.invocation.Settle() + call.invocation.DispatchDone() + } + end.receiver = nil + end.calls = map[returnKey]*localWireCall{} + end.queue, end.dataQueued = nil, 0 + } + p.mu.Unlock() + p.observe(ConnectionClosed{At: time.Now(), Code: int(code), Reason: reason, Local: true}) + go func() { + for _, receiver := range receivers { + if receiver.Closed != nil { + func() { defer func() { _ = recover() }(); receiver.Closed(code, reason) }() + } + } + for _, request := range requests { + sendWireResponse(request, nil, ErrClosed) + } + }() +} + +type localReturn struct { + wire *localWire + call *localWireCall +} + +func (r *localReturn) wireDispatch() *wireDispatchContext { return r.call.dispatch } + +// Invocation exposes this return capability's lifecycle to the pair that owns +// it. Participants reach the same state through the vocabulary on Send. +func (r *localReturn) Invocation() *Invocation { return r.call.invocation } + +func (r *localReturn) Send(path []string, message duplex.Message) (err error) { + if len(path) != 0 { + return r.call.invocation.Deliver(path, message) + } + if message.Frame.Kind != duplex.ProfileResponse || message.Frame.ID != r.call.message.Frame.ID { + return errors.New("invalid wire response") + } + if err := validateWireFrame("", message.Frame, r.wire.pair.options.MaxFrameBytes); err != nil { + return err + } + // Validation refuses an attempt before completion, allowing the shared + // response helper to substitute its bounded internal-error fallback. + defer r.wire.complete(r.call) + defer func() { + if value := recover(); value != nil { + err = fmt.Errorf("wire return failed: %v", value) + } + }() + r.wire.pair.mu.Lock() + if r.call.responded || r.call.completed { + r.wire.pair.mu.Unlock() + return ErrClosed + } + r.call.responded = true + r.wire.pair.mu.Unlock() + r.call.invocation.Settle() + message.Frame.Result = append(json.RawMessage(nil), message.Frame.Result...) + if message.Frame.Error != nil { + copied := *message.Frame.Error + copied.Data = append(json.RawMessage(nil), copied.Data...) + message.Frame.Error = &copied + } + return WithoutUnpublishedProof(r.call.key.address.Wire.Send(path, message)) +} diff --git a/core/go/pair_test.go b/core/go/pair_test.go new file mode 100644 index 0000000..70e9411 --- /dev/null +++ b/core/go/pair_test.go @@ -0,0 +1,330 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +type localTestReturn struct{ send func(duplex.Message) error } + +type localTestObserver func(ObserverEvent) + +func (observe localTestObserver) Observe(event ObserverEvent) { observe(event) } + +func (r *localTestReturn) Send(_ []string, m duplex.Message) error { return r.send(m) } + +func testBinding(t *testing.T, endpoint duplex.Endpoint) *Dispatcher { + t.Helper() + binding, err := NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = binding.Close(duplex.CodeNormal, "done") }) + return binding +} + +func localPair(t *testing.T, options Options) (duplex.Endpoint, duplex.Endpoint) { + t.Helper() + a, b, err := NewWirePair(options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = a.Close(duplex.CodeNormal, "done") }) + return a, b +} + +func TestLocalWirePairRoundTripReverseAndIsolation(t *testing.T) { + a, b := localPair(t, Options{}) + aBinding := testBinding(t, a) + _, err := HandleWire(aBinding, []string{"reverse"}, func(_ context.Context, raw json.RawMessage) (any, error) { return string(raw), nil }) + if err != nil { + t.Fatal(err) + } + bBinding := testBinding(t, b) + _, err = HandleWire(bBinding, []string{"call"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var result string + err := CallWire(ctx, b, []string{"reverse"}, raw, &result) + return result, err + }) + if err != nil { + t.Fatal(err) + } + var result string + if err := CallWire(context.Background(), a, []string{"call"}, 7, &result); err != nil || result != "7" { + t.Fatalf("reverse result %q: %v", result, err) + } + x, y := localPair(t, Options{}) + yBinding := testBinding(t, y) + _, _ = HandleWire(yBinding, []string{"call"}, func(context.Context, json.RawMessage) (any, error) { return "independent", nil }) + _ = a.Close(duplex.CodeNormal, "first pair only") + if err := CallWire(context.Background(), x, []string{"call"}, nil, &result); err != nil || result != "independent" { + t.Fatalf("other pair %q: %v", result, err) + } +} + +func TestLocalWirePairRetainsPendingUntilResponse(t *testing.T) { + a, b := localPair(t, Options{MaxPendingRequests: 1}) + started, release := make(chan struct{}), make(chan struct{}) + bBinding := testBinding(t, b) + _, _ = HandleWire(bBinding, []string{"hold"}, func(context.Context, json.RawMessage) (any, error) { close(started); <-release; return "done", nil }) + first := make(chan error, 1) + go func() { var result string; first <- CallWire(context.Background(), a, []string{"hold"}, nil, &result) }() + <-started + var result any + err := CallWire(context.Background(), a, []string{"hold"}, nil, &result) + var public *PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("pending budget: %v", err) + } + close(release) + if err := <-first; err != nil { + t.Fatal(err) + } + _, _ = HandleWire(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) + if err := CallWire(context.Background(), a, []string{"next"}, nil, &result); err != nil { + t.Fatal(err) + } +} + +func TestLocalWirePairOrderedEventsAndReservedCancel(t *testing.T) { + a, b := localPair(t, Options{QueueCapacity: 1, MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + started, cancelled := make(chan struct{}), make(chan struct{}) + _, _ = HandleWire(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(cancelled) + return nil, ctx.Err() + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + answer := make(chan error, 1) + go func() { answer <- CallWire(ctx, a, []string{"hold"}, nil, nil) }() + <-started + entered, release, drained := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var mu sync.Mutex + var seen []int + _, _ = bBinding.Register([]string{"event"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + var n int + _ = json.Unmarshal(m.Frame.Data, &n) + mu.Lock() + seen = append(seen, n) + mu.Unlock() + if n == 1 { + close(entered) + <-release + } else { + close(drained) + } + }}) + if err := EmitWire(context.Background(), a, []string{"event"}, 1); err != nil { + t.Fatal(err) + } + <-entered + if err := EmitWire(context.Background(), a, []string{"event"}, 2); err != nil { + t.Fatal(err) + } + cancel() + if !errors.Is(<-answer, context.Canceled) { + t.Fatal("caller was not cancelled") + } + close(release) + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("reserved cancel did not arrive") + } + <-drained + mu.Lock() + defer mu.Unlock() + if len(seen) != 2 || seen[0] != 1 || seen[1] != 2 { + t.Fatalf("event order: %v", seen) + } +} + +func TestLocalWirePairOverflowClosesOnlyItsCarrier(t *testing.T) { + a, b := localPair(t, Options{QueueCapacity: 1}) + bBinding := testBinding(t, b) + entered, release, ended := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer close(release) + _, _ = bBinding.Register([]string{"event"}, duplex.Receiver{Message: func([]string, duplex.Message) { close(entered); <-release }, Closed: func(duplex.Code, string) { close(ended) }}) + if err := EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + <-entered + if err := EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + if err := EmitWire(context.Background(), a, []string{"event"}, nil); !errors.Is(err, ErrBackpressure) { + t.Fatalf("overflow: %v", err) + } + select { + case <-ended: + case <-time.After(time.Second): + t.Fatal("blocked consumer hid closure") + } +} + +func TestLocalWirePairReturnMappingAndFailedResponseRetirement(t *testing.T) { + a, b := localPair(t, Options{MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + received := make(chan duplex.Message, 2) + _, _ = bBinding.Register([]string{"raw"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { received <- m }}) + failed := errors.New("return failed") + original := &duplex.ReturnAddress{Wire: &localTestReturn{send: func(duplex.Message) error { return Unpublished(failed) }}} + if err := a.Send([]string{"raw"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage("null")}, Return: original}); err != nil { + t.Fatal(err) + } + request := <-received + if request.Return == original { + t.Fatal("root did not map the return capability") + } + if err := a.Send([]string{"raw"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: "c:1"}, Return: original}); err != nil { + t.Fatal(err) + } + if cancelled := <-received; cancelled.Return != request.Return { + t.Fatal("cancellation used a different return capability") + } + err := request.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: "c:1", Result: json.RawMessage("null")}}) + if !errors.Is(err, failed) { + t.Fatalf("return failure lost: %v", err) + } + var unpublished *UnpublishedError + if errors.As(err, &unpublished) { + t.Fatal("return retained publication proof after dispatch") + } + _, _ = HandleWire(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) + var result string + if err := CallWire(context.Background(), a, []string{"next"}, nil, &result); err != nil || result != "reused" { + t.Fatalf("next: %q, %v", result, err) + } +} + +func TestLocalWirePairPrivateDispatchContext(t *testing.T) { + a, b := localPair(t, Options{}) + type verifiedKey struct{} + verified := &struct{ identity string }{"verified locally"} + dispatch := &wireDispatchContext{ctx: context.WithValue(context.Background(), verifiedKey{}, verified)} + bBinding := testBinding(t, b) + _, _ = HandleWire(bBinding, []string{"inspect"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + if ctx.Value(verifiedKey{}) != verified { + return nil, errors.New("lost verified dispatch context") + } + return "observed", nil + }) + var result string + if err := callWire(context.Background(), a, []string{"inspect"}, nil, &result, dispatch); err != nil || result != "observed" { + t.Fatalf("context: %q, %v", result, err) + } +} + +func TestLocalDispatcherExactAndPrefixRoutes(t *testing.T) { + a, b := localPair(t, Options{}) + bBinding := testBinding(t, b) + for _, path := range [][]string{nil, {"a"}} { + label := "root" + if len(path) > 0 { + label = "a" + } + _, err := bBinding.RegisterPrefix(path, duplex.Receiver{Message: func(received []string, m duplex.Message) { + if len(received) == 0 { + t.Error("callback path lost its origin") + } + sendWireResponse(m, json.RawMessage(`"`+label+`"`), nil) + }}) + if err != nil { + t.Fatal(err) + } + } + detach, _ := HandleWire(bBinding, []string{"a", "b"}, func(context.Context, json.RawMessage) (any, error) { return "exact", nil }) + var result string + if err := CallWire(context.Background(), a, []string{"a", "b"}, nil, &result); err != nil || result != "exact" { + t.Fatalf("exact: %q, %v", result, err) + } + detach() + if err := CallWire(context.Background(), a, []string{"a", "b"}, nil, &result); err != nil || result != "a" { + t.Fatalf("prefix: %q, %v", result, err) + } + if err := CallWire(context.Background(), a, []string{"other"}, nil, &result); err != nil || result != "root" { + t.Fatalf("root: %q, %v", result, err) + } +} + +func TestLocalWirePairDeadlineRetainsNoncooperativeHandlerBudget(t *testing.T) { + a, b := localPair(t, Options{RequestTimeout: 15 * time.Millisecond, MaxConcurrentHandlers: 1}) + started, cancelled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer close(release) + bBinding := testBinding(t, b) + _, _ = HandleWire(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(cancelled) + <-release + return nil, nil + }) + first := make(chan error, 1) + go func() { first <- CallWire(context.Background(), a, []string{"hold"}, nil, nil) }() + <-started + var public *PublicError + if err := <-first; !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatalf("deadline: %v", err) + } + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("deadline did not cancel handler") + } + err := CallWire(context.Background(), a, []string{"hold"}, nil, nil) + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("handler budget: %v", err) + } +} + +func TestLocalWirePairStalledEventDeadlineIsObserved(t *testing.T) { + pressure := make(chan Backpressure, 1) + a, b := localPair(t, Options{WriteTimeout: 15 * time.Millisecond, Observer: localTestObserver(func(event ObserverEvent) { + if event, ok := event.(Backpressure); ok && event.Stalled { + pressure <- event + } + })}) + bBinding := testBinding(t, b) + release, closed := make(chan struct{}), make(chan struct{}) + defer close(release) + _, _ = bBinding.Register([]string{"event"}, duplex.Receiver{Message: func([]string, duplex.Message) { <-release }, Closed: func(duplex.Code, string) { close(closed) }}) + if err := EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + select { + case event := <-pressure: + if event.Deadline != 15*time.Millisecond { + t.Fatalf("deadline: %v", event.Deadline) + } + case <-time.After(time.Second): + t.Fatal("stalled event was not observed") + } + <-closed + if err := EmitWire(context.Background(), a, []string{"event"}, nil); !errors.Is(err, ErrClosed) { + t.Fatalf("closed pair: %v", err) + } +} + +func TestLocalWirePairOversizedResponseUsesBoundedFallback(t *testing.T) { + a, b := localPair(t, Options{MaxFrameBytes: 512}) + bBinding := testBinding(t, b) + _, _ = HandleWire(bBinding, []string{"large"}, func(context.Context, json.RawMessage) (any, error) { return strings.Repeat("x", 2048), nil }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var result string + err := CallWire(ctx, a, []string{"large"}, nil, &result) + var public *PublicError + if !errors.As(err, &public) || public.Code != "internal" { + t.Fatalf("oversized response: %v", err) + } +} diff --git a/core/go/publication.go b/core/go/publication.go new file mode 100644 index 0000000..15b4a3d --- /dev/null +++ b/core/go/publication.go @@ -0,0 +1,57 @@ +package runtime + +import "errors" + +// UnpublishedError reports a local refusal before a frame entered the peer's +// outbound queue or a local implementation dispatched. Its underlying cause +// retains the ordinary public error or cancellation identity. A queued write failure or a remote response never +// supplies this proof, even if it has the same error code or message. +// +// The proof belongs to this send attempt. A handler must not treat a nested +// call's refusal as proof that its own already-dispatched request was unsent. +type UnpublishedError struct { + cause error +} + +func (e *UnpublishedError) Error() string { return e.cause.Error() } + +// Unwrap preserves errors.Is and errors.As for the original refusal. +func (e *UnpublishedError) Unwrap() error { return e.cause } + +// Unpublished marks an error only at a boundary that can prove its own payload +// was neither queued nor dispatched locally. It preserves a nil error. Never +// use it to classify a received error code or an uncertain transport outcome. +func Unpublished(err error) error { + if err == nil { + return nil + } + return &UnpublishedError{cause: err} +} + +// WithoutUnpublishedProof preserves the ordinary cause of an error crossing a +// dispatch boundary, but removes evidence that belongs to a nested send attempt. +// Transport adapters and local implementations can return another call's error; +// that error cannot prove that the surrounding request was never published. +func WithoutUnpublishedProof(err error) error { + var proof *UnpublishedError + if errors.As(err, &proof) { + return dispatchedError{cause: err} + } + return err +} + +type dispatchedError struct{ cause error } + +func (e dispatchedError) Error() string { return e.cause.Error() } +func (e dispatchedError) Is(target error) bool { + if _, proof := target.(*UnpublishedError); proof { + return false + } + return errors.Is(e.cause, target) +} +func (e dispatchedError) As(target any) bool { + if _, proof := target.(**UnpublishedError); proof { + return false + } + return errors.As(e.cause, target) +} diff --git a/core/go/publication_test.go b/core/go/publication_test.go new file mode 100644 index 0000000..c899486 --- /dev/null +++ b/core/go/publication_test.go @@ -0,0 +1,187 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +func wantUnpublished(t *testing.T, err error, want bool) { + t.Helper() + var unpublished *ws.UnpublishedError + if got := errors.As(err, &unpublished); got != want || err == nil { + t.Fatalf("publication proof: %v, want unpublished=%v", err, want) + } +} + +func TestUnpublishedProofIsLocalToTheSendAttempt(t *testing.T) { + entered, finish := make(chan struct{}, 1), make(chan struct{}) + client, server := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "wait": func(c context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + entered <- struct{}{} + select { + case <-finish: + return nil, nil + case <-c.Done(): + return nil, c.Err() + } + }, + "busy": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { + return nil, &ws.PublicError{Code: "busy", Message: "retained before refusing"} + }, + "nested": func(c context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + withdrawn, cancel := context.WithCancel(c) + cancel() + return nil, peer.Call(withdrawn, "never.sent", nil, nil) + }, + }}, ws.Options{MaxPendingRequests: 1, MaxFrameBytes: 512}) + _ = server + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := client.Call(ctx, "wait", nil, nil) + wantUnpublished(t, err, true) + if !errors.Is(err, context.Canceled) { + t.Fatalf("wrapping lost cancellation identity: %v", err) + } + wantUnpublished(t, client.Call(context.Background(), "wait", make(chan int), nil), true) + wantUnpublished(t, client.Call(context.Background(), "wait", strings.Repeat("x", 1024), nil), true) + wantUnpublished(t, client.Emit(context.Background(), "event", make(chan int)), true) + wantUnpublished(t, client.Emit(context.Background(), "event", strings.Repeat("x", 1024)), true) + + done := make(chan error, 1) + go func() { done <- client.Call(context.Background(), "wait", nil, nil) }() + receive(t, entered) + err = client.Call(context.Background(), "busy", nil, nil) + wantUnpublished(t, err, true) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("wrapping lost public refusal: %v", err) + } + close(finish) + if err := receive(t, done); err != nil { + t.Fatal(err) + } + + // The same public code received from the other side carries no proof. + err = client.Call(context.Background(), "busy", nil, nil) + wantUnpublished(t, err, false) + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatal(err) + } + // A marker from a nested, definitely-unsent call must not cross the wire + // as proof about the request whose implementation has already run. + err = client.Call(context.Background(), "nested", nil, nil) + wantUnpublished(t, err, false) + if !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatal(err) + } +} + +type publicationWriteFailure struct { + duplex.Conn + wrote chan struct{} + err error +} + +func (c *publicationWriteFailure) Send(context.Context, duplex.Frame) error { + close(c.wrote) + return c.err +} + +func TestQueuedWriteFailureHasNoUnpublishedProof(t *testing.T) { + client, _ := newPair(t, ws.Options{}, ws.Options{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + proof := client.Call(ctx, "unsent", nil, nil) + wantUnpublished(t, proof, true) + for _, tc := range []struct { + name string + cause, identity error + }{ + {"plain", errors.New("transport failed after accepting a queued frame"), nil}, + {"nested", fmt.Errorf("adapter send: %w", proof), context.Canceled}, + } { + t.Run(tc.name, func(t *testing.T) { + near, far := duplex.Pipe(1 << 20) + defer far.Abort() + cause := tc.cause + connection := &publicationWriteFailure{Conn: near, wrote: make(chan struct{}), err: cause} + peer, err := ws.NewPeer(context.Background(), connection, ws.ClientRole, ws.Options{}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + err = peer.Call(context.Background(), "supply", nil, nil) + receive(t, connection.wrote) + wantUnpublished(t, err, false) + if !errors.Is(err, cause) { + t.Fatalf("transport cause lost: %v", err) + } + if tc.identity != nil && !errors.Is(err, tc.identity) { + t.Fatalf("nested cause lost: %v", err) + } + }) + } +} + +type publicationReadFailure struct { + duplex.Conn + wrote chan struct{} + err error +} + +func (c *publicationReadFailure) Send(context.Context, duplex.Frame) error { + close(c.wrote) + return nil +} +func (c *publicationReadFailure) Receive(context.Context) (duplex.Frame, error) { + <-c.wrote + return duplex.Frame{}, c.err +} + +func TestReadFailureHasNoNestedUnpublishedProof(t *testing.T) { + client, _ := newPair(t, ws.Options{}, ws.Options{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + proof := client.Call(ctx, "unsent", nil, nil) + near, far := duplex.Pipe(1 << 20) + defer far.Abort() + connection := &publicationReadFailure{Conn: near, wrote: make(chan struct{}), err: fmt.Errorf("adapter receive: %w", proof)} + peer, err := ws.NewPeer(context.Background(), connection, ws.ClientRole, ws.Options{}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + err = peer.Call(context.Background(), "supply", nil, nil) + wantUnpublished(t, err, false) + if !errors.Is(err, context.Canceled) { + t.Fatalf("nested cause lost: %v", err) + } +} + +func TestRefusedReverseReplyCannotProveDeliveredCallUnpublished(t *testing.T) { + var delivered atomic.Bool + client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "a": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + delivered.Store(true) + return nil, peer.Call(ctx, "b", nil, nil) + }, + }}, ws.Options{MaxFrameBytes: 180, Handlers: map[string]ws.Handler{ + "b": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return strings.Repeat("x", 2000), nil }, + }}) + err := client.Call(context.Background(), "a", nil, nil) + if !delivered.Load() { + t.Fatal("outer request was not delivered") + } + if client.Err() == nil { + t.Fatal("oversized reply fallback did not reach the failure broadcast") + } + wantUnpublished(t, err, false) +} diff --git a/core/go/trace.go b/core/go/trace.go new file mode 100644 index 0000000..776ed48 --- /dev/null +++ b/core/go/trace.go @@ -0,0 +1,70 @@ +package runtime + +import ( + "context" + "crypto/rand" + "encoding/hex" +) + +// Trace is the W3C Trace Context a frame carries: the two members verbatim, +// neither of them read by the runtime beyond the form the decoder holds a +// traceparent to. The runtime imports no tracing library; one binds to it as a +// Propagator and nowhere else. +type Trace struct{ Parent, State string } + +// Propagator moves a trace between a context and a frame. Extract places an +// incoming frame's trace in the context its handler runs under; Inject says +// what an outgoing frame carries โ€” a child of the trace the context holds, or +// a new trace where it holds none. Both members reach it verbatim, and a peer +// sends what it returns as it returns it. +type Propagator interface { + Extract(ctx context.Context, trace Trace) context.Context + Inject(ctx context.Context) Trace +} + +// DefaultPropagator is what a peer with no Options.Propagator propagates by: it +// keeps an incoming trace verbatim and mints an outgoing one's ids itself, so +// that frames correlate across hops with no tracing library installed. +var DefaultPropagator Propagator = w3cPropagator{} + +type traceKey struct{} + +// TraceOf is the trace DefaultPropagator placed in ctx, and whether one is +// there. A propagator of another making keeps its trace where it chooses. +func TraceOf(ctx context.Context) (Trace, bool) { + trace, ok := ctx.Value(traceKey{}).(Trace) + return trace, ok +} + +// w3cPropagator carries the trace under a key of its own and mints ids in the +// one form W3C Trace Context gives them: version 00, sampled, a tracestate +// forwarded exactly as it arrived. +type w3cPropagator struct{} + +// Extract keeps a trace whose traceparent the decoder has already held to its +// form. A frame carrying none leaves the context as it was, so that what is +// sent from there begins a trace rather than continuing one that is not there. +func (w3cPropagator) Extract(ctx context.Context, trace Trace) context.Context { + if trace.Parent == "" { + return ctx + } + return context.WithValue(ctx, traceKey{}, trace) +} + +// Inject mints a child of the context's trace โ€” its trace id and its flags, a +// span id of this frame's own โ€” or a new trace where the context carries none. +func (w3cPropagator) Inject(ctx context.Context) Trace { + parent, ok := TraceOf(ctx) + if !ok || !validTraceparent(parent.Parent) { + return Trace{Parent: "00-" + randomID(16) + "-" + randomID(8) + "-01"} + } + return Trace{Parent: parent.Parent[:36] + randomID(8) + parent.Parent[52:], State: parent.State} +} + +// randomID is n random bytes in lower-case hexadecimal. crypto/rand fills the +// buffer or does not return, so a trace id is never the zero one by accident. +func randomID(n int) string { + id := make([]byte, n) + rand.Read(id) + return hex.EncodeToString(id) +} diff --git a/core/go/trace_test.go b/core/go/trace_test.go new file mode 100644 index 0000000..464c302 --- /dev/null +++ b/core/go/trace_test.go @@ -0,0 +1,233 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "testing" + + ws "github.com/Bitspark/nightseam/runtime/go" + "github.com/coder/websocket" +) + +// The trace a test sends and expects to see continued: one traceparent of the +// W3C form and a tracestate the runtime never reads, only forwards. +var carried = ws.Trace{Parent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", State: "congo=t61rcWkgMzE"} + +const carriedMembers = `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"congo=t61rcWkgMzE"` + +func frameMember(t *testing.T, members map[string]json.RawMessage, name string) string { + t.Helper() + raw, present := members[name] + if !present { + return "" + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("member %s = %s: %v", name, raw, err) + } + return value +} + +// frameTrace is what one frame carries, read off the wire rather than from the +// peer that wrote it. +func frameTrace(t *testing.T, members map[string]json.RawMessage) ws.Trace { + t.Helper() + return ws.Trace{Parent: frameMember(t, members, "traceparent"), State: frameMember(t, members, "tracestate")} +} + +// childOf holds a trace to what a child of parent is: the same trace id and +// flags, a span id of its own, and the tracestate it inherited verbatim. +func childOf(t *testing.T, parent, child ws.Trace) { + t.Helper() + if len(child.Parent) != 55 { + t.Fatalf("child traceparent %q is not of the W3C form", child.Parent) + } + if child.Parent[:36] != parent.Parent[:36] || child.Parent[52:] != parent.Parent[52:] { + t.Fatalf("child %q is not of the trace %q", child.Parent, parent.Parent) + } + if child.Parent[36:52] == parent.Parent[36:52] { + t.Fatalf("child %q reuses its parent's span id", child.Parent) + } + for _, c := range child.Parent[36:52] { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Fatalf("child span id in %q is not lower-case hexadecimal", child.Parent) + } + } + if child.State != parent.State { + t.Fatalf("child tracestate = %q, want %q verbatim", child.State, parent.State) + } +} + +// TestOutgoingFramesContinueTheContextTrace: a request sent from a context +// carrying a trace carries a child of it, and the cancellation that follows +// carries that request's members rather than a sibling span of them. +func TestOutgoingFramesContinueTheContextTrace(t *testing.T) { + peer, conn, ctx := rawPeer(t, ws.Options{}) + traced, cancel := context.WithCancel(ws.DefaultPropagator.Extract(context.Background(), carried)) + t.Cleanup(cancel) + returned := make(chan error, 1) + go func() { returned <- peer.Call(traced, "far", nil, nil) }() + + request := readFrame(ctx, t, conn) + sent := frameTrace(t, request) + childOf(t, carried, sent) + cancel() + cancellation := readFrame(ctx, t, conn) + if string(cancellation["kind"]) != `"cancel"` || string(cancellation["id"]) != string(request["id"]) { + t.Fatalf("frame after the cancelled call = %v", cancellation) + } + if got := frameTrace(t, cancellation); got != sent { + t.Fatalf("cancel carried %+v, not its request's %+v", got, sent) + } + receive(t, returned) +} + +// TestARequestFromABareContextCarriesANewTrace: nothing correlates two calls +// made from a context that carries no trace, and each is nonetheless traced โ€” +// a peer with no propagator configured still says where its frames came from. +func TestARequestFromABareContextCarriesANewTrace(t *testing.T) { + peer, conn, ctx := rawPeer(t, ws.Options{}) + traces := make([]ws.Trace, 0, 2) + for range 2 { + go func() { _ = peer.Call(context.Background(), "far", nil, nil) }() + trace := frameTrace(t, readFrame(ctx, t, conn)) + if len(trace.Parent) != 55 || trace.Parent[:3] != "00-" || trace.Parent[52:] != "-01" { + t.Fatalf("a new trace = %q, not a sampled traceparent of version 00", trace.Parent) + } + if trace.State != "" { + t.Fatalf("a new trace carries the tracestate %q of nothing", trace.State) + } + traces = append(traces, trace) + } + if traces[0].Parent[3:35] == traces[1].Parent[3:35] { + t.Fatalf("two calls from bare contexts share the trace id in %q", traces[0].Parent) + } +} + +// TestAHandlerRunsUnderItsRequestsTrace: the trace of an incoming request is in +// the context its handler runs under and readable there; what the handler sends +// is a child of it; the response carries the request's members byte for byte. +// An incoming event's trace reaches its handler the same way. +func TestAHandlerRunsUnderItsRequestsTrace(t *testing.T) { + handlerTraces := make(chan ws.Trace, 2) + peer, conn, ctx := rawPeer(t, ws.Options{ + Events: map[string]ws.EventHandler{ + "progress": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) { + trace, _ := ws.TraceOf(ctx) + handlerTraces <- trace + }, + }, + Handlers: map[string]ws.Handler{ + "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + trace, found := ws.TraceOf(ctx) + if !found { + return nil, ws.ErrClosed + } + handlerTraces <- trace + if err := peer.Emit(ctx, "progress", 1); err != nil { + return nil, err + } + var answer string + if err := peer.Call(ctx, "reverse", nil, &answer); err != nil { + return nil, err + } + return answer, nil + }, + }, + }) + write := func(data string) { + t.Helper() + if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { + t.Fatal(err) + } + } + write(`{"version":1,"kind":"request","id":"c:1","method":"outer","params":{},` + carriedMembers + `}`) + if got := receive(t, handlerTraces); got != carried { + t.Fatalf("the handler's context carried %+v, not the request's %+v", got, carried) + } + event := readFrame(ctx, t, conn) + if string(event["event"]) != `"progress"` { + t.Fatalf("frame after the traced request = %v", event) + } + emitted := frameTrace(t, event) + childOf(t, carried, emitted) + reverse := readFrame(ctx, t, conn) + if string(reverse["method"]) != `"reverse"` { + t.Fatalf("frame after the emitted event = %v", reverse) + } + called := frameTrace(t, reverse) + childOf(t, carried, called) + if called.Parent == emitted.Parent { + t.Fatalf("two frames of one handler share the span id in %q", called.Parent) + } + write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back"}`) + response := readFrame(ctx, t, conn) + if string(response["id"]) != `"c:1"` || string(response["result"]) != `"back"` { + t.Fatalf("response to the traced request = %v", response) + } + if got := frameTrace(t, response); got != carried { + t.Fatalf("the response carried %+v, not its request's %+v", got, carried) + } + write(`{"version":1,"kind":"event","event":"progress","data":1,` + carriedMembers + `}`) + if got := receive(t, handlerTraces); got != carried { + t.Fatalf("the event handler's context carried %+v, not the event's %+v", got, carried) + } + if peer.Err() != nil { + t.Fatalf("a traced exchange closed the connection: %v", peer.Err()) + } +} + +// recordingPropagator is a propagator of another making: it records what it is +// asked to extract and dictates what every outgoing frame carries. +type recordingPropagator struct { + extracted chan ws.Trace + injects ws.Trace +} + +func (p *recordingPropagator) Extract(ctx context.Context, trace ws.Trace) context.Context { + p.extracted <- trace + return ctx +} + +func (p *recordingPropagator) Inject(context.Context) ws.Trace { return p.injects } + +// TestACustomPropagatorSeesTheMembersVerbatim: the peer neither reads nor +// rewrites what a configured propagator is given or returns โ€” the incoming +// members reach Extract as they arrived, and what Inject returns is what the +// outgoing frame carries. Only a response keeps its request's own members. +func TestACustomPropagatorSeesTheMembersVerbatim(t *testing.T) { + propagator := &recordingPropagator{ + extracted: make(chan ws.Trace, 2), + injects: ws.Trace{Parent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00", State: "rojo=00f067aa0ba902b7"}, + } + peer, conn, ctx := rawPeer(t, ws.Options{ + Propagator: propagator, + Handlers: map[string]ws.Handler{ + "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + var answer string + return answer, peer.Call(ctx, "reverse", nil, &answer) + }, + }, + }) + write := func(data string) { + t.Helper() + if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { + t.Fatal(err) + } + } + write(`{"version":1,"kind":"request","id":"c:1","method":"outer","params":{},` + carriedMembers + `}`) + if got := receive(t, propagator.extracted); got != carried { + t.Fatalf("Extract saw %+v, not the members %+v the frame carried", got, carried) + } + reverse := readFrame(ctx, t, conn) + if got := frameTrace(t, reverse); got != propagator.injects { + t.Fatalf("the outgoing request carried %+v, not the %+v Inject returned", got, propagator.injects) + } + write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back"}`) + if got := frameTrace(t, readFrame(ctx, t, conn)); got != carried { + t.Fatalf("the response carried %+v, not its request's %+v", got, carried) + } + if peer.Err() != nil { + t.Fatalf("a custom propagator closed the connection: %v", peer.Err()) + } +} diff --git a/dispatch/go/bitwire_test.go b/dispatch/go/bitwire_test.go new file mode 100644 index 0000000..66de068 --- /dev/null +++ b/dispatch/go/bitwire_test.go @@ -0,0 +1,62 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "slices" + "testing" + "time" + + bitwire "github.com/Bitspark/bitwire/wire/go" + "github.com/Bitspark/nightseam/duplex/go" + runtime "github.com/Bitspark/nightseam/runtime/go" + "github.com/Bitspark/nightseam/tunnel/go" +) + +// These assignments cross the actual public package boundary. Independently +// named, structurally similar Message/Receiver/Code types do not satisfy it. +var _ bitwire.Wire = (duplex.Wire)(nil) +var _ duplex.Wire = (bitwire.Wire)(nil) +var _ bitwire.Endpoint = (duplex.Endpoint)(nil) +var _ duplex.Endpoint = (bitwire.Endpoint)(nil) +var _ bitwire.Endpoint = (*tunnel.Channel)(nil) + +func TestPublishedBitwireTypesCarryNightseamCalls(t *testing.T) { + left, right, err := runtime.NewWirePair(runtime.Options{}) + if err != nil { + t.Fatal(err) + } + var client, server bitwire.Endpoint = left, right + defer client.Close(duplex.CodeNormal, "done") + detach, err := server.Receive(bitwire.Receiver{ + Message: func(path []string, request bitwire.Message) { + if !slices.Equal(path, []string{"model", "read"}) { + t.Errorf("shared endpoint path = %v", path) + } + if request.Frame.Kind != bitwire.ProfileRequest || request.Return == nil { + t.Error("the shared receiver did not receive a request and return capability") + return + } + err := request.Return.Wire.Send(nil, bitwire.Message{Frame: bitwire.ProfileFrame{ + Version: 1, Kind: bitwire.ProfileResponse, ID: request.Frame.ID, Result: request.Frame.Params, + }}) + if err != nil { + t.Error(err) + } + }, + }) + if err != nil { + t.Fatal(err) + } + defer detach() + selected := duplex.At(duplex.Mount(map[string]bitwire.Endpoint{"service": client}), []string{"service", "model"}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var result json.RawMessage + if err := runtime.CallWire(ctx, selected, []string{"read"}, json.RawMessage(`{"shared":true}`), &result); err != nil { + t.Fatal(err) + } + if string(result) != `{"shared":true}` { + t.Fatalf("shared contract response: %s", result) + } +} diff --git a/dispatch/go/dispatcher.go b/dispatch/go/dispatcher.go new file mode 100644 index 0000000..213fcc4 --- /dev/null +++ b/dispatch/go/dispatcher.go @@ -0,0 +1,291 @@ +package runtime + +import ( + "errors" + "slices" + "sync" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// HandlerRegistry is the explicit registration capability used by generated +// bindings. Closing it releases its registrations, not its borrowed carrier. +type HandlerRegistry interface { + duplex.Wire + Register([]string, duplex.Receiver) (func(), error) + Close(duplex.Code, string) error +} + +type dispatchRegistration struct { + path []string + receiver duplex.Receiver +} +type dispatchRoute struct { + name string + prefix bool +} + +// Dispatcher owns one endpoint attachment and an explicit exact/longest-prefix +// routing policy. It captures each request's traversal on the invocation its +// return capability carries, through the public vocabulary alone, and refuses a +// request whose return capability carries none rather than routing it with +// weaker detach and cancellation guarantees. An opaque wrapper is therefore as +// good as a native endpoint: the lifecycle travels with the unchanged return +// capability, and nothing here recognizes a concrete type. +type Dispatcher struct { + root duplex.Endpoint + ownEndpoint bool + mu sync.Mutex + closed bool + detach func() + routes map[dispatchRoute]*dispatchRegistration +} + +// DispatcherOptions explicitly transfers closure authority for an endpoint the +// caller owns. Borrowed endpoints remain the default. +type DispatcherOptions struct{ OwnEndpoint bool } + +func NewDispatcher(root duplex.Endpoint, options ...DispatcherOptions) (*Dispatcher, error) { + if root == nil { + return nil, errors.New("dispatcher requires an endpoint") + } + d := &Dispatcher{root: root, routes: map[dispatchRoute]*dispatchRegistration{}} + if len(options) > 0 { + d.ownEndpoint = options[0].OwnEndpoint + } + detach, err := root.Receive(duplex.Receiver{Message: d.deliver, Closed: func(code duplex.Code, reason string) { _ = d.Close(code, reason) }}) + if err != nil { + return nil, err + } + d.mu.Lock() + closed := d.closed + if !closed { + d.detach = detach + } + d.mu.Unlock() + if closed { + detach() + return nil, ErrClosed + } + return d, nil +} + +func (d *Dispatcher) Send(path []string, message duplex.Message) error { + d.mu.Lock() + closed := d.closed + d.mu.Unlock() + if closed { + return ErrClosed + } + return d.root.Send(path, message) +} +func (d *Dispatcher) Register(path []string, receiver duplex.Receiver) (func(), error) { + return d.register(path, receiver, false) +} +func (d *Dispatcher) RegisterPrefix(path []string, receiver duplex.Receiver) (func(), error) { + return d.register(path, receiver, true) +} +func (d *Dispatcher) register(path []string, receiver duplex.Receiver, prefix bool) (func(), error) { + name, err := duplex.EncodePath(path) + if err != nil { + return nil, err + } + key := dispatchRoute{name, prefix} + registration := &dispatchRegistration{path: slices.Clone(path), receiver: receiver} + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return nil, ErrClosed + } + if d.routes[key] != nil { + return nil, duplex.ErrReceiverExists + } + d.routes[key] = registration + return func() { + d.mu.Lock() + if d.routes[key] == registration { + delete(d.routes, key) + } + d.mu.Unlock() + }, nil +} +func (d *Dispatcher) match(path []string, name string) *dispatchRegistration { + if exact := d.routes[dispatchRoute{name, false}]; exact != nil { + return exact + } + var selected *dispatchRegistration + for key, candidate := range d.routes { + if key.prefix && len(candidate.path) <= len(path) && (selected == nil || len(candidate.path) > len(selected.path)) && slices.Equal(candidate.path, path[:len(candidate.path)]) { + selected = candidate + } + } + return selected +} +func (d *Dispatcher) deliver(path []string, message duplex.Message) { + name, err := duplex.EncodePath(path) + if err != nil { + return + } + // A control belongs to the traversal that captured it, never to the + // registration in force now. Handing it to the invocation is what keeps a + // detach or a rebind from retargeting an admitted request. + if message.Frame.Kind == duplex.ProfileCancel { + _ = RelayInvocationControl(message) + return + } + d.mu.Lock() + var registration *dispatchRegistration + if !d.closed { + registration = d.match(path, name) + } + d.mu.Unlock() + if registration == nil || registration.receiver.Message == nil { + if message.Frame.Kind == duplex.ProfileRequest { + _ = sendWireResponse(message, nil, &PublicError{Code: "method_not_found", Message: "Unknown method"}) + } + return + } + delivered := slices.Clone(path) + if message.Frame.Kind != duplex.ProfileRequest { + registration.receiver.Message(delivered, message) + return + } + capture, err := CaptureInvocation(message, func(control duplex.Message) { + registration.receiver.Message(slices.Clone(delivered), control) + }) + if err != nil { + // A bound reached is a refusal to try again at; a capability that + // carries no lifecycle is a request this dispatcher cannot route with + // the guarantees it advertises. + refusal := &PublicError{Code: "invalid_message", Message: "Invocation requires the lifecycle its return capability carries"} + if errors.Is(err, ErrInvocationLimit) { + refusal = &PublicError{Code: "busy", Message: "Invocation participation limit reached"} + } + _ = sendWireResponse(message, nil, refusal) + return + } + defer capture.Ready() + registration.receiver.Message(delivered, message) +} + +func (d *Dispatcher) Close(code duplex.Code, reason string) error { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil + } + d.closed = true + detach, routes := d.detach, d.routes + d.detach, d.routes = nil, nil + d.mu.Unlock() + if detach != nil { + detach() + } + for _, registration := range routes { + if registration.receiver.Closed != nil { + func() { defer func() { _ = recover() }(); registration.receiver.Closed(code, reason) }() + } + } + if d.ownEndpoint { + return d.root.Close(code, reason) + } + return nil +} + +// Select returns a receiving view of this shared dispatcher. The view owns its +// prefix route and never acquires closure authority over the root endpoint. +func (d *Dispatcher) Select(path []string) *SelectedEndpoint { + return &SelectedEndpoint{owner: d, prefix: slices.Clone(path)} +} + +type SelectedEndpoint struct { + owner *Dispatcher + prefix []string + mu sync.Mutex + closed bool + attachment *selectedAttachment +} +type selectedAttachment struct { + receiver duplex.Receiver + detach func() +} + +func (s *SelectedEndpoint) Select(path []string) *SelectedEndpoint { + return s.owner.Select(append(slices.Clone(s.prefix), path...)) +} +func (s *SelectedEndpoint) Send(path []string, message duplex.Message) error { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return ErrClosed + } + return s.owner.Send(append(slices.Clone(s.prefix), path...), message) +} +func (s *SelectedEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + attachment := &selectedAttachment{receiver: receiver} + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil, ErrClosed + } + if s.attachment != nil { + s.mu.Unlock() + return nil, duplex.ErrReceiverExists + } + s.attachment = attachment + s.mu.Unlock() + detach, err := s.owner.RegisterPrefix(s.prefix, duplex.Receiver{ + Message: func(path []string, message duplex.Message) { + if receiver.Message != nil { + receiver.Message(slices.Clone(path[len(s.prefix):]), message) + } else if message.Frame.Kind == duplex.ProfileRequest { + _ = sendWireResponse(message, nil, &PublicError{Code: "method_not_found", Message: "Unknown method"}) + } + }, + Closed: func(code duplex.Code, reason string) { s.remove(attachment, true, code, reason) }, + }) + s.mu.Lock() + active := s.attachment == attachment + if err != nil && active { + s.attachment = nil + } + if err == nil && active { + attachment.detach = detach + } + s.mu.Unlock() + if err != nil { + return nil, err + } + if !active { + detach() + return nil, ErrClosed + } + return func() { s.remove(attachment, false, 0, "") }, nil +} +func (s *SelectedEndpoint) remove(attachment *selectedAttachment, tell bool, code duplex.Code, reason string) { + s.mu.Lock() + if s.attachment != attachment { + s.mu.Unlock() + return + } + s.attachment = nil + detach := attachment.detach + s.mu.Unlock() + if detach != nil { + detach() + } + if tell && attachment.receiver.Closed != nil { + attachment.receiver.Closed(code, reason) + } +} +func (s *SelectedEndpoint) Close(code duplex.Code, reason string) error { + s.mu.Lock() + s.closed = true + attachment := s.attachment + s.mu.Unlock() + if attachment != nil { + s.remove(attachment, true, code, reason) + } + return nil +} diff --git a/dispatch/go/dispatcher_ownership_test.go b/dispatch/go/dispatcher_ownership_test.go new file mode 100644 index 0000000..3823339 --- /dev/null +++ b/dispatch/go/dispatcher_ownership_test.go @@ -0,0 +1,195 @@ +package runtime_test + +import ( + "errors" + "slices" + "testing" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +// An endpoint has one owning attachment: a second is refused without replacing +// the first, detach is idempotent, and a later attachment is permitted. +func TestAnEndpointHasOneOwningAttachment(t *testing.T) { + left, right, err := ws.NewWirePair(ws.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) + first := make(chan []string, 4) + detach, err := right.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { first <- path }}) + if err != nil { + t.Fatal(err) + } + if _, err := right.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatalf("a second attachment was accepted: %v", err) + } + if err := ws.EmitWire(t.Context(), left, []string{"one"}, nil); err != nil { + t.Fatal(err) + } + if got := <-first; !slices.Equal(got, []string{"one"}) { + t.Fatalf("first attachment saw %v", got) + } + detach() + detach() + second := make(chan []string, 4) + if _, err := right.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { second <- path }}); err != nil { + t.Fatalf("a later attachment was refused: %v", err) + } + if err := ws.EmitWire(t.Context(), left, []string{"two"}, nil); err != nil { + t.Fatal(err) + } + if got := <-second; !slices.Equal(got, []string{"two"}) { + t.Fatalf("second attachment saw %v", got) + } + select { + case got := <-first: + t.Fatalf("the detached attachment still received %v", got) + default: + } +} + +// A dispatcher refuses a duplicate path and frees it when its registration +// detaches. Exact and prefix are separate spaces: one of each may hold a path. +func TestADispatcherRefusesADuplicatePath(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + detach, err := dispatch.Register([]string{"a", "b"}, duplex.Receiver{Message: func([]string, duplex.Message) {}}) + if err != nil { + t.Fatal(err) + } + if _, err := dispatch.Register([]string{"a", "b"}, duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatalf("a duplicate exact path was accepted: %v", err) + } + if _, err := dispatch.RegisterPrefix([]string{"a", "b"}, duplex.Receiver{}); err != nil { + t.Fatalf("a prefix at an exact path was refused: %v", err) + } + if _, err := dispatch.RegisterPrefix([]string{"a", "b"}, duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatalf("a duplicate prefix path was accepted: %v", err) + } + detach() + if _, err := dispatch.Register([]string{"a", "b"}, duplex.Receiver{}); err != nil { + t.Fatalf("a detached path was not freed: %v", err) + } +} + +// Overlapping prefixes: the longest match wins, and an exact registration wins +// over every prefix that would also have matched. +func TestOverlappingRoutesSelectTheLongestThenTheExact(t *testing.T) { + endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) + dispatch, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + reached := make(chan string, 8) + name := func(label string) duplex.Receiver { + return duplex.Receiver{Message: func([]string, duplex.Message) { reached <- label }} + } + for _, route := range []struct { + path []string + label string + }{{nil, "root"}, {[]string{"a"}, "a"}, {[]string{"a", "b"}, "a/b"}} { + if _, err := dispatch.RegisterPrefix(route.path, name(route.label)); err != nil { + t.Fatal(err) + } + } + if _, err := dispatch.Register([]string{"a", "b", "c"}, name("exact a/b/c")); err != nil { + t.Fatal(err) + } + for _, want := range []struct { + path []string + label string + }{ + {[]string{"z"}, "root"}, + {[]string{"a", "z"}, "a"}, + {[]string{"a", "b", "z"}, "a/b"}, + {[]string{"a", "b", "c"}, "exact a/b/c"}, + } { + endpoint.deliver(want.path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")}}) + if got := <-reached; got != want.label { + t.Fatalf("%v reached %q, want %q", want.path, got, want.label) + } + } +} + +// A nested selection prepends its prefixes outgoing and strips them incoming, +// and every view is a view of the one root attachment. +func TestNestedSelectionPrependsAndStrips(t *testing.T) { + left, right, err := ws.NewWirePair(ws.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) + dispatch, err := ws.NewDispatcher(right) + if err != nil { + t.Fatal(err) + } + inner := dispatch.Select([]string{"a"}).Select([]string{"b"}) + delivered := make(chan []string, 4) + if _, err := inner.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { delivered <- path }}); err != nil { + t.Fatal(err) + } + if err := ws.EmitWire(t.Context(), left, []string{"a", "b", "read"}, nil); err != nil { + t.Fatal(err) + } + if got := <-delivered; !slices.Equal(got, []string{"read"}) { + t.Fatalf("the nested view was delivered %v", got) + } + // And outgoing: what the view sends arrives at the root's full path. + back := make(chan []string, 4) + if _, err := left.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { back <- path }}); err != nil { + t.Fatal(err) + } + if err := ws.EmitWire(t.Context(), inner, []string{"reply"}, nil); err != nil { + t.Fatal(err) + } + if got := <-back; !slices.Equal(got, []string{"a", "b", "reply"}) { + t.Fatalf("the nested view sent to %v", got) + } +} + +// Mounting chooses a child by one segment and restores it on delivery; +// closing the mount detaches its own attachments and leaves children usable. +func TestMountRoutesByOneSegmentAndBorrowsItsChildren(t *testing.T) { + left, right, err := ws.NewWirePair(ws.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) + mount := duplex.Mount(map[string]duplex.Endpoint{"child": right}) + delivered := make(chan []string, 4) + if _, err := mount.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { delivered <- path }}); err != nil { + t.Fatal(err) + } + if err := ws.EmitWire(t.Context(), left, []string{"read"}, nil); err != nil { + t.Fatal(err) + } + if got := <-delivered; !slices.Equal(got, []string{"child", "read"}) { + t.Fatalf("the mount delivered %v", got) + } + // A mount has no destination at the empty path, and an unknown child has + // no route at all. + if err := mount.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")}}); !errors.Is(err, duplex.ErrNoRoute) { + t.Fatalf("the mount had a destination at []: %v", err) + } + if err := mount.Send([]string{"absent"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")}}); !errors.Is(err, duplex.ErrNoRoute) { + t.Fatalf("an unknown child had a route: %v", err) + } + if err := mount.Close(duplex.CodeNormal, ""); err != nil { + t.Fatal(err) + } + after := make(chan []string, 4) + if _, err := right.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { after <- path }}); err != nil { + t.Fatalf("closing the mount closed its borrowed child: %v", err) + } + if err := ws.EmitWire(t.Context(), left, []string{"again"}, nil); err != nil { + t.Fatal(err) + } + if got := <-after; !slices.Equal(got, []string{"again"}) { + t.Fatalf("the borrowed child delivered %v", got) + } +} diff --git a/dispatch/go/dispatcher_test.go b/dispatch/go/dispatcher_test.go new file mode 100644 index 0000000..8f8a6a5 --- /dev/null +++ b/dispatch/go/dispatcher_test.go @@ -0,0 +1,85 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +func TestDispatcherSharesOneAttachmentAndPreservesBorrowedEndpoint(t *testing.T) { + left, right, err := ws.NewWirePair(ws.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) + dispatch, err := ws.NewDispatcher(right) + if err != nil { + t.Fatal(err) + } + if _, err := right.Receive(duplex.Receiver{}); err == nil { + t.Fatal("second owning attachment accepted") + } + for _, name := range []string{"a", "b"} { + view := dispatch.Select([]string{name}) + binding, err := ws.NewDispatcher(view) + if err != nil { + t.Fatal(err) + } + if _, err := ws.HandleWire(binding, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return name, nil }); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"a", "b"} { + var got string + if err := ws.CallWire(context.Background(), left, []string{name, "read"}, nil, &got); err != nil || got != name { + t.Fatalf("%s: %q, %v", name, got, err) + } + } + if err := dispatch.Close(duplex.CodeNormal, ""); err != nil { + t.Fatal(err) + } + rebound, err := ws.NewDispatcher(right) + if err != nil { + t.Fatalf("borrowed endpoint was closed: %v", err) + } + if _, err := ws.HandleWire(rebound, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return "rebound", nil }); err != nil { + t.Fatal(err) + } + var got string + if err := ws.CallWire(context.Background(), left, []string{"read"}, nil, &got); err != nil || got != "rebound" { + t.Fatalf("rebound: %q, %v", got, err) + } +} + +func TestDispatcherClosesAnExplicitlyOwnedEndpoint(t *testing.T) { + left, right, err := ws.NewWirePair(ws.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) + dispatch, err := ws.NewDispatcher(right, ws.DispatcherOptions{OwnEndpoint: true}) + if err != nil { + t.Fatal(err) + } + if err := dispatch.Close(duplex.CodeProtocolError, "wire event rejected"); err != nil { + t.Fatal(err) + } + if _, err := right.Receive(duplex.Receiver{}); err == nil { + t.Fatal("owned endpoint stayed open") + } + if err := dispatch.Close(duplex.CodeNormal, "again"); err != nil { + t.Fatal(err) + } +} + +type unmanagedDispatchEndpoint struct{ receiver duplex.Receiver } + +func (*unmanagedDispatchEndpoint) Send([]string, duplex.Message) error { return nil } +func (w *unmanagedDispatchEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + w.receiver = receiver + return func() {}, nil +} +func (*unmanagedDispatchEndpoint) Close(duplex.Code, string) error { return nil } diff --git a/dispatch/go/wire_cancel_reservation_test.go b/dispatch/go/wire_cancel_reservation_test.go new file mode 100644 index 0000000..2fcbe64 --- /dev/null +++ b/dispatch/go/wire_cancel_reservation_test.go @@ -0,0 +1,211 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +type wireDrainGate struct { + started chan struct{} + release chan struct{} + once sync.Once +} + +func (g *wireDrainGate) open() { g.once.Do(func() { close(g.release) }) } + +type wireReservationPropagator struct { + Propagator + gates chan *wireDrainGate +} + +func (p *wireReservationPropagator) Extract(ctx context.Context, trace Trace) context.Context { + select { + case gate := <-p.gates: + close(gate.started) + select { + case <-gate.release: + case <-ctx.Done(): + } + default: + } + return p.Propagator.Extract(ctx, trace) +} +func (p *wireReservationPropagator) pause(t *testing.T) *wireDrainGate { + gate := &wireDrainGate{started: make(chan struct{}), release: make(chan struct{})} + p.gates <- gate + t.Cleanup(gate.open) + return gate +} + +type wireReservationSink struct { + replies chan duplex.ProfileFrame + onReply func(duplex.ProfileFrame) +} + +func (s *wireReservationSink) Send(_ []string, message duplex.Message) error { + if s.onReply != nil { + s.onReply(message.Frame) + } + s.replies <- message.Frame + return nil +} + +func wireReservationAwait[T any](t *testing.T, values <-chan T) T { + t.Helper() + select { + case value := <-values: + return value + case <-time.After(3 * time.Second): + t.Fatal("wire reservation barrier did not arrive") + var zero T + return zero + } +} + +// Only the root dispatcher runs. Its actual peer admission writes into an +// independently drained carrier queue, so a full root queue is tested without +// a second, unrelated transport saturation masking the root's result. +func wireReservationPeer(t *testing.T) (*Peer, duplex.Wire, *wireReservationPropagator) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + near, far := duplex.Pipe(1 << 20) + propagator := &wireReservationPropagator{Propagator: DefaultPropagator, gates: make(chan *wireDrainGate, 1)} + options, err := (Options{QueueCapacity: 1, MaxPendingRequests: 1, Propagator: propagator}).normalized() + if err != nil { + t.Fatal(err) + } + peer := &Peer{ctx: ctx, cancel: cancel, conn: near, options: options, prefix: "c:", done: make(chan struct{}), pending: map[string]chan pendingResult{}, incoming: map[string]context.CancelFunc{}, handlers: map[string]Handler{}, eventHandlers: map[string]EventHandler{}, outputs: make(chan queuedFrame, 16)} + t.Cleanup(func() { _ = peer.Close(); _ = far.Abort() }) + return peer, peer.Wire(), propagator +} + +func wireReservationMessage(kind duplex.ProfileKind, id string, address *duplex.ReturnAddress) duplex.Message { + frame := duplex.ProfileFrame{Version: 1, Kind: kind, ID: id} + if kind == duplex.ProfileRequest { + frame.Params = json.RawMessage(`{}`) + } else if kind == duplex.ProfileEvent { + frame.Data = json.RawMessage(`null`) + } + return duplex.Message{Frame: frame, Return: address} +} +func wireReservationSend(t *testing.T, wire duplex.Wire, kind duplex.ProfileKind, id string, address *duplex.ReturnAddress) { + t.Helper() + if err := wire.Send([]string{"operation"}, wireReservationMessage(kind, id, address)); err != nil { + t.Fatal(err) + } +} + +func TestRootWireCancellationHasReservedAdmissionAndKeepsFIFO(t *testing.T) { + peer, wire, propagator := wireReservationPeer(t) + sink := &wireReservationSink{replies: make(chan duplex.ProfileFrame, 8)} + address := &duplex.ReturnAddress{Wire: sink} + gate := propagator.pause(t) + wireReservationSend(t, wire, duplex.ProfileRequest, "c:1", address) + wireReservationAwait(t, gate.started) + wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) // The sole data slot is occupied. + wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) + for range 4 { + wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) + wireReservationSend(t, wire, duplex.ProfileCancel, "c:999", address) + } + if err := peer.Err(); err != nil { + t.Fatalf("cancellation ended its carrier: %v", err) + } + gate.open() + var kinds []string + for range 3 { + kinds = append(kinds, wireReservationAwait(t, peer.outputs).frame.Kind) + } + if !reflect.DeepEqual(kinds, []string{"request", "event", "cancel"}) { + t.Fatalf("physical admission order = %v", kinds) + } + if reply := wireReservationAwait(t, sink.replies); reply.Error == nil || reply.Error.Code != "cancelled" { + t.Fatalf("cancel reply = %+v", reply) + } + // A fence after duplicate, unknown and settled controls proves none escaped. + wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) + wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) + if got := wireReservationAwait(t, peer.outputs).frame.Kind; got != "event" { + t.Fatalf("stale control reached the carrier: %s", got) + } + if err := peer.Err(); err != nil { + t.Fatalf("carrier ended: %v", err) + } +} + +func TestRootWireCompletedCallRetainsItsQueuedCancellationBudget(t *testing.T) { + peer, wire, propagator := wireReservationPeer(t) + first := &wireReservationSink{replies: make(chan duplex.ProfileFrame, 4)} + address := &duplex.ReturnAddress{Wire: first} + second := &wireReservationSink{replies: make(chan duplex.ProfileFrame, 4)} + secondAddress := &duplex.ReturnAddress{Wire: second} + reentrant := make(chan error, 1) + first.onReply = func(duplex.ProfileFrame) { + reentrant <- wire.Send([]string{"operation"}, wireReservationMessage(duplex.ProfileRequest, "c:2", secondAddress)) + } + wireReservationSend(t, wire, duplex.ProfileRequest, "c:1", address) + request := wireReservationAwait(t, peer.outputs).frame + gate := propagator.pause(t) + wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) + wireReservationAwait(t, gate.started) + wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) + // Complete before the root can drain the control. The response callback + // attempts to spend the same one-request budget while its stale control is + // still queued; it must receive busy, not replenish control capacity. + peer.mu.Lock() + reply := peer.pending[request.ID] + peer.mu.Unlock() + reply <- pendingResult{result: json.RawMessage(`7`)} + if err := wireReservationAwait(t, reentrant); err != nil { + t.Fatalf("refusal admission closed carrier: %v", err) + } + if response := wireReservationAwait(t, first.replies); string(response.Result) != "7" { + t.Fatalf("first response = %+v", response) + } + gate.open() + if got := wireReservationAwait(t, peer.outputs).frame.Kind; got != "event" { + t.Fatalf("queued event = %s", got) + } + if response := wireReservationAwait(t, second.replies); response.Error == nil || response.Error.Code != "busy" { + t.Fatalf("retained reservation allowed another request: %+v", response) + } + // Reuse the original local identity after the control drains. The stale + // cancellation must neither cancel it nor delete its new state. + first.onReply = nil + wireReservationSend(t, wire, duplex.ProfileRequest, "c:1", address) + third := wireReservationAwait(t, peer.outputs).frame + if third.Kind != "request" { + t.Fatalf("stale control was emitted: %+v", third) + } + peer.mu.Lock() + reply = peer.pending[third.ID] + peer.mu.Unlock() + reply <- pendingResult{result: json.RawMessage(`9`)} + if response := wireReservationAwait(t, first.replies); string(response.Result) != "9" { + t.Fatalf("reused identity response = %+v", response) + } + if err := peer.Err(); err != nil { + t.Fatalf("carrier ended: %v", err) + } +} + +func TestRootWireCancellationReservationDoesNotIncreaseDataCapacity(t *testing.T) { + peer, wire, propagator := wireReservationPeer(t) + gate := propagator.pause(t) + wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) + wireReservationAwait(t, gate.started) + wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) + if err := wire.Send([]string{"operation"}, wireReservationMessage(duplex.ProfileEvent, "", nil)); !errors.Is(err, ErrBackpressure) { + t.Fatalf("extra data admission = %v", err) + } + if !errors.Is(peer.Err(), ErrBackpressure) { + t.Fatalf("full data carrier remained open: %v", peer.Err()) + } +} diff --git a/dispatch/go/wire_event_context_test.go b/dispatch/go/wire_event_context_test.go new file mode 100644 index 0000000..98c8a69 --- /dev/null +++ b/dispatch/go/wire_event_context_test.go @@ -0,0 +1,161 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +type eventVerifiedKey struct{} +type eventVerifiedPropagator struct{ verified any } + +func (p eventVerifiedPropagator) Extract(ctx context.Context, trace ws.Trace) context.Context { + return context.WithValue(ws.DefaultPropagator.Extract(ctx, trace), eventVerifiedKey{}, p.verified) +} +func (eventVerifiedPropagator) Inject(ctx context.Context) ws.Trace { + return ws.DefaultPropagator.Inject(ctx) +} + +func TestWireEventContextSurvivesPhysicalForwardLocalPairAndMount(t *testing.T) { + verified := &struct{ source string }{"trusted context"} + client, server := newPair(t, ws.Options{Propagator: eventVerifiedPropagator{verified}}, ws.Options{}) + access, binding, err := ws.NewWirePair(ws.Options{MaxPendingRequests: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = access.Close(duplex.CodeNormal, "done") }) + stop, err := ws.ForwardWire(server.Wire(), testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"local": access})).Select([]string{"local"})) + if err != nil { + t.Fatal(err) + } + defer stop() + mountBinding := testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"model": binding})) + model := mountBinding.Select([]string{"model", "events"}) + observed := make(chan context.Context, 1) + effects := 0 + modelBinding := testBinding(t, model) + _, err = ws.RegisterWire(modelBinding, []string{"change"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { + observed <- ctx + if ctx.Value(eventVerifiedKey{}) != verified { + return errors.New("unverified event") + } + effects++ + return nil + }}) + if err != nil { + t.Fatal(err) + } + meta := map[string]string{"tenant": "explicit", "verified": "cannot manufacture context"} + if err := ws.EmitWire(ws.WithMeta(context.Background(), meta), client.Wire(), []string{"events", "change"}, nil); err != nil { + t.Fatal(err) + } + ctx := receive(t, observed) + if ctx.Value(eventVerifiedKey{}) != verified || !reflect.DeepEqual(ws.MetaFrom(ctx), meta) { + t.Fatalf("lost received context: verified=%v meta=%v", ctx.Value(eventVerifiedKey{}), ws.MetaFrom(ctx)) + } + _, _ = ws.HandleWire(mountBinding, []string{"model", "barrier"}, func(context.Context, json.RawMessage) (any, error) { return effects, nil }) + var count int + if err := ws.CallWire(context.Background(), access, []string{"barrier"}, nil, &count); err != nil || count != 1 { + t.Fatalf("effect count=%d, err=%v", count, err) + } + + // New outgoing events carry only explicitly supplied metadata. The private + // received context ends at the next physical boundary. + returned := make(chan context.Context, 2) + clientBinding := testBinding(t, client.Wire()) + _, _ = ws.RegisterWire(clientBinding, []string{"outgoing"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { returned <- ctx; return nil }}) + if err := ws.EmitWire(ctx, server.Wire(), []string{"outgoing"}, nil); err != nil { + t.Fatal(err) + } + fresh := receive(t, returned) + if fresh.Value(eventVerifiedKey{}) != nil || len(ws.MetaFrom(fresh)) != 0 { + t.Fatalf("ambient context crossed transport: %v %v", fresh.Value(eventVerifiedKey{}), ws.MetaFrom(fresh)) + } + if err := ws.EmitWire(ws.WithMeta(ctx, ws.MetaFrom(ctx)), server.Wire(), []string{"outgoing"}, nil); err != nil { + t.Fatal(err) + } + if got := ws.MetaFrom(receive(t, returned)); !reflect.DeepEqual(got, meta) { + t.Fatalf("explicit outgoing metadata=%v", got) + } + _ = server.Close() + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("event context lost its physical connection lifetime") + } +} + +func TestWireEventMetadataCannotSupplyVerifiedContext(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + access, binding, err := ws.NewWirePair(ws.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = access.Close(duplex.CodeNormal, "done") }) + stop, err := ws.ForwardWire(server.Wire(), access) + if err != nil { + t.Fatal(err) + } + defer stop() + denied := make(chan bool, 1) + modelBinding := testBinding(t, binding) + _, _ = ws.RegisterWire(modelBinding, []string{"guard"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { + if ctx.Value(eventVerifiedKey{}) == nil { + denied <- true + return errors.New("event denied") + } + denied <- false + return nil + }}) + if err := ws.EmitWire(ws.WithMeta(context.Background(), map[string]string{"verified": "yes"}), client.Wire(), []string{"guard"}, nil); err != nil { + t.Fatal(err) + } + if !receive(t, denied) { + t.Fatal("metadata bypassed the event guard") + } +} + +func TestWireEventContextStopsAtAnotherPhysicalBoundary(t *testing.T) { + verified := &struct{}{} + client, incoming := newPair(t, ws.Options{Propagator: eventVerifiedPropagator{verified}}, ws.Options{}) + outgoing, server := newPair(t, ws.Options{}, ws.Options{}) + stop, err := ws.ForwardWire(incoming.Wire(), outgoing.Wire()) + if err != nil { + t.Fatal(err) + } + defer stop() + observed := make(chan context.Context, 1) + serverBinding := testBinding(t, server.Wire()) + _, _ = ws.RegisterWire(serverBinding, []string{"event"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { observed <- ctx; return nil }}) + if err := ws.EmitWire(ws.WithMeta(context.Background(), map[string]string{"explicit": "yes"}), client.Wire(), []string{"event"}, nil); err != nil { + t.Fatal(err) + } + ctx := receive(t, observed) + if ctx.Value(eventVerifiedKey{}) != nil || ws.MetaFrom(ctx)["explicit"] != "yes" { + t.Fatalf("physical boundary: private=%v meta=%v", ctx.Value(eventVerifiedKey{}), ws.MetaFrom(ctx)) + } +} + +func TestWireLocalEventsUseSuppliedPropagator(t *testing.T) { + verified := &struct{}{} + a, b, err := ws.NewWirePair(ws.Options{Propagator: eventVerifiedPropagator{verified}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = a.Close(duplex.CodeNormal, "done") }) + observed := make(chan context.Context, 1) + bBinding := testBinding(t, b) + _, _ = ws.RegisterWire(bBinding, []string{"event"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { observed <- ctx; return nil }}) + if err := ws.EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + if receive(t, observed).Value(eventVerifiedKey{}) != verified { + t.Fatal("local event bypassed configured propagator") + } +} diff --git a/dispatch/go/wire_namespace_test.go b/dispatch/go/wire_namespace_test.go new file mode 100644 index 0000000..b5313e8 --- /dev/null +++ b/dispatch/go/wire_namespace_test.go @@ -0,0 +1,406 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +type namespaceObservation struct { + Picked string + Path []string +} + +func namespaceReceiver(picked string, events chan namespaceObservation) duplex.Receiver { + return duplex.Receiver{Message: func(path []string, message duplex.Message) { + observation := namespaceObservation{picked, append([]string{}, path...)} + if message.Frame.Kind == duplex.ProfileEvent { + events <- observation + return + } + if message.Frame.Kind != duplex.ProfileRequest { + return + } + data, _ := json.Marshal(observation) + _ = message.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: message.Frame.ID, Result: data}}) + }} +} + +func TestDispatcherUsesExactThenLongestSegmentPrefix(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + events := make(chan namespaceObservation, 20) + wire := testBinding(t, server.Wire()) + for _, route := range []struct { + path []string + name string + }{{nil, "root"}, {[]string{"a"}, "a"}, {[]string{"a", "b"}, "ab"}} { + if _, err := wire.RegisterPrefix(route.path, namespaceReceiver(route.name, events)); err != nil { + t.Fatal(err) + } + if _, err := wire.RegisterPrefix(route.path, namespaceReceiver("duplicate", events)); !errors.Is(err, duplex.ErrReceiverExists) { + t.Fatalf("duplicate namespace = %v", err) + } + } + detach, err := wire.Register([]string{"a"}, namespaceReceiver("exact", events)) + if err != nil { + t.Fatal(err) + } + for _, route := range []struct { + path []string + picked string + }{ + {[]string{"a"}, "exact"}, {[]string{"a", "b", "leaf"}, "ab"}, {[]string{"a", "bc"}, "a"}, {[]string{"a.b", "leaf"}, "root"}, {[]string{"", "๐Ÿ˜€"}, "root"}, + } { + var got namespaceObservation + if err := ws.CallWire(context.Background(), client.Wire(), route.path, nil, &got); err != nil { + t.Fatal(err) + } + want := namespaceObservation{route.picked, route.path} + if !reflect.DeepEqual(got, want) { + t.Fatalf("request = %+v; want %+v", got, want) + } + if err := ws.EmitWire(context.Background(), client.Wire(), route.path, nil); err != nil { + t.Fatal(err) + } + if got := receive(t, events); !reflect.DeepEqual(got, want) { + t.Fatalf("event = %+v; want %+v", got, want) + } + } + detach() + detach() + var got namespaceObservation + if err := ws.CallWire(context.Background(), client.Wire(), []string{"a"}, nil, &got); err != nil || got.Picked != "a" { + t.Fatalf("exact detach did not expose namespace: %+v %v", got, err) + } + if err := server.Handle("ordinary", func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return "raw", nil }); err != nil { + t.Fatal(err) + } + var raw string + if err := client.Call(context.Background(), "ordinary", nil, &raw); err != nil || raw != "raw" { + t.Fatalf("raw handler = %q %v", raw, err) + } + for _, name := range []string{"unknown.raw", "01:a", "1:a.invalid"} { + err := client.Call(context.Background(), name, nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "method_not_found" { + t.Fatalf("namespace captured noncanonical name %q: %v", name, err) + } + } +} + +func TestDispatcherCancellationKeepsOriginalRegistration(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + serverBinding := testBinding(t, server.Wire()) + started := make(chan *duplex.ReturnAddress, 1) + cancelled := make(chan *duplex.ReturnAddress, 1) + detach, err := serverBinding.RegisterPrefix([]string{"worker"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + if m.Frame.Kind == duplex.ProfileRequest { + started <- m.Return + } + if m.Frame.Kind == duplex.ProfileCancel { + cancelled <- m.Return + } + }}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { result <- ws.CallWire(ctx, client.Wire(), []string{"worker", "dynamic"}, nil, nil) }() + original := receive(t, started) + detach() + replacement := make(chan duplex.ProfileKind, 4) + if _, err := serverBinding.RegisterPrefix([]string{"worker"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { replacement <- m.Frame.Kind }}); err != nil { + t.Fatal(err) + } + cancel() + if err := receive(t, result); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if got := receive(t, cancelled); got != original { + t.Fatal("cancellation changed the original return capability") + } + select { + case got := <-replacement: + t.Fatalf("replacement received old request's %s", got) + default: + } +} + +func TestStructuredWireBridgePreservesTraceVerbatim(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + serverBinding := testBinding(t, server.Wire()) + frames := make(chan duplex.ProfileFrame, 8) + _, err := serverBinding.Register([]string{"trace"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { + frames <- m.Frame + if m.Frame.Kind == duplex.ProfileRequest { + _ = m.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: m.Frame.ID, Result: json.RawMessage(`null`)}}) + } + }}) + if err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 2)} + address := &duplex.ReturnAddress{Wire: sink} + for _, trace := range []ws.Trace{{Parent: "00-11111111111111111111111111111111-2222222222222222-01", State: "vendor=value"}, {}} { + for _, kind := range []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileEvent} { + frame := duplex.ProfileFrame{Version: 1, Kind: kind, Traceparent: trace.Parent, Tracestate: trace.State} + if kind == duplex.ProfileRequest { + frame.ID = "c:1" + frame.Params = json.RawMessage(`null`) + } else { + frame.Data = json.RawMessage(`null`) + } + if err := client.Wire().Send([]string{"trace"}, duplex.Message{Frame: frame, Return: address}); err != nil { + t.Fatal(err) + } + got := receive(t, frames) + if got.Traceparent != trace.Parent || got.Tracestate != trace.State { + t.Fatalf("structured %s trace changed: %+v; want %+v", kind, got, trace) + } + if kind == duplex.ProfileRequest { + reply := receive(t, sink.replies) + if reply.Traceparent != trace.Parent || reply.Tracestate != trace.State { + t.Fatalf("response trace changed: %+v", reply) + } + } + } + } +} + +func TestRegisterWireGroupsRequestAndEventAtOnePath(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + serverBinding := testBinding(t, server.Wire()) + events := make(chan string, 2) + detach, err := ws.RegisterWire(serverBinding, []string{"shared"}, ws.WireHandlers{ + Request: func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }, + Event: func(_ context.Context, value json.RawMessage) error { + var text string + _ = json.Unmarshal(value, &text) + events <- text + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + var got string + if err := ws.CallWire(context.Background(), client.Wire(), []string{"shared"}, "response", &got); err != nil || got != "response" { + t.Fatalf("grouped request = %q %v", got, err) + } + if err := ws.EmitWire(context.Background(), client.Wire(), []string{"shared"}, "event"); err != nil { + t.Fatal(err) + } + if got := receive(t, events); got != "event" { + t.Fatal(got) + } + detach() + detach() + _, err = ws.RegisterWire(serverBinding, []string{"shared"}, ws.WireHandlers{Event: func(context.Context, json.RawMessage) error { return nil }}) + if err != nil { + t.Fatal(err) + } + err = ws.CallWire(context.Background(), client.Wire(), []string{"shared"}, nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "method_not_found" { + t.Fatalf("event-only request did not refuse: %v", err) + } + if _, err := ws.RegisterWire(serverBinding, []string{"empty"}, ws.WireHandlers{}); err == nil { + t.Fatal("registered empty handlers") + } +} + +// This fixture created and owns the carrier, so its registry explicitly owns +// fatal-handler closure. Ordinary borrowed dispatchers only detach themselves. +type ownedEventRegistry struct { + *ws.Dispatcher + endpoint duplex.Endpoint +} + +func (r ownedEventRegistry) Close(code duplex.Code, reason string) error { + _ = r.Dispatcher.Close(code, reason) + return r.endpoint.Close(code, reason) +} + +func TestRegisterWireEventFailuresEndOnlyTheirCarrierWithSanitizedReason(t *testing.T) { + for _, panics := range []bool{false, true} { + t.Run(map[bool]string{false: "error", true: "panic"}[panics], func(t *testing.T) { + log := &recorder{} + client, server := newPair(t, ws.Options{Observer: log}, ws.Options{}) + serverBinding := ownedEventRegistry{testBinding(t, server.Wire()), server.Wire()} + _, err := ws.RegisterWire(serverBinding, []string{"rejected"}, ws.WireHandlers{Event: func(context.Context, json.RawMessage) error { + if panics { + panic("private failure") + } + return errors.New("private failure") + }}) + if err != nil { + t.Fatal(err) + } + if err := ws.EmitWire(context.Background(), client.Wire(), []string{"rejected"}, nil); err != nil { + t.Fatal(err) + } + receive(t, server.Done()) + wireChannelAwait(t, log, 1, func(event ws.ObserverEvent) bool { _, ok := event.(ws.ConnectionClosed); return ok }) + for _, event := range log.all() { + if closed, ok := event.(ws.ConnectionClosed); ok { + if closed.Code != 1002 || closed.Reason != "wire event rejected" { + t.Fatalf("event ending = %+v", closed) + } + return + } + } + t.Fatal("no carrier ending was observed") + }) + } +} + +type forwardRegistrationWire struct { + receiver duplex.Receiver + sent []duplex.Message + paths [][]string + detached int + closed int + receiveErr error + sendErr error +} + +func (w *forwardRegistrationWire) Send(path []string, message duplex.Message) error { + w.paths = append(w.paths, path) + w.sent = append(w.sent, message) + return w.sendErr +} +func (w *forwardRegistrationWire) Receive(receiver duplex.Receiver) (func(), error) { + if w.receiveErr != nil { + return nil, w.receiveErr + } + w.receiver = receiver + var once sync.Once + return func() { once.Do(func() { w.detached++ }) }, nil +} +func (w *forwardRegistrationWire) Close(duplex.Code, string) error { w.closed++; return nil } + +func TestForwardWirePreservesMessagesAndOwnsOnlyRegistrations(t *testing.T) { + left, right := &forwardRegistrationWire{}, &forwardRegistrationWire{} + detach, err := ws.ForwardWire(left, right) + if err != nil { + t.Fatal(err) + } + returning := &duplex.ReturnAddress{Wire: left} + path := []string{"unknown", "a.b", "", "๐Ÿ˜€"} + for _, kind := range []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileResponse, duplex.ProfileEvent, duplex.ProfileCancel} { + message := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: kind, ID: "c:1", Params: json.RawMessage(`{"n":1e3}`)}, Return: returning} + left.receiver.Message(path, message) + right.receiver.Message(path, message) + if !reflect.DeepEqual(left.sent[len(left.sent)-1], message) || !reflect.DeepEqual(right.sent[len(right.sent)-1], message) { + t.Fatalf("%s changed", kind) + } + if left.sent[len(left.sent)-1].Return != returning || right.sent[len(right.sent)-1].Return != returning { + t.Fatal("return capability changed") + } + } + if !reflect.DeepEqual(left.paths[0], path) || !reflect.DeepEqual(right.paths[0], path) { + t.Fatal("forward path changed") + } + left.receiver.Closed(1000, "ended") + detach() + detach() + if left.detached != 1 || right.detached != 1 || left.closed != 0 || right.closed != 0 { + t.Fatalf("lifecycle: left=%+v right=%+v", left, right) + } + left, right = &forwardRegistrationWire{}, &forwardRegistrationWire{receiveErr: errors.New("installation refused")} + if _, err := ws.ForwardWire(left, right); err == nil || left.detached != 1 || left.closed != 0 { + t.Fatalf("partial install leaked: %+v %v", left, err) + } + left, right = &forwardRegistrationWire{}, &forwardRegistrationWire{sendErr: ws.Unpublished(&ws.PublicError{Code: "busy", Message: "Busy"})} + if _, err := ws.ForwardWire(left, right); err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 1)} + left.receiver.Message(path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1"}, Return: &duplex.ReturnAddress{Wire: sink}}) + response := receive(t, sink.replies) + if response.Error == nil || response.Error.Code != "busy" || left.detached != 1 || right.detached != 1 || right.closed != 0 { + t.Fatalf("failed forwarding did not refuse and detach: %+v %+v %+v", response, left, right) + } +} + +func TestForwardWireCarriesUnknownPathsAndReverseCallsAcrossPeers(t *testing.T) { + client, middleIn := newPair(t, ws.Options{}, ws.Options{}) + middleOut, server := newPair(t, ws.Options{}, ws.Options{}) + inbound := testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"in": testBinding(t, middleIn.Wire()).Select([]string{"gateway"})})).Select([]string{"in"}) + outbound := testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"out": testBinding(t, middleOut.Wire()).Select([]string{"service"})})).Select([]string{"out"}) + caller := testBinding(t, testBinding(t, client.Wire()).Select([]string{"gateway"})) + implementation := testBinding(t, testBinding(t, server.Wire()).Select([]string{"service"})) + detach, err := ws.ForwardWire(inbound, outbound) + if err != nil { + t.Fatal(err) + } + defer detach() + _, err = ws.HandleWire(caller, []string{"reverse", "dynamic"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) + if err != nil { + t.Fatal(err) + } + _, err = ws.HandleWire(implementation, []string{"arbitrary", "nested", "call"}, func(ctx context.Context, value json.RawMessage) (any, error) { + var result string + if err := ws.CallWire(ctx, implementation, []string{"reverse", "dynamic"}, value, &result); err != nil { + return nil, err + } + return result + " returned", nil + }) + if err != nil { + t.Fatal(err) + } + var result string + if err := ws.CallWire(context.Background(), caller, []string{"arbitrary", "nested", "call"}, "callback", &result); err != nil || result != "callback returned" { + t.Fatalf("forwarded reverse call = %q %v", result, err) + } + events := make(chan string, 2) + _, err = ws.RegisterWire(implementation, []string{"arbitrary", "nested", "event"}, ws.WireHandlers{Event: func(_ context.Context, value json.RawMessage) error { + var text string + _ = json.Unmarshal(value, &text) + events <- text + return nil + }}) + if err != nil { + t.Fatal(err) + } + if err := ws.EmitWire(context.Background(), caller, []string{"arbitrary", "nested", "event"}, "observed"); err != nil { + t.Fatal(err) + } + if got := receive(t, events); got != "observed" { + t.Fatal(got) + } + started, ended := make(chan struct{}), make(chan struct{}) + _, err = ws.HandleWire(implementation, []string{"arbitrary", "cancel"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(ended) + return nil, ctx.Err() + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- ws.CallWire(ctx, caller, []string{"arbitrary", "cancel"}, nil, nil) }() + receive(t, started) + detach() + cancel() + if err := receive(t, finished); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + receive(t, ended) + for _, peer := range []*ws.Peer{client, middleIn, middleOut, server} { + if err := peer.Err(); err != nil { + t.Fatalf("forward detach closed peer: %v", err) + } + } +} diff --git a/dispatch/go/wire_options_test.go b/dispatch/go/wire_options_test.go new file mode 100644 index 0000000..64dc3cd --- /dev/null +++ b/dispatch/go/wire_options_test.go @@ -0,0 +1,99 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" + "testing" + "time" +) + +func TestWireForwardingRetainsTheAdmittedDeadline(t *testing.T) { + client, server := newPair(t, ws.Options{RequestTimeout: time.Minute}, ws.Options{RequestTimeout: time.Minute}) + serverBinding := testBinding(t, server.Wire()) + _, err := ws.HandleWire(serverBinding, []string{"deadline"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + deadline, ok := ctx.Deadline() + if !ok { + return int64(0), nil + } + return int64(time.Until(deadline) / time.Millisecond), nil + }) + if err != nil { + t.Fatal(err) + } + var remaining int64 + if err := client.Call(context.Background(), "8:deadline", nil, &remaining); err != nil { + t.Fatal(err) + } + if remaining < 50000 { + t.Fatalf("forwarding shortened the admitted one-minute deadline to %dms", remaining) + } +} + +type configuredWirePropagator struct { + remaining time.Duration + trace ws.Trace +} + +func (p *configuredWirePropagator) Extract(ctx context.Context, _ ws.Trace) context.Context { + return ctx +} +func (p *configuredWirePropagator) Inject(ctx context.Context) ws.Trace { + if deadline, ok := ctx.Deadline(); ok { + p.remaining = time.Until(deadline) + } + return p.trace +} + +type optionWire struct { + send func([]string, duplex.Message) error +} + +func (w optionWire) Send(path []string, m duplex.Message) error { return w.send(path, m) } + +func TestWireUsesTheConfiguredOutgoingPropagatorAndTimeout(t *testing.T) { + want := ws.Trace{Parent: "00-11111111111111111111111111111111-2222222222222222-01", State: "vendor=kept"} + propagator := &configuredWirePropagator{trace: want} + received := make(chan duplex.ProfileFrame, 2) + wire := optionWire{send: func(_ []string, m duplex.Message) error { + received <- m.Frame + if m.Frame.Kind == duplex.ProfileRequest { + return m.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: m.Frame.ID, Result: json.RawMessage(`null`)}}) + } + return nil + }} + if err := ws.CallWire(context.Background(), wire, []string{"call"}, nil, nil, ws.WireCallOptions{Propagator: propagator, RequestTimeout: time.Minute}); err != nil { + t.Fatal(err) + } + if propagator.remaining < 50*time.Second { + t.Fatalf("configured minute shortened to %v", propagator.remaining) + } + if err := ws.EmitWire(context.Background(), wire, []string{"event"}, nil, ws.WireEmitOptions{Propagator: propagator}); err != nil { + t.Fatal(err) + } + for range 2 { + frame := <-received + if frame.Traceparent != want.Parent || frame.Tracestate != want.State { + t.Fatalf("configured trace lost: %+v", frame) + } + } +} + +func TestWireConfiguredTimeoutCancelsTheSameReturnCapability(t *testing.T) { + messages := make(chan duplex.Message, 2) + wire := optionWire{send: func(_ []string, m duplex.Message) error { messages <- m; return nil }} + started := time.Now() + err := ws.CallWire(context.Background(), wire, []string{"wait"}, nil, nil, ws.WireCallOptions{RequestTimeout: 20 * time.Millisecond}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout result: %v", err) + } + if time.Since(started) > time.Second { + t.Fatal("configured timeout was ignored") + } + request, cancel := <-messages, <-messages + if cancel.Frame.Kind != duplex.ProfileCancel || cancel.Return != request.Return || cancel.Frame.ID != request.Frame.ID || cancel.Frame.Traceparent != request.Frame.Traceparent { + t.Fatal("timeout changed request correlation") + } +} diff --git a/dispatch/go/wire_send_test.go b/dispatch/go/wire_send_test.go new file mode 100644 index 0000000..a5e7235 --- /dev/null +++ b/dispatch/go/wire_send_test.go @@ -0,0 +1,130 @@ +package runtime_test + +import ( + "context" + "errors" + "testing" + "time" + + ws "github.com/Bitspark/nightseam/runtime/go" +) + +// A full destination's consumer remains held while the caller finishes. The +// write deadline is deliberately much longer than the caller's bound: waiting +// for that deadline would make fan-out run at its slowest destination's pace. +func TestWireSendEndsAFullCarrierWithoutWaitingForItsConsumer(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + observed := &recorder{} + _, destination := delayedWritePair(t, control, ws.Options{ + QueueCapacity: 1, + WriteTimeout: 5 * time.Second, + Observer: observed, + }, ws.Options{}) + if err := destination.Emit(context.Background(), "first", 1); err != nil { + t.Fatal(err) + } + receive(t, control.started) + if err := destination.Emit(context.Background(), "second", 2); err != nil { + t.Fatal(err) + } + finished := make(chan error, 1) + go func() { finished <- ws.EmitWire(context.Background(), destination.Wire(), []string{"overflow"}, 3) }() + select { + case err := <-finished: + if err != nil && !errors.Is(err, ws.ErrBackpressure) { + t.Fatalf("wire admission = %v, want admission or backpressure", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("send waited for a destination whose consumer is still held") + } + select { + case <-destination.Done(): + case <-time.After(500 * time.Millisecond): + t.Fatal("wire dispatch waited for a destination whose consumer is still held") + } + if !errors.Is(destination.Err(), ws.ErrBackpressure) { + t.Fatalf("full carrier remained open: %v", destination.Err()) + } + stalls := 0 + for _, event := range observed.all() { + if pressure, ok := event.(ws.Backpressure); ok && pressure.Stalled { + stalls++ + } + } + if stalls != 1 { + t.Fatalf("observer received %d terminal pressure events, want 1", stalls) + } +} + +func TestWireRequestEndsAFullCarrierWithoutWaitingForItsConsumer(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + _, destination := delayedWritePair(t, control, ws.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second}, ws.Options{}) + if err := destination.Emit(context.Background(), "first", 1); err != nil { + t.Fatal(err) + } + receive(t, control.started) + if err := destination.Emit(context.Background(), "second", 2); err != nil { + t.Fatal(err) + } + finished := make(chan error, 1) + go func() { + finished <- ws.CallWire(context.Background(), destination.Wire(), []string{"overflow"}, nil, nil) + }() + select { + case err := <-finished: + if err == nil { + t.Fatal("wire request into a full carrier succeeded") + } + // Root wire admission already succeeded. A downstream carrier refusal + // is an ordinary result, not proof of pre-admission non-publication. + wantUnpublished(t, err, false) + case <-time.After(500 * time.Millisecond): + t.Fatal("wire request waited for a destination whose consumer is still held") + } + if !errors.Is(destination.Err(), ws.ErrBackpressure) { + t.Fatalf("full carrier remained open: %v", destination.Err()) + } +} + +func TestOutputCancellationProofBelongsOnlyToTheUnadmittedCall(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + observed := &pacedOutputObserver{pressure: make(chan ws.Backpressure, 4)} + _, destination := delayedWritePair(t, control, ws.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second, Observer: observed}, ws.Options{}) + accepted := make(chan error, 1) + go func() { accepted <- destination.Call(context.Background(), "accepted", nil, nil) }() + // The earlier call is already in its carrier's write. Hold it there, then + // occupy the only queue slot before attempting the rejected call. + receive(t, control.started) + if err := destination.Emit(context.Background(), "queued", nil); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + refused := make(chan error, 1) + go func() { refused <- destination.Call(ctx, "rejected", nil, nil) }() + if pressure := receive(t, observed.pressure); pressure.Stalled { + t.Fatalf("public call did not pace: %+v", pressure) + } + cancel() + rejected := receive(t, refused) + wantUnpublished(t, rejected, true) + if !errors.Is(rejected, context.Canceled) { + t.Fatalf("rejected call = %v", rejected) + } + // A subsequent immediate Wire dispatch ends this full carrier. Its failure + // cannot make the earlier admitted call prove that it never left. + if err := ws.EmitWire(context.Background(), destination.Wire(), []string{"overflow"}, nil); err != nil { + t.Fatal(err) + } + earlier := receive(t, accepted) + wantUnpublished(t, earlier, false) + if !errors.Is(earlier, ws.ErrBackpressure) { + t.Fatalf("earlier call = %v", earlier) + } +} diff --git a/dispatch/go/wire_test.go b/dispatch/go/wire_test.go new file mode 100644 index 0000000..e1daaaf --- /dev/null +++ b/dispatch/go/wire_test.go @@ -0,0 +1,453 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +func testBinding(t *testing.T, endpoint duplex.Endpoint) *ws.Dispatcher { + t.Helper() + binding, err := ws.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = binding.Close(duplex.CodeNormal, "done") }) + return binding +} + +type wireReplySink struct{ replies chan duplex.ProfileFrame } + +func TestReceiverDeadlineWinsImmediateHandlerRefusal(t *testing.T) { + previous := runtime.GOMAXPROCS(4) + t.Cleanup(func() { runtime.GOMAXPROCS(previous) }) + ended := make(chan ws.RequestEnded, 1) + client, server := newPair(t, ws.Options{RequestTimeout: 100 * time.Microsecond, Observer: observerFunc(func(event ws.ObserverEvent) { + if e, ok := event.(ws.RequestEnded); ok && e.Incoming { + ended <- e + } + })}, ws.Options{}) + if err := server.Handle("deadline", func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + <-ctx.Done() + return nil, &ws.PublicError{Code: "declined", Message: "Body completed at deadline"} + }); err != nil { + t.Fatal(err) + } + // Exercise the real timer/body race: completion can run before the + // asynchronous deadline callback, but the deadline is already selected. + for i := range 1024 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := client.Call(ctx, "deadline", nil, nil) + cancel() + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatalf("call %d deadline response = %v", i, err) + } + if e := receive(t, ended); e.Outcome != ws.OutcomeTimedOut || e.ErrorCode != "request_timeout" { + t.Fatalf("call %d deadline outcome = %+v", i, e) + } + } +} + +func TestWireCancellationRetainsExecutingHandlerBudget(t *testing.T) { + for _, mode := range []string{"cancel", "caller-deadline", "receiver-deadline", "public-refusal"} { + for _, route := range []string{"wire", "forwarded", "peer"} { + t.Run(mode+"/"+route, func(t *testing.T) { + ended := make(chan ws.RequestEnded, 16) + options := ws.Options{MaxConcurrentHandlers: 1, Observer: observerFunc(func(event ws.ObserverEvent) { + if e, ok := event.(ws.RequestEnded); ok && e.Incoming { + ended <- e + } + })} + if mode == "receiver-deadline" { + options.RequestTimeout = 100 * time.Millisecond + } + client, server := newPair(t, options, ws.Options{}) + entered, cancelled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var released sync.Once + t.Cleanup(func() { released.Do(func() { close(release) }) }) + var calls atomic.Int32 + handler := func(ctx context.Context, _ json.RawMessage) (any, error) { + if calls.Add(1) == 1 { + close(entered) + <-ctx.Done() + close(cancelled) + <-release + if mode == "public-refusal" { + return nil, &ws.PublicError{Code: "cancelled", Message: "Application refusal"} + } + } + return "finished", nil + } + var err error + call := func(ctx context.Context) error { return ws.CallWire(ctx, client.Wire(), []string{"hold"}, nil, nil) } + if route == "peer" { + err = server.Handle("hold", func(ctx context.Context, _ *ws.Peer, params json.RawMessage) (any, error) { + return handler(ctx, params) + }) + call = func(ctx context.Context) error { return client.Call(ctx, "hold", nil, nil) } + } else { + model := server.Wire() + if route == "forwarded" { + left, right, pairErr := ws.NewWirePair(ws.Options{}) + if pairErr != nil { + t.Fatal(pairErr) + } + t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) + stop, forwardErr := ws.ForwardWire(server.Wire(), left) + if forwardErr != nil { + t.Fatal(forwardErr) + } + t.Cleanup(stop) + model = right + } + modelBinding := testBinding(t, model) + _, err = ws.HandleWire(modelBinding, []string{"hold"}, handler) + } + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if mode == "caller-deadline" { + cancel() + ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond) + } + defer cancel() + result := make(chan error, 1) + go func() { result <- call(ctx) }() + receive(t, entered) + if mode == "cancel" || mode == "public-refusal" { + cancel() + } + if mode == "receiver-deadline" { + var public *ws.PublicError + if err := receive(t, result); !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatalf("receiver deadline = %v", err) + } + } else { + if err := receive(t, result); !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("caller cancellation = %v", err) + } + } + receive(t, cancelled) + if mode == "receiver-deadline" { + if e := receive(t, ended); e.Outcome != ws.OutcomeTimedOut || e.ErrorCode != "request_timeout" { + t.Fatalf("receiver deadline outcome = %+v", e) + } + } + // Repeated round trips keep exercising admission while the first body + // is explicitly held, independent of when its wrapper is scheduled. + for range 10 { + err := call(context.Background()) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("request admitted while cancelled body still runs: calls=%d, err=%v", calls.Load(), err) + } + if e := receive(t, ended); e.ErrorCode != "busy" { + t.Fatalf("held request ended before its body returned: %+v", e) + } + } + if calls.Load() != 1 { + t.Fatalf("executed %d bodies at a limit of one", calls.Load()) + } + released.Do(func() { close(release) }) + if mode != "receiver-deadline" { + outcome := ws.OutcomeCancelled + if mode == "public-refusal" { + outcome = ws.OutcomeErrored + } + if e := receive(t, ended); e.Outcome != outcome || e.ErrorCode != "cancelled" { + t.Fatalf("withdrawal outcome = %+v", e) + } + } + ready, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + for { + err := call(ready) + if err == nil { + break + } + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatal(err) + } + // Each retry produces an observation before its refusal. Drain + // it so the test observer cannot block the next admission. + if e := receive(t, ended); e.ErrorCode != "busy" { + t.Fatalf("retry outcome = %+v", e) + } + } + }) + } + } +} + +type wireVerifiedKey struct{} +type wireContextPropagator struct{ ws.Propagator } + +func (p wireContextPropagator) Extract(ctx context.Context, trace ws.Trace) context.Context { + return context.WithValue(p.Propagator.Extract(ctx, trace), wireVerifiedKey{}, true) +} + +func TestWireKeepsReceivedContextWithoutForwardingApplicationMetadata(t *testing.T) { + client, server := newPair(t, ws.Options{Propagator: wireContextPropagator{ws.DefaultPropagator}}, ws.Options{}) + clientBinding := testBinding(t, client.Wire()) + _, err := ws.HandleWire(clientBinding, []string{"reverse"}, func(ctx context.Context, _ json.RawMessage) (any, error) { return ws.MetaFrom(ctx), nil }) + if err != nil { + t.Fatal(err) + } + serverBinding := testBinding(t, server.Wire()) + _, err = ws.HandleWire(serverBinding, []string{"check"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + var reverse ws.Meta + if err := ws.CallWire(ctx, server.Wire(), []string{"reverse"}, nil, &reverse); err != nil { + return nil, err + } + return map[string]any{"verified": ctx.Value(wireVerifiedKey{}) == true, "received": ws.MetaFrom(ctx), "reverse": reverse}, nil + }) + if err != nil { + t.Fatal(err) + } + var got struct { + Verified bool + Received ws.Meta + Reverse ws.Meta + } + if err := ws.CallWire(ws.WithMeta(context.Background(), ws.Meta{"credential": "one-call"}), client.Wire(), []string{"check"}, nil, &got); err != nil { + t.Fatal(err) + } + if !got.Verified || got.Received["credential"] != "one-call" || len(got.Reverse) != 0 { + t.Fatalf("wire request context = %+v", got) + } +} + +func TestWireHandlerPanicStaysPrivateAndObserved(t *testing.T) { + observed := &recorder{} + client, server := newPair(t, ws.Options{Observer: observed}, ws.Options{}) + serverBinding := testBinding(t, server.Wire()) + _, err := ws.HandleWire(serverBinding, []string{"panic"}, func(context.Context, json.RawMessage) (any, error) { panic("private failure") }) + if err != nil { + t.Fatal(err) + } + err = ws.CallWire(context.Background(), client.Wire(), []string{"panic"}, nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "internal" || public.Message != "Internal error" { + t.Fatalf("panic response = %v", err) + } + count := 0 + for _, event := range observed.all() { + if failure, ok := event.(ws.HandlerPanic); ok { + count++ + if failure.Value != "private failure" { + t.Fatalf("panic observation = %+v", failure) + } + } + } + if count != 1 { + t.Fatalf("panic observations = %d", count) + } + if server.Err() != nil { + t.Fatalf("panic ended carrier: %v", server.Err()) + } +} + +// A return capability refuses what it does not implement, as every addressed +// receiver in this profile does; this one carries outcomes and nothing else. +func (s *wireReplySink) Send(path []string, message duplex.Message) error { + if len(path) != 0 { + return errors.New("this return capability carries outcomes only") + } + s.replies <- message.Frame + return nil +} + +func TestWirePreservesRequestAndEventAdmissionOrder(t *testing.T) { + observed := &recorder{} + client, server := newPair(t, ws.Options{}, ws.Options{Observer: observed}) + sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 40)} + address := &duplex.ReturnAddress{Wire: sink} + wire := client.Wire() + var want []string + serverBinding := testBinding(t, server.Wire()) + for i := range 40 { + path := []string{"ordered", fmt.Sprint(i)} + _, err := ws.HandleWire(serverBinding, path, func(context.Context, json.RawMessage) (any, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + name, _ := duplex.EncodePath(path) + for _, kind := range []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileEvent} { + frame := duplex.ProfileFrame{Version: 1, Kind: kind} + if kind == duplex.ProfileRequest { + frame.ID = fmt.Sprintf("c:%d", i+1) + frame.Params = json.RawMessage("{}") + } else { + frame.Data = json.RawMessage("null") + } + if err := wire.Send(path, duplex.Message{Frame: frame, Return: address}); err != nil { + t.Fatal(err) + } + want = append(want, string(kind)+" "+name) + } + } + for range 40 { + receive(t, sink.replies) + } + var got []string + for _, event := range observed.all() { + if sent, ok := event.(ws.FrameSent); ok && (sent.Kind == "request" || sent.Kind == "event") { + got = append(got, sent.Kind+" "+sent.Name) + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("per-wire send order changed:\n got %v\nwant %v", got, want) + } +} + +func TestWirePreservesCancellationBeforeTheFollowingEvent(t *testing.T) { + observed := &recorder{} + client, server := newPair(t, ws.Options{}, ws.Options{Observer: observed}) + started := make(chan struct{}) + eventReceived := make(chan struct{}) + serverBinding := testBinding(t, server.Wire()) + if _, err := ws.RegisterWire(serverBinding, []string{"after"}, ws.WireHandlers{Event: func(context.Context, json.RawMessage) error { close(eventReceived); return nil }}); err != nil { + t.Fatal(err) + } + _, err := ws.HandleWire(serverBinding, []string{"wait"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }) + if err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 1)} + address := &duplex.ReturnAddress{Wire: sink} + wire := client.Wire() + if err := wire.Send([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage("{}")}, Return: address}); err != nil { + t.Fatal(err) + } + receive(t, started) + if err := wire.Send([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: "c:1"}, Return: address}); err != nil { + t.Fatal(err) + } + if err := ws.EmitWire(context.Background(), wire, []string{"after"}, nil); err != nil { + t.Fatal(err) + } + receive(t, sink.replies) + receive(t, eventReceived) + var kinds []string + for _, event := range observed.all() { + if sent, ok := event.(ws.FrameSent); ok { + kinds = append(kinds, sent.Kind) + } + } + if !reflect.DeepEqual(kinds, []string{"request", "cancel", "event"}) { + t.Fatalf("send order = %v", kinds) + } +} + +func TestMountedWireKeepsIndependentOriginsAndCancellation(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + started := make(chan string, 2) + finished := make(chan string, 2) + allow := make(chan struct{}) + defer close(allow) + serverBinding := testBinding(t, server.Wire()) + _, err := ws.HandleWire(serverBinding, []string{"worker", "run"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var name string + if err := json.Unmarshal(raw, &name); err != nil { + return nil, err + } + started <- name + select { + case <-ctx.Done(): + finished <- name + return nil, ctx.Err() + case <-allow: + return name, nil + } + }) + if err != nil { + t.Fatal(err) + } + // Both views select the same existing carrier. Each CallWire has its own + // local return address, so cancelling one cannot cancel the other's id. + wire := duplex.At(duplex.Mount(map[string]duplex.Endpoint{"service": client.Wire()}), []string{"service", "worker"}) + first, cancelFirst := context.WithCancel(context.Background()) + second, cancelSecond := context.WithCancel(context.Background()) + defer cancelFirst() + defer cancelSecond() + var calls sync.WaitGroup + results := make(chan error, 2) + for _, call := range []struct { + ctx context.Context + name string + }{{first, "first"}, {second, "second"}} { + calls.Add(1) + go func() { + defer calls.Done() + results <- ws.CallWire(call.ctx, wire, []string{"run"}, call.name, nil) + }() + } + receive(t, started) + receive(t, started) + cancelFirst() + if err := receive(t, results); !errors.Is(err, context.Canceled) { + t.Fatalf("first cancellation = %v", err) + } + if name := receive(t, finished); name != "first" { + t.Fatalf("cancelled %q, want first", name) + } + var echoed string + _, err = ws.HandleWire(serverBinding, []string{"worker", "echo"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) + if err != nil { + t.Fatal(err) + } + if err := ws.CallWire(context.Background(), wire, []string{"echo"}, "still open", &echoed); err != nil || echoed != "still open" { + t.Fatalf("sibling call after cancellation = %q, %v", echoed, err) + } + cancelSecond() + if err := receive(t, results); !errors.Is(err, context.Canceled) { + t.Fatalf("second cancellation = %v", err) + } + if name := receive(t, finished); name != "second" { + t.Fatalf("second cancellation reached %q", name) + } + calls.Wait() +} + +func TestWirePathPreservesOpaqueSegmentsOverTheExistingEnvelope(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + serverBinding := testBinding(t, server.Wire()) + for _, route := range []struct { + path []string + want string + }{{[]string{"a.b"}, "one segment"}, {[]string{"a", "b"}, "two segments"}, {[]string{""}, "empty segment"}} { + _, err := ws.HandleWire(serverBinding, route.path, func(context.Context, json.RawMessage) (any, error) { return route.want, nil }) + if err != nil { + t.Fatal(err) + } + } + for _, route := range []struct { + path []string + want string + }{{[]string{"a.b"}, "one segment"}, {[]string{"a", "b"}, "two segments"}, {[]string{""}, "empty segment"}} { + var got string + if err := ws.CallWire(context.Background(), client.Wire(), route.path, nil, &got); err != nil || got != route.want { + t.Fatalf("path %q = %q, %v; want %q", route.path, got, err, route.want) + } + if err := ws.CallWire(context.Background(), duplex.At(client.Wire(), route.path), nil, nil, &got); err != nil || got != route.want { + t.Fatalf("selected leaf %q = %q, %v; want %q", route.path, got, err, route.want) + } + } +} diff --git a/dispatch/go/wire_validation_test.go b/dispatch/go/wire_validation_test.go new file mode 100644 index 0000000..6cd11a9 --- /dev/null +++ b/dispatch/go/wire_validation_test.go @@ -0,0 +1,70 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" +) + +func TestWireRefusesMalformedFramesBeforeDispatch(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{MaxFrameBytes: 256}) + invoked := 0 + serverBinding := testBinding(t, server.Wire()) + _, err := ws.HandleWire(serverBinding, []string{"echo"}, func(_ context.Context, raw json.RawMessage) (any, error) { + invoked++ + return raw, nil + }) + if err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 30)} + address := &duplex.ReturnAddress{Wire: sink} + valid := duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage("{}")} + for name, change := range map[string]func(*duplex.ProfileFrame){ + "version": func(f *duplex.ProfileFrame) { f.Version = 0 }, + "id": func(f *duplex.ProfileFrame) { f.ID = "unscoped" }, + "zero id": func(f *duplex.ProfileFrame) { f.ID = "c:0" }, + "missing params": func(f *duplex.ProfileFrame) { f.Params = nil }, + "foreign member": func(f *duplex.ProfileFrame) { f.Result = json.RawMessage("null") }, + "trace": func(f *duplex.ProfileFrame) { f.Traceparent = "invalid" }, + "reserved metadata": func(f *duplex.ProfileFrame) { f.Meta = map[string]string{"nightseam.future": "value"} }, + "oversize": func(f *duplex.ProfileFrame) { f.Params, _ = json.Marshal(strings.Repeat("x", 300)) }, + } { + t.Run(name, func(t *testing.T) { + frame := valid + change(&frame) + if err := client.Wire().Send([]string{"echo"}, duplex.Message{Frame: frame, Return: address}); err == nil { + t.Errorf("malformed frame admitted") + } + }) + } + var result string + if err := ws.CallWire(context.Background(), client.Wire(), []string{"echo"}, "still usable", &result); err != nil || result != "still usable" { + t.Fatalf("healthy call = %q, %v", result, err) + } + if invoked != 1 { + t.Fatalf("handler invoked %d times, want only the valid call", invoked) + } +} + +func TestWireSanitizesMalformedPublicErrors(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + serverBinding := testBinding(t, server.Wire()) + for _, value := range []*ws.PublicError{nil, {Code: ""}, {Code: "bad", Message: ""}} { + detach, err := ws.HandleWire(serverBinding, []string{"fail"}, func(context.Context, json.RawMessage) (any, error) { return nil, value }) + if err != nil { + t.Fatal(err) + } + err = ws.CallWire(context.Background(), client.Wire(), []string{"fail"}, nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public == nil || public.Code != "internal" { + t.Errorf("malformed public error = %v", err) + } + detach() + } +} diff --git a/engine/go/backpressure_test.go b/engine/go/backpressure_test.go new file mode 100644 index 0000000..acbe88d --- /dev/null +++ b/engine/go/backpressure_test.go @@ -0,0 +1,344 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + ws "github.com/Bitspark/nightseam/runtime/go" +) + +// Delaying actual TCP writes makes producer/transport imbalance reproducible +// without depending on the OS socket-buffer size or a stopped remote reader. +type delayedWriteControl struct { + enabled atomic.Bool + delay time.Duration + started chan struct{} + gate <-chan struct{} + once sync.Once +} + +type delayedWriteListener struct { + net.Listener + control *delayedWriteControl +} + +func (l delayedWriteListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return &delayedWriteConn{Conn: conn, control: l.control}, nil +} + +type delayedWriteConn struct { + net.Conn + control *delayedWriteControl +} + +func (c *delayedWriteConn) Write(data []byte) (int, error) { + if c.control.enabled.Load() { + c.control.once.Do(func() { close(c.control.started) }) + if c.control.gate != nil { + <-c.control.gate + } + time.Sleep(c.control.delay) + } + return c.Conn.Write(data) +} + +func delayedWritePair(t *testing.T, control *delayedWriteControl, serverOptions, clientOptions ws.Options, clientWriteControl ...*delayedWriteControl) (*ws.Peer, *ws.Peer) { + t.Helper() + connected := make(chan *ws.Peer, 1) + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: serverOptions, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *ws.Peer) { + // The HTTP upgrade has been flushed before introducing write delay. + control.enabled.Store(true) + connected <- peer + }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(handler) + server.Listener = delayedWriteListener{Listener: server.Listener, control: control} + server.Start() + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + dialOptions := ws.DialOptions{Options: clientOptions} + if len(clientWriteControl) != 0 { + transport := &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return &delayedWriteConn{Conn: conn, control: clientWriteControl[0]}, nil + }} + t.Cleanup(transport.CloseIdleConnections) + dialOptions.HTTPClient = &http.Client{Transport: transport} + } + client, _, err := ws.Dial(ctx, server.URL, dialOptions) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + remote := receive(t, connected) + t.Cleanup(func() { _ = remote.Close() }) + return client, remote +} + +func TestOutboundQueueDeliversAcceptedPrefixInOrder(t *testing.T) { + for _, capacity := range []int{2, 8} { + t.Run(fmt.Sprintf("capacity_%d", capacity), func(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + allowWrites := func() { releaseOnce.Do(func() { close(release) }) } + defer allowWrites() + received := make(chan int, capacity+1) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + client, server := delayedWritePair(t, control, ws.Options{ + QueueCapacity: capacity, + WriteTimeout: 5 * time.Second, + Handlers: map[string]ws.Handler{ + "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { + return data, nil + }, + }, + }, ws.Options{Events: map[string]ws.EventHandler{ + "replay.item": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { + var sequence int + if err := json.Unmarshal(data, &sequence); err != nil { + t.Error(err) + sequence = -1 + } + received <- sequence + }, + }}) + if err := server.Emit(context.Background(), "replay.item", 0); err != nil { + t.Fatal(err) + } + // Hold the transport's current write, then fill precisely the bounded + // handoff. Each successful emit has been accepted without a reader. + receive(t, control.started) + for sequence := 1; sequence <= capacity; sequence++ { + if err := server.Emit(context.Background(), "replay.item", sequence); err != nil { + t.Fatalf("accepted prefix item %d: %v", sequence, err) + } + } + allowWrites() + for want := range capacity + 1 { + if got := receive(t, received); got != want { + t.Fatalf("accepted sequence = %d, want %d", got, want) + } + } + var echo string + if err := client.Call(context.Background(), "echo", "still connected", &echo); err != nil || echo != "still connected" { + t.Fatalf("echo after accepted prefix = %q, error=%v", echo, err) + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("accepted prefix disconnected peers: client=%v server=%v", client.Err(), server.Err()) + } + }) + } +} + +func TestOutboundQueuePreCancelledSendDoesNotDisconnect(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + allowWrites := func() { releaseOnce.Do(func() { close(release) }) } + defer allowWrites() + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + received := make(chan int, 4) + client, server := delayedWritePair(t, control, ws.Options{ + QueueCapacity: 2, + WriteTimeout: 5 * time.Second, + Handlers: map[string]ws.Handler{ + "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { return data, nil }, + }, + }, ws.Options{Events: map[string]ws.EventHandler{ + "progress": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { + var value int + if err := json.Unmarshal(data, &value); err != nil { + received <- -1 + return + } + received <- value + }, + }}) + if err := server.Emit(context.Background(), "progress", 0); err != nil { + t.Fatal(err) + } + // Hold the first real write until the cancellation assertion is complete. This + // guarantees the two queued frames cannot drain, even on a heavily loaded host. + receive(t, control.started) + for _, value := range []int{1, 2} { + if err := server.Emit(context.Background(), "progress", value); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := server.Emit(ctx, "progress", 999) + if !errors.Is(err, context.Canceled) { + t.Fatalf("pre-cancelled send = %v, want context canceled", err) + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("unadmitted cancellation disconnected peers: client=%v server=%v", client.Err(), server.Err()) + } + allowWrites() + for want := range 3 { + if got := receive(t, received); got != want { + t.Fatalf("queued event = %d, want %d", got, want) + } + } + if err := server.Emit(context.Background(), "progress", 3); err != nil { + t.Fatal(err) + } + if got := receive(t, received); got != 3 { + t.Fatalf("cancelled event was published: received %d before marker 3", got) + } + var echo string + if err := client.Call(context.Background(), "echo", "alive", &echo); err != nil || echo != "alive" { + t.Fatalf("echo after cancelled enqueue = %q, error=%v", echo, err) + } +} + +func TestOutboundQueueDoesNotDelayCancellationOfSentCall(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + allowWrites := func() { releaseOnce.Do(func() { close(release) }) } + defer allowWrites() + serverControl := &delayedWriteControl{started: make(chan struct{})} + clientControl := &delayedWriteControl{started: make(chan struct{}), gate: release} + handlerStarted := make(chan struct{}) + client, server := delayedWritePair(t, serverControl, ws.Options{Handlers: map[string]ws.Handler{ + "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + close(handlerStarted) + <-ctx.Done() + return nil, ctx.Err() + }, + }}, ws.Options{QueueCapacity: 2, WriteTimeout: 5 * time.Second}, clientControl) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + returned := make(chan error, 1) + go func() { returned <- client.Call(ctx, "wait", nil, nil) }() + // Only introduce congestion after the request has reached its handler. + receive(t, handlerStarted) + clientControl.enabled.Store(true) + if err := client.Emit(context.Background(), "progress", 0); err != nil { + t.Fatal(err) + } + receive(t, clientControl.started) + for _, value := range []int{1, 2} { + if err := client.Emit(context.Background(), "progress", value); err != nil { + t.Fatal(err) + } + } + cancel() + select { + case err := <-returned: + if !errors.Is(err, context.Canceled) { + t.Fatalf("sent call cancellation = %v, want context canceled", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("caller cancellation waited for space to enqueue the best-effort cancellation frame") + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("call cancellation disconnected peers: client=%v server=%v", client.Err(), server.Err()) + } + // Delivery of the remote cancellation is intentionally not required when the + // output queue is full. Connection cleanup releases the waiting handler. + allowWrites() +} + +// TestInboundEventBurstIsPacedRatherThanDisconnected: a queue filled faster +// than its consumer drains it is a burst, not a stall, and the peer pacing it +// is what tells them apart โ€” the events are delivered, in order, and the +// connection is whole. Before the queue was paced this ended the connection +// on the third event. +func TestInboundEventBurstIsPacedRatherThanDisconnected(t *testing.T) { + release := make(chan struct{}) + delivered := make(chan int, 8) + client, server := newPair(t, ws.Options{}, ws.Options{ + QueueCapacity: 1, + WriteTimeout: 5 * time.Second, + Events: map[string]ws.EventHandler{ + "progress": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { + <-release + var value int + if err := json.Unmarshal(data, &value); err != nil { + t.Error(err) + return + } + delivered <- value + }, + }, + }) + for _, value := range []int{1, 2, 3} { + if err := server.Emit(context.Background(), "progress", value); err != nil { + t.Fatal(err) + } + } + // The burst is held while the consumer is busy and drains when it is not. + close(release) + for want := 1; want <= 3; want++ { + if got := receive(t, delivered); got != want { + t.Fatalf("event %d arrived where %d was due", got, want) + } + } + select { + case <-client.Done(): + t.Fatalf("a burst that drained ended the connection: %v", client.Err()) + default: + } +} + +// TestOutstandingCallLimitRefusesWithoutEndingTheConnection: the caller's own +// bound. The call past it is refused busy where it stands โ€” no frame, no +// request an observer is told of โ€” and the connection serves the next call, +// which is what makes it a refusal and not a failure. +func TestOutstandingCallLimitRefusesWithoutEndingTheConnection(t *testing.T) { + started := make(chan struct{}, 4) + client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + started <- struct{}{} + <-ctx.Done() + return nil, ctx.Err() + }, + "echo": func(_ context.Context, _ *ws.Peer, params json.RawMessage) (any, error) { return params, nil }, + }}, ws.Options{MaxPendingRequests: 2}) + + ctx, cancel := context.WithCancel(context.Background()) + var waiting sync.WaitGroup + for range 2 { + waiting.Add(1) + go func() { defer waiting.Done(); _ = client.Call(ctx, "wait", nil, nil) }() + } + receive(t, started) + receive(t, started) + + err := client.Call(context.Background(), "wait", nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("the call past the limit ended with %v, not busy", err) + } + + cancel() + waiting.Wait() + var echoed int + if err := client.Call(context.Background(), "echo", 7, &echoed); err != nil || echoed != 7 { + t.Fatalf("the connection did not serve on: %v, %d", err, echoed) + } +} diff --git a/engine/go/carriage_test.go b/engine/go/carriage_test.go new file mode 100644 index 0000000..d464ee6 --- /dev/null +++ b/engine/go/carriage_test.go @@ -0,0 +1,341 @@ +package runtime + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// The carriage a request and an event may take: what is about the call rather +// than the call. The peer accepts it and keeps it on the decoded frame, and +// sends what WithMeta placed on the sending context โ€” never one of its own. + +// TestMetaIsKeptOnTheDecodedFrame: a frame of each kind that may carry meta +// decodes, and the member reaches the frame verbatim rather than being read +// and dropped. +func TestMetaIsKeptOnTheDecodedFrame(t *testing.T) { + for _, test := range []struct { + name string + frame string + meta map[string]string + }{ + {"request", `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"tenant":"acme","idempotency":"k-1"}}`, + map[string]string{"tenant": "acme", "idempotency": "k-1"}}, + {"request with an empty carriage", `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{}}`, + map[string]string{}}, + {"event", `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"cause":"nightly"}}`, + map[string]string{"cause": "nightly"}}, + {"event beside a trace", `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"tenant":"acme"},` + + `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}`, + map[string]string{"tenant": "acme"}}, + } { + t.Run(test.name, func(t *testing.T) { + f, err := decodeFrame([]byte(test.frame)) + if err != nil { + t.Fatalf("a frame carrying meta was refused: %v", err) + } + if !reflect.DeepEqual(f.Meta, test.meta) { + t.Fatalf("meta = %v, want %v", f.Meta, test.meta) + } + }) + } + // A frame carrying none leaves the member absent rather than empty, so the + // emitting half can tell a carriage with nothing in it from no carriage. + f, err := decodeFrame([]byte(`{"version":1,"kind":"request","id":"c:1","method":"read","params":{}}`)) + if err != nil || f.Meta != nil { + t.Fatalf("a frame carrying no meta = %v, %v", f.Meta, err) + } +} + +// TestMetaIsRefusedInEveryOtherForm: the kinds that may not carry it, the +// forms that are not an object of strings, and the keys the profile keeps. +func TestMetaIsRefusedInEveryOtherForm(t *testing.T) { + for _, frame := range []string{ + // A response says what it says in its result; a cancel withdraws a call + // rather than making one. + `{"version":1,"kind":"response","id":"s:1","result":1,"meta":{"tenant":"acme"}}`, + `{"version":1,"kind":"response","id":"s:1","error":{"code":"busy","message":"Try later"},"meta":{"tenant":"acme"}}`, + `{"version":1,"kind":"cancel","id":"c:1","meta":{"tenant":"acme"}}`, + // An object of strings, and nothing else. + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":"acme"}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":["acme"]}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":7}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":null}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"attempt":2}}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"tenant":null}}`, + `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"live":true}}`, + `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"who":{"id":"u1"}}}`, + // The namespace the profile keeps for itself, which it fills with + // nothing in this version. + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"nightseam.deadline":"2026-01-01T00:00:00Z"}}`, + `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"nightseam.cause":"nightly"}}`, + } { + t.Run(frame, func(t *testing.T) { + if _, err := decodeFrame([]byte(frame)); err == nil { + t.Fatal("a frame the profile does not admit was accepted") + } + }) + } +} + +// TestTheConformanceTableIsJudgedAsItJudges: every row of tables/frames.json, +// held the way the peer holds a frame it is handed โ€” the envelope decoded and +// the id held to the prefix its kind carries โ€” so that the two runtimes and +// the suite read one description of the wire, this one. +func TestTheConformanceTableIsJudgedAsItJudges(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "conformance", "tables", "frames.json")) + if err != nil { + t.Fatal(err) + } + var table struct { + Rows []struct { + Name string + To string + Frame string + Valid bool + } + } + if err := json.Unmarshal(data, &table); err != nil { + t.Fatal(err) + } + carriages := 0 + for _, row := range table.Rows { + // A row addressed to the server carries the client's ids and answers + // the server's; one addressed to either is read as a server's. + local, remote := "s:", "c:" + if row.To == "client" { + local, remote = "c:", "s:" + } + f, err := decodeFrame([]byte(row.Frame)) + accepted := err == nil + if accepted && f.ID != "" { + prefix := remote + if f.Kind == "response" { + prefix = local + } + accepted = validID(f.ID, prefix) + } + if accepted != row.Valid { + t.Errorf("%s: valid=%v, decode error %v", row.Name, row.Valid, err) + } + var members map[string]json.RawMessage + if json.Unmarshal([]byte(row.Frame), &members) == nil { + if _, carried := members["meta"]; carried { + carriages++ + } + } + } + if len(table.Rows) < 70 || carriages < 12 { + t.Fatalf("the table holds %d rows and names meta in %d; the wire is held by more than that", len(table.Rows), carriages) + } +} + +// TestAFrameWithARefusedMetaEndsTheConnection: the refusal is the profile's +// own close, 4011, as any malformed frame is. The peer aborts rather than +// closing with a handshake, so the code this side decided on is what the +// observer is told, and the connection is dead either way. +func TestAFrameWithARefusedMetaEndsTheConnection(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + far, near := duplex.Pipe(1 << 20) + closes := make(chan ConnectionClosed, 1) + peer, err := NewPeer(ctx, near, ServerRole, Options{Observer: observerFunc(func(event ObserverEvent) { + if closed, ok := event.(ConnectionClosed); ok { + closes <- closed + } + })}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = peer.Close() }() + frame := `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"nightseam.cause":"nightly"}}` + if err := far.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte(frame)}); err != nil { + t.Fatal(err) + } + select { + case closed := <-closes: + if closed.Code != int(duplex.CodeDuplex) || !closed.Local { + t.Fatalf("the connection closed with %d local=%v, want %d local", closed.Code, closed.Local, int(duplex.CodeDuplex)) + } + case <-ctx.Done(): + t.Fatal("a frame the profile does not admit left the connection open") + } + if peer.Err() == nil { + t.Fatal("the peer ended without an error") + } +} + +// TestMetaTravelsFromTheContextToTheFrame: what WithMeta said reaches the +// request and the event sent from that context, and a context that said +// nothing carries the member nowhere. +func TestMetaTravelsFromTheContextToTheFrame(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + far, near := duplex.Pipe(1 << 20) + peer, err := NewPeer(ctx, near, ClientRole, Options{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = peer.Close() }() + carried := Meta{"tenant": "acme", "idempotency": "k-1"} + // The call waits for a response nobody sends; the frame it sent is the + // assertion, and the test's own context releases it at the end โ€” cancelling + // it here would put a cancel frame between the reads below. + go func() { _ = peer.Call(WithMeta(ctx, carried), "read", nil, nil) }() + if meta := metaOfNextFrame(ctx, t, far); !reflect.DeepEqual(meta, carried) { + t.Fatalf("the request carried meta %v, want %v", meta, carried) + } + if err := peer.Emit(WithMeta(ctx, Meta{"cause": "nightly"}), "updated", 1); err != nil { + t.Fatal(err) + } + if meta := metaOfNextFrame(ctx, t, far); !reflect.DeepEqual(meta, Meta{"cause": "nightly"}) { + t.Fatalf("the event carried meta %v", meta) + } + // A context that said nothing sends the member nowhere: absent, not empty. + if err := peer.Emit(ctx, "updated", 1); err != nil { + t.Fatal(err) + } + if meta := metaOfNextFrame(ctx, t, far); meta != nil { + t.Fatalf("an event from a bare context carried meta %v", meta) + } + // A key of the reserved prefix is the profile's; WithMeta drops it rather + // than sending a frame the far peer would refuse. + if err := peer.Emit(WithMeta(ctx, Meta{"nightseam.cause": "nightly", "tenant": "acme"}), "updated", 1); err != nil { + t.Fatal(err) + } + if meta := metaOfNextFrame(ctx, t, far); !reflect.DeepEqual(meta, Meta{"tenant": "acme"}) { + t.Fatalf("a reserved key reached the wire: %v", meta) + } +} + +// metaOfNextFrame is the meta of the next frame the far side of a pipe reads, +// and nil where the frame carried none. +func metaOfNextFrame(ctx context.Context, t *testing.T, conn duplex.Conn) Meta { + t.Helper() + frame, err := conn.Receive(ctx) + if err != nil { + t.Fatalf("receive: %v", err) + } + var members struct { + Meta Meta `json:"meta"` + } + if err := json.Unmarshal(frame.Data, &members); err != nil { + t.Fatalf("decode %s: %v", frame.Data, err) + } + return members.Meta +} + +// TestAHandlerReadsItsMetaAndForwardsNothingOfItself: a carriage reaches the +// handler of the frame that carried it, and goes no further on its own โ€” a +// trace is the peer's to propagate and a credential is not, so a handler that +// means to forward one says WithMeta(ctx, MetaFrom(ctx)). +func TestAHandlerReadsItsMetaAndForwardsNothingOfItself(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + clientConn, serverConn := duplex.Pipe(1 << 20) + nested := make(chan Meta, 1) + events := make(chan Meta, 1) + server, err := NewPeer(ctx, serverConn, ServerRole, Options{ + Handlers: map[string]Handler{ + // Reads its own meta, then calls back without saying to forward it. + "read": func(ctx context.Context, p *Peer, _ json.RawMessage) (any, error) { + mine := MetaFrom(ctx) + var back string + if err := p.Call(ctx, "reverse", nil, &back); err != nil { + return nil, err + } + return mine, nil + }, + // Reads its own meta, then forwards it as a handler must say to. + "relay": func(ctx context.Context, p *Peer, _ json.RawMessage) (any, error) { + var back string + return back, p.Call(WithMeta(ctx, MetaFrom(ctx)), "reverse", nil, &back) + }, + }, + Events: map[string]EventHandler{ + "updated": func(ctx context.Context, _ *Peer, _ json.RawMessage) { events <- MetaFrom(ctx) }, + }, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close() }() + client, err := NewPeer(ctx, clientConn, ClientRole, Options{Handlers: map[string]Handler{ + "reverse": func(ctx context.Context, _ *Peer, _ json.RawMessage) (any, error) { + nested <- MetaFrom(ctx) + return "back", nil + }, + }}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = client.Close() }() + + carried := Meta{"tenant": "acme"} + var seen Meta + if err := client.Call(WithMeta(ctx, carried), "read", nil, &seen); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(seen, carried) { + t.Fatalf("the handler read meta %v, want %v", seen, carried) + } + if forwarded := receiveMeta(ctx, t, nested); forwarded != nil { + t.Fatalf("a call from the handler carried the caller's meta %v of its own accord", forwarded) + } + if err := client.Call(WithMeta(ctx, carried), "relay", nil, nil); err != nil { + t.Fatal(err) + } + if forwarded := receiveMeta(ctx, t, nested); !reflect.DeepEqual(forwarded, carried) { + t.Fatalf("a handler that said to forward carried %v, want %v", forwarded, carried) + } + // An event's handler reads its event's carriage across the bounded queue. + if err := client.Emit(WithMeta(ctx, Meta{"cause": "nightly"}), "updated", 1); err != nil { + t.Fatal(err) + } + if got := receiveMeta(ctx, t, events); !reflect.DeepEqual(got, Meta{"cause": "nightly"}) { + t.Fatalf("the event handler read meta %v", got) + } + // A handler of a frame that carried none reads nil, not an empty carriage. + if err := client.Emit(ctx, "updated", 1); err != nil { + t.Fatal(err) + } + if got := receiveMeta(ctx, t, events); got != nil { + t.Fatalf("a handler of a bare event read meta %v", got) + } +} + +func receiveMeta(ctx context.Context, t *testing.T, from <-chan Meta) Meta { + t.Helper() + select { + case meta := <-from: + return meta + case <-ctx.Done(): + t.Fatal("nothing arrived") + return nil + } +} + +// TestMetaFromIsACopy: what a handler writes into what it read reaches no +// frame and no other handler. +func TestMetaFromIsACopy(t *testing.T) { + carried := Meta{"tenant": "acme"} + ctx := withIncomingMeta(context.Background(), carried) + mine := MetaFrom(ctx) + mine["tenant"] = "other" + if MetaFrom(ctx)["tenant"] != "acme" { + t.Fatal("a handler's write reached the frame's carriage") + } + if MetaFrom(context.Background()) != nil { + t.Fatal("a context no frame ran carries a carriage") + } +} + +type observerFunc func(ObserverEvent) + +func (f observerFunc) Observe(event ObserverEvent) { f(event) } diff --git a/engine/go/peer.go b/engine/go/peer.go new file mode 100644 index 0000000..88f69c1 --- /dev/null +++ b/engine/go/peer.go @@ -0,0 +1,1120 @@ +// Package runtime implements the nightseam.duplex/1 profile over a frames +// duplex connection (duplex): JSON text frames carrying requests, +// responses, events and cancellations. It never touches a WebSocket; Dial +// and Accept open one and hand it over as a connection, and a Peer over any +// other transport speaks the same profile byte for byte. It has no +// application authorization, replay, retries, or persistence policy. +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "runtime" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/Bitspark/nightseam/duplex/go" + "github.com/Bitspark/nightseam/internal/scalarjson" +) + +// Profile names what this package speaks: JSON text frames carrying requests, +// responses, events and cancellations both ways over a connection of the seam. +// docs/wire/profile.md is its specification. +const Profile = "nightseam.duplex/1" + +// Role is the side of a connection a peer takes. It decides the prefix of the +// request ids the peer mints โ€” c: for a client, s: for a server โ€” so the two +// sides never mint the same id, and a tunnel over the peer chooses its channel +// ids by it. +type Role string + +const ( + // ClientRole dials and mints request ids under the c: prefix. + ClientRole Role = "client" + // ServerRole accepts and mints request ids under the s: prefix. + ServerRole Role = "server" +) + +// The errors a peer ends with: ErrClosed when Close was called or the +// connection ended, ErrBackpressure when a queue stayed full past its write +// deadline โ€” a consumer that does not drain is disconnected rather than +// allowed to hold the connection up. Both reach Err and every pending call. +var ( + ErrClosed = errors.New("duplex connection closed") + ErrBackpressure = errors.New("duplex consumer is stalled") +) + +// PublicError is safe to send to the remote caller. Other handler errors are +// replaced by a generic internal error; their messages are not disclosed. +type PublicError struct { + Code string `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Error is the code and the message, as a log line shows them. +func (e *PublicError) Error() string { return e.Code + ": " + e.Message } + +// Handler answers one request: it takes the params as they arrived and +// returns the result, or an error โ€” a *PublicError crosses the wire with its +// code; any other error reaches the caller as internal. The context is +// cancelled when the caller withdraws the request or its deadline passes, and +// carries the trace and the meta the frame brought. +type Handler func(context.Context, *Peer, json.RawMessage) (any, error) + +// EventHandler takes one event's data; events have no answer. Handlers run +// one at a time in the order the events arrived. +type EventHandler func(context.Context, *Peer, json.RawMessage) + +// Event is one event of the profile as a handler or an emitter sees it: its +// name and its data. +type Event struct { + Name string `json:"event"` + Data json.RawMessage `json:"data"` +} + +// Options limits are per connection. Zero values select the documented defaults. +// Handlers may run concurrently; event callbacks run serially in receive order. +type Options struct { + Handlers map[string]Handler + Events map[string]EventHandler + // Prepare runs on the peer once it is built and before it reads its + // first frame: what it installs โ€” Handle, HandleEvent, a tunnel over the + // peer โ€” is there before anything can arrive, so the other side's first + // request cannot be refused method_not_found by a peer whose handlers + // are still on their way. Install in Prepare, use in OnConnect, which + // runs on a peer that is already live. An error fails the construction: + // the peer never runs and the constructor answers with it. + Prepare func(*Peer) error + MaxConcurrentHandlers int + // MaxPendingRequests bounds the calls this peer may have outstanding at + // once; the one past it is refused busy without reaching the wire. It is + // the caller's own bound, as MaxConcurrentHandlers is the receiver's. + MaxPendingRequests int + QueueCapacity int + MaxFrameBytes int64 + RequestTimeout time.Duration + WriteTimeout time.Duration + Propagator Propagator + // Observer is told what the peer does with the traffic it carries; nil + // observes nothing and costs nothing. Families labels a method or event + // name with the family it belongs to, which the generated install fills: + // an unlabelled name has no family, and the runtime parses none. + Observer Observer + Families map[string]string +} + +func (o Options) normalized() (Options, error) { + if o.MaxConcurrentHandlers < 0 || o.MaxPendingRequests < 0 || o.QueueCapacity < 0 || o.MaxFrameBytes < 0 || o.RequestTimeout < 0 || o.WriteTimeout < 0 { + return o, errors.New("duplex limits must not be negative") + } + if o.MaxConcurrentHandlers == 0 { + o.MaxConcurrentHandlers = 64 + } + if o.MaxPendingRequests == 0 { + o.MaxPendingRequests = 128 + } + if o.QueueCapacity == 0 { + o.QueueCapacity = 128 + } + if o.MaxFrameBytes == 0 { + o.MaxFrameBytes = 1 << 20 + } + if o.RequestTimeout == 0 { + o.RequestTimeout = 30 * time.Second + } + if o.WriteTimeout == 0 { + o.WriteTimeout = 10 * time.Second + } + if o.Propagator == nil { + o.Propagator = DefaultPropagator + } + for name, h := range o.Handlers { + if name == "" || h == nil { + return o, errors.New("invalid duplex method handler") + } + } + for name, h := range o.Events { + if name == "" || h == nil { + return o, errors.New("invalid duplex event handler") + } + } + return o, nil +} + +type frame struct { + Version int `json:"version"` + Kind string `json:"kind"` + ID string `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *PublicError `json:"error,omitempty"` + Event string `json:"event,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + // W3C Trace Context, which every kind may carry and none requires. + Traceparent string `json:"traceparent,omitempty"` + Tracestate string `json:"tracestate,omitempty"` + // What is about a call rather than the call, which a request and an event + // may carry. The peer keeps it for what reads it above and emits none. + Meta map[string]string `json:"meta,omitempty"` +} + +// queuedFrame is one frame waiting for the writer: the bytes it will write and +// the frame they were rendered from. The two travel together so that the send +// is observed by the one goroutine that writes, immediately before the bytes +// leave โ€” one serialization point per peer, which is what makes the observer's +// events one order (docs/runtime/observer.md). +type queuedFrame struct { + data []byte + frame frame +} + +// queuedEvent keeps an event's trace beside it across the bounded queue: the +// handler runs under the context the trace was extracted into, not under one +// the reader has already left behind. +type queuedEvent struct { + event Event + trace Trace + meta Meta +} + +type pendingResult struct { + result json.RawMessage + err error +} + +// Peer owns a frames duplex connection until Close or transport failure. +// Never send on or receive from the connection after handing it to NewPeer. +// The connection's receive limit is its maker's to set to MaxFrameBytes; +// the peer refuses a larger frame it is nonetheless handed. +type Peer struct { + wireOnce sync.Once + wire *peerWire + conn duplex.Conn + ctx context.Context + cancel context.CancelFunc + options Options + prefix string + remotePrefix string + subprotocol string + next uint64 + publish sync.Mutex + admitted uint64 + done chan struct{} + once sync.Once + mu sync.Mutex + err error + pending map[string]chan pendingResult + incoming map[string]context.CancelFunc + handlers map[string]Handler + eventHandlers map[string]EventHandler + requestFallback func(string) Handler + eventFallback func(string) EventHandler + listeners map[uint64]func(context.Context, Event) + listenerID uint64 + outputs chan queuedFrame + events chan queuedEvent + slots chan struct{} +} + +// NewPeer speaks the profile over any connection of the seam โ€” a pipe, a +// tunnel channel, a socket already accepted โ€” as the given role. The peer +// owns the connection from here and closes it when it ends; ctx ending ends +// the peer. Dial and Accept are this over a WebSocket. +func NewPeer(ctx context.Context, conn duplex.Conn, role Role, options Options) (*Peer, error) { + return newPeer(ctx, conn, role, options, "") +} + +// newPeer is NewPeer carrying what the handshake beneath selected, which only +// Accept and Dial are in a position to know; every other connection has none. +// It is set before the loops start, so Subprotocol is read without a lock. +func newPeer(ctx context.Context, conn duplex.Conn, role Role, options Options, subprotocol string) (*Peer, error) { + if ctx == nil || conn == nil { + return nil, errors.New("duplex requires a context and connection") + } + if role != ClientRole && role != ServerRole { + return nil, errors.New("invalid duplex role") + } + o, err := options.normalized() + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(ctx) + p := &Peer{conn: conn, ctx: ctx, cancel: cancel, options: o, prefix: "c:", remotePrefix: "s:", subprotocol: subprotocol, done: make(chan struct{}), + pending: make(map[string]chan pendingResult), incoming: make(map[string]context.CancelFunc), handlers: make(map[string]Handler), + eventHandlers: make(map[string]EventHandler), listeners: make(map[uint64]func(context.Context, Event)), + outputs: make(chan queuedFrame, o.QueueCapacity), events: make(chan queuedEvent, o.QueueCapacity), slots: make(chan struct{}, o.MaxConcurrentHandlers)} + if role == ServerRole { + p.prefix, p.remotePrefix = "s:", "c:" + } + for k, v := range o.Handlers { + p.handlers[k] = v + } + for k, v := range o.Events { + p.eventHandlers[k] = v + } + if o.Prepare != nil { + if err := o.Prepare(p); err != nil { + p.abandon(err) + return nil, err + } + } + p.observeOpened(role) + go p.readLoop() + go p.writeLoop() + go p.eventLoop() + go func() { <-ctx.Done(); p.fail(ctx.Err()) }() + return p, nil +} + +// Done is closed when the peer has ended, for whatever reason; Err says which. +func (p *Peer) Done() <-chan struct{} { return p.done } + +// Context is the peer's own, cancelled when it ends: what a handler or a +// caller derives its own from to be released with the connection. +func (p *Peer) Context() context.Context { return p.ctx } + +// Role is the side of the connection this peer is: it prefixes the request +// ids it mints, and a tunnel over it chooses channel ids by it. +func (p *Peer) Role() Role { + if p.prefix == "s:" { + return ServerRole + } + return ClientRole +} + +// Subprotocol is what the WebSocket handshake beneath this peer selected, and +// "" when it selected none or the peer does not run over a WebSocket. It is +// fixed for the peer's life; the profile reads nothing into it. +func (p *Peer) Subprotocol() string { return p.subprotocol } + +// MaxFrameBytes is the largest frame this peer sends or receives. +func (p *Peer) MaxFrameBytes() int64 { return p.options.MaxFrameBytes } + +// Err is why the peer ended, or nil while it runs: ErrClosed, ErrBackpressure, +// the context's error, or the connection's own. +func (p *Peer) Err() error { p.mu.Lock(); defer p.mu.Unlock(); return p.err } + +// Close ends the peer with ErrClosed, closing the connection beneath it with +// 1000 and failing every pending call. It is safe to call more than once. +func (p *Peer) Close() error { p.end(ErrClosed, duplex.CodeNormal, ""); return nil } + +// fail ends the peer on a transport there is nothing to say over: a write that +// failed, the context ending, a consumer that stalled past its deadline. The +// connection is aborted rather than closed with a handshake nobody is left to +// answer, and the far side reads an abnormal closure. +func (p *Peer) fail(err error) { p.end(err, codeAborted, "") } + +// refuse ends the peer on a frame the profile does not admit โ€” a malformed +// envelope, an id that correlates with nothing, a frame of the wrong kind. The +// other side broke the profile and is told so, with 4011 and a reason, because +// a gateway or a proxy between the two can act on a code and can act on +// nothing at all (docs/wire/profile.md). +func (p *Peer) refuse(err error) { p.end(err, duplex.CodeDuplex, err.Error()) } + +// abandon releases a peer that never ran: Prepare failed, the loops were +// never started and nothing of the profile reached the wire, so there is +// nothing to close with a code here โ€” the connection is disposed of by the +// constructor that opened it. Whatever Prepare started before it failed sees +// the context cancelled and Done closed, as it would on any other end. +func (p *Peer) abandon(err error) { + p.once.Do(func() { + p.mu.Lock() + p.err = err + p.mu.Unlock() + p.cancel() + close(p.done) + }) +} + +// codeAborted stands for no close at all: the connection is aborted, nothing +// is sent, and the far side reads 1006. +const codeAborted duplex.Code = 0 + +// end ends the peer once, whatever ended it: every pending call is released, +// the connection is closed with the code this side decided on or aborted where +// there is none, and the observer is told what the wire carried. +func (p *Peer) end(err error, code duplex.Code, reason string) { + p.once.Do(func() { + if err == nil { + err = ErrClosed + } + // This outcome settles every pending request, including ones already + // delivered. Another send's proof cannot be broadcast as their proof. + err = WithoutUnpublishedProof(err) + p.mu.Lock() + p.err = err + p.mu.Unlock() + p.cancel() + close(p.done) + if code == codeAborted { + _ = p.conn.Abort() + } else { + // The peer's own context is already cancelled, so the handshake + // waits on one of its own: a far side that answers is told the + // code, and one that does not holds nothing up past the deadline. + reason = closeReason(reason) + ctx, cancel := context.WithTimeout(context.Background(), p.options.WriteTimeout) + _ = p.conn.Close(ctx, code, reason) + cancel() + } + p.observeClosed(err, code, reason) + }) +} + +// closeReason is what a close frame admits: the registry bounds a reason at +// 123 bytes and requires valid UTF-8, and a transport handed a longer one +// would close with no code at all โ€” which is the one thing a refusal must not +// do. A refused frame's own text may reach it, so it is cut on a rune. +func closeReason(reason string) string { + const limit = 123 + if len(reason) <= limit { + return reason + } + reason = reason[:limit] + for len(reason) > 0 && !utf8.ValidString(reason) { + reason = reason[:len(reason)-1] + } + return reason +} + +// Handle registers a method. Duplicate registrations are rejected. +func (p *Peer) Handle(method string, handler Handler) error { + if method == "" || handler == nil { + return errors.New("invalid duplex method handler") + } + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + if _, exists := p.handlers[method]; exists { + return fmt.Errorf("method %q already registered", method) + } + p.handlers[method] = handler + return nil +} + +// HandleEvent registers the handler for the event of that name, replacing +// any before it; an event with no handler is dropped. +func (p *Peer) HandleEvent(name string, handler EventHandler) error { + if name == "" || handler == nil { + return errors.New("invalid duplex event handler") + } + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + if _, exists := p.eventHandlers[name]; exists { + return fmt.Errorf("event %q already registered", name) + } + p.eventHandlers[name] = handler + return nil +} + +// OnEvent observes every event and returns an idempotent unsubscribe function. +// Slow callbacks consume the bounded event queue and can disconnect the peer. +func (p *Peer) OnEvent(listener func(context.Context, Event)) func() { + if listener == nil { + return func() {} + } + p.mu.Lock() + p.listenerID++ + id := p.listenerID + p.listeners[id] = listener + p.mu.Unlock() + return func() { p.mu.Lock(); delete(p.listeners, id); p.mu.Unlock() } +} + +// Call sends one request and waits for its response. It never retries. A caller +// cancellation also sends best-effort cancellation to the remote handler. +func (p *Peer) Call(ctx context.Context, method string, params, result any) error { + call, err := p.beginCall(ctx, method, params) + if err != nil { + return err + } + return call.await(result) +} + +type admittedCall struct { + await func(any) error + withdraw func() +} + +// beginCall performs the bounded admission synchronously. A wire dispatcher +// admits frames in its delivery order, then waits for each result separately; +// starting goroutines before admission would reorder requests and events. +func (p *Peer) beginCall(ctx context.Context, method string, params any) (*admittedCall, error) { + return p.beginCallTrace(ctx, method, params, nil, false) +} + +// A structured wire frame already carries its trace. Public Call still injects +// a child; forwarding the existing frame uses these exact members instead. +func (p *Peer) beginCallTrace(ctx context.Context, method string, params any, carried *Trace, immediate bool) (*admittedCall, error) { + if ctx == nil || method == "" { + return nil, Unpublished(errors.New("duplex call requires context and method")) + } + ctx, cancel := context.WithTimeout(ctx, p.options.RequestTimeout) + if err := ctx.Err(); err != nil { + cancel() + return nil, Unpublished(err) + } + data, err := MarshalJSON(params) + if err != nil { + cancel() + return nil, Unpublished(err) + } + // One trace serves the request and the cancellation that may follow it: a + // cancel carries its request's members, not a sibling span of them. + var trace Trace + if carried != nil { + trace = *carried + } else { + trace = p.options.Propagator.Inject(ctx) + } + reply := make(chan pendingResult, 1) + // Reservation and publication happen under the outgoing queue's one + // ordering gate, so serials reach the other side in the order they were + // taken. A reservation that never publishes has still spent its serial, + // and the receiver allows the gap it leaves. + p.publish.Lock() + if p.next == maxRequestSerial { + p.publish.Unlock() + cancel() + p.fail(errors.New("duplex request serials exhausted")) + return nil, Unpublished(&PublicError{Code: "identifier_exhausted", Message: "Create a new peer before issuing further calls"}) + } + p.next++ + id := p.prefix + strconv.FormatUint(p.next, 10) + p.mu.Lock() + if p.err != nil { + err = p.err + p.mu.Unlock() + p.publish.Unlock() + cancel() + return nil, Unpublished(err) + } + // The caller's own bound. A call past it never reaches the wire and never + // becomes an observer's request: nothing started, so nothing ended. + if len(p.pending) >= p.options.MaxPendingRequests { + p.mu.Unlock() + p.publish.Unlock() + cancel() + return nil, Unpublished(&PublicError{Code: "busy", Message: "Outstanding call limit reached"}) + } + p.pending[id] = reply + p.mu.Unlock() + finish := func() { cancel(); p.mu.Lock(); delete(p.pending, id); p.mu.Unlock() } + started := p.requestStarted(id, method, false, trace) + err = p.enqueueFrame(ctx, frame{Version: 1, Kind: "request", ID: id, Method: method, Params: data, + Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)}, immediate) + p.publish.Unlock() + if err != nil { + finish() + err = Unpublished(err) + p.requestEnded(started, id, method, false, trace, err) + return nil, err + } + var completed sync.Once + complete := func(err error, withdrawn bool) { + completed.Do(func() { + p.requestEnded(started, id, method, false, trace, err) + if withdrawn { + p.cancelRequest(id, trace) + } + }) + } + return &admittedCall{await: func(result any) error { + defer finish() + cancelRemote, err := awaitReply(ctx, reply, p.done, p.Err, result) + complete(err, cancelRemote) + return err + }, withdraw: func() { + cancel() + // A routed cancel occupies a position in this wire's send order. Its + // observation and best-effort admission finish before the next frame. + complete(context.Canceled, true) + }}, nil +} + +// awaitReply is the request primitive shared by carrier calls and relative +// wires. A wire changes where a request is dispatched, not how it completes. +func awaitReply(ctx context.Context, reply <-chan pendingResult, done <-chan struct{}, ended func() error, result any) (bool, error) { + select { + case r := <-reply: + if r.err != nil { + return false, r.err + } + if result == nil { + return false, nil + } + if err := json.Unmarshal(r.result, result); err != nil { + return false, fmt.Errorf("decode duplex result: %w", err) + } + return false, nil + case <-ctx.Done(): + return true, ctx.Err() + case <-done: + return false, ended() + } +} + +// Cancellation is best effort; a congested transport must not extend the +// caller's already-expired deadline while waiting to send its cancellation. +func (p *Peer) cancelRequest(id string, trace Trace) { + f := frame{Version: 1, Kind: "cancel", ID: id, Traceparent: trace.Parent, Tracestate: trace.State} + data, err := MarshalJSON(f) + if err != nil || int64(len(data)) > p.options.MaxFrameBytes { + return + } + select { + case <-p.done: + return + default: + } + select { + case p.outputs <- queuedFrame{data: data, frame: f}: + default: + } +} + +// Emit queues an event. Success means queued for this connection, not persisted +// or processed by the remote application. +func (p *Peer) Emit(ctx context.Context, event string, data any) error { + return p.emitTrace(ctx, event, data, nil, false) +} + +func (p *Peer) emitTrace(ctx context.Context, event string, data any, carried *Trace, immediate bool) error { + if ctx == nil || event == "" { + return Unpublished(errors.New("duplex event requires context and name")) + } + encoded, err := MarshalJSON(data) + if err != nil { + return Unpublished(err) + } + var trace Trace + if carried != nil { + trace = *carried + } else { + trace = p.options.Propagator.Inject(ctx) + } + f := frame{Version: 1, Kind: "event", Event: event, Data: encoded, + Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)} + // The application emitted it here; the frame carrying it is sent when the + // queue takes it, which is one event of its own and may not happen at all. + p.observeEmitted(f) + return Unpublished(p.enqueueFrame(ctx, f, immediate)) +} + +func (p *Peer) enqueue(ctx context.Context, f frame) error { + return p.enqueueFrame(ctx, f, false) +} + +func (p *Peer) enqueueFrame(ctx context.Context, f frame, immediate bool) error { + if err := ctx.Err(); err != nil { + return err + } + data, err := MarshalJSON(f) + if err != nil { + return err + } + if int64(len(data)) > p.options.MaxFrameBytes { + return errors.New("duplex frame exceeds size limit") + } + select { + case <-p.done: + return p.Err() + default: + } + select { + case p.outputs <- queuedFrame{data: data, frame: f}: + return nil + default: + } + if !immediate { + // Public Peer producers pace a transient burst for one write deadline. + // Their own cancellation withdraws only this unadmitted frame. Wire + // dispatch instead requires an immediate handoff so a composition does + // not run at its slowest destination's pace. + p.observeBackpressure(len(p.outputs), false, p.options.WriteTimeout) + timer := time.NewTimer(p.options.WriteTimeout) + defer timer.Stop() + select { + case p.outputs <- queuedFrame{data: data, frame: f}: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-p.done: + return p.Err() + case <-timer.C: + } + } + p.observeBackpressure(len(p.outputs), true, p.options.WriteTimeout) + p.fail(ErrBackpressure) + return ErrBackpressure +} + +func (p *Peer) writeLoop() { + for { + select { + case <-p.done: + return + case queued := <-p.outputs: + // The one place a send is observed, and before the bytes leave: a + // reply cannot be read, let alone observed, ahead of the frame.sent + // of the request that drew it. + p.observeSent(queued.frame, len(queued.data)) + ctx, cancel := context.WithTimeout(p.ctx, p.options.WriteTimeout) + err := p.conn.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: queued.data}) + cancel() + if err != nil { + p.fail(err) + return + } + } + } +} + +func (p *Peer) readLoop() { + for { + received, err := p.conn.Receive(p.ctx) + if err != nil { + p.fail(err) + return + } + if received.Kind != duplex.Text { + p.refuse(errors.New("duplex requires JSON text frames")) + return + } + if int64(len(received.Data)) > p.options.MaxFrameBytes { + p.refuse(errors.New("duplex frame exceeds size limit")) + return + } + f, err := decodeFrame(received.Data) + if err != nil { + p.refuse(err) + return + } + if f.ID != "" { + prefix := p.remotePrefix + if f.Kind == "response" { + prefix = p.prefix + } + if !validID(f.ID, prefix) { + p.refuse(errors.New("invalid duplex request identifier")) + return + } + // Only a request advances the mark: a response or a control names + // a serial that was taken before it. + if f.Kind == "request" && !p.admit(f.ID, prefix) { + p.refuse(errors.New("duplex request serial did not increase")) + return + } + } + p.observeReceived(f, len(received.Data)) + switch f.Kind { + case "response": + p.mu.Lock() + reply := p.pending[f.ID] + delete(p.pending, f.ID) + p.mu.Unlock() + if reply != nil { + r := pendingResult{result: f.Result} + if f.Error != nil { + r.err = f.Error + } + reply <- r + } + case "cancel": + p.mu.Lock() + cancel := p.incoming[f.ID] + p.mu.Unlock() + if cancel != nil { + cancel() + } + case "request": + p.startRequest(f) + case "event": + if !p.enqueueEvent(queuedEvent{event: Event{Name: f.Event, Data: f.Data}, trace: Trace{Parent: f.Traceparent, State: f.Tracestate}, meta: f.Meta}) { + return + } + } + } +} + +func (p *Peer) enqueueEvent(event queuedEvent) bool { + select { + case p.events <- event: + return true + default: + } + // A full queue can be a healthy transient burst โ€” a tight decoder loop + // outrunning a ready consumer โ€” so the producer is paced for one write + // deadline before the consumer is declared stalled, as the outgoing queue + // does. The producer here is the remote, and the only way to pace it is to + // stop reading: while this waits, responses and cancellations on this + // connection wait with it. That is the cost of not ending a connection + // that would drain in a second, and the deadline is what bounds it. + p.observeBackpressure(len(p.events), false, p.options.WriteTimeout) + timer := time.NewTimer(p.options.WriteTimeout) + defer timer.Stop() + select { + case p.events <- event: + return true + case <-p.done: + return false + case <-timer.C: + p.observeBackpressure(len(p.events), true, p.options.WriteTimeout) + p.fail(ErrBackpressure) + return false + } +} + +// maxRequestSerial is the largest serial a sender publishes. A sender that +// would wrap refuses and ends the connection instead, since a wrapped serial +// would name an invocation the receiver has already seen. +const maxRequestSerial = uint64(1)<<63 - 1 + +// admit holds an incoming request to the profile's publication order: within +// one connection instance and one direction, each request's serial is greater +// than every request's published before it. Gaps are allowed. +func (p *Peer) admit(id, prefix string) bool { + serial, err := strconv.ParseUint(strings.TrimPrefix(id, prefix), 10, 64) + if err != nil || serial == 0 { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + if serial <= p.admitted { + return false + } + p.admitted = serial + return true +} + +func validID(id, prefix string) bool { + if !strings.HasPrefix(id, prefix) { + return false + } + n := strings.TrimPrefix(id, prefix) + if n == "" || n[0] == '0' { + return false + } + for _, r := range n { + if r < '0' || r > '9' { + return false + } + } + return len(n) <= 20 +} + +// validTraceparent holds a traceparent to the one form W3C Trace Context gives +// it: version, trace id, parent id and flags, lower-case hexadecimal, dashed. +func validTraceparent(value string) bool { + if len(value) != 55 || value[2] != '-' || value[35] != '-' || value[52] != '-' { + return false + } + for i := 0; i < len(value); i++ { + if i == 2 || i == 35 || i == 52 { + continue + } + if c := value[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func (p *Peer) startRequest(f frame) { + p.mu.Lock() + if _, exists := p.incoming[f.ID]; exists { + p.mu.Unlock() + p.refuse(errors.New("duplicate active duplex request identifier")) + return + } + handler := p.handlers[f.Method] + fallback := p.requestFallback + p.mu.Unlock() + if fallback != nil { + if attached := fallback(f.Method); attached != nil { + handler = attached + } + } + // A response carries its request's trace, whether a handler ran or not. + trace := Trace{Parent: f.Traceparent, State: f.Tracestate} + // A request this peer refuses for want of a method or of a slot is still a + // request it began: what starts is what ends, and the refusal is the outcome. + started := p.requestStarted(f.ID, f.Method, true, trace) + if handler == nil { + refusal := &PublicError{Code: "method_not_found", Message: "Unknown method"} + p.requestEnded(started, f.ID, f.Method, true, trace, refusal) + p.rejectRequest(f.ID, trace, refusal) + return + } + select { + case p.slots <- struct{}{}: + default: + refusal := &PublicError{Code: "busy", Message: "Too many concurrent requests"} + p.requestEnded(started, f.ID, f.Method, true, trace, refusal) + p.rejectRequest(f.ID, trace, refusal) + return + } + // What the handler sends is a child of the request that ran it, and carries + // the request's meta only where the handler says so: a trace is the peer's + // to propagate, a carriage the consumer's. + handling := withIncomingMeta(p.options.Propagator.Extract(p.ctx, trace), f.Meta) + ctx, cancel := context.WithTimeout(handling, p.options.RequestTimeout) + ctx = context.WithValue(ctx, wireFrameKey{}, f) + p.mu.Lock() + p.incoming[f.ID] = cancel + p.mu.Unlock() + var answered sync.Once + respond := func(result any, err error) { + answered.Do(func() { + // The deadline has already won when it releases the body, even if + // that body beats the asynchronous deadline callback to this once. + // Explicit withdrawal still preserves a later public refusal. + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + result, err = nil, context.DeadlineExceeded + } + p.requestEnded(started, f.ID, f.Method, true, trace, err) + p.respond(f.ID, trace, result, err) + }) + } + // A receiver deadline settles the response, but cannot retire work that + // ignores cancellation. Explicit withdrawal still waits for the body. + stopDeadline := context.AfterFunc(ctx, func() { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + respond(nil, ctx.Err()) + } + }) + go func() { + defer func() { cancel(); p.mu.Lock(); delete(p.incoming, f.ID); p.mu.Unlock(); <-p.slots }() + defer stopDeadline() + result, err := invokeHandler(ctx, p, handler, f) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } + // Completion answers once, unless the receiver deadline already did. + respond(result, err) + }() +} + +// Rejections run on the reader because they do not consume handler slots. They +// must never wait for outbound capacity: that could hold up a response needed by +// an already active reverse call. A flood exhausting the rejection capacity +// closes the overloaded connection after giving the writer a scheduling turn. +func (p *Peer) rejectRequest(id string, trace Trace, public *PublicError) { + f := frame{Version: 1, Kind: "response", ID: id, Error: public, + Traceparent: trace.Parent, Tracestate: trace.State} + data, err := MarshalJSON(f) + if err != nil || int64(len(data)) > p.options.MaxFrameBytes { + p.fail(errors.New("duplex rejection exceeds frame limit")) + return + } + select { + case p.outputs <- queuedFrame{data: data, frame: f}: + return + case <-p.done: + return + default: + } + runtime.Gosched() + select { + case p.outputs <- queuedFrame{data: data, frame: f}: + case <-p.done: + default: + p.fail(ErrBackpressure) + } +} + +// invokeHandler takes the whole frame so that a panic is reported as what the +// handler was called for, never as what it was called with. +func invokeHandler(ctx context.Context, p *Peer, h Handler, f frame) (result any, err error) { + defer func() { + if value := recover(); value != nil { + p.observePanic(f, value) + err = errors.New("duplex handler panic") + } + }() + return h(ctx, p, f.Params) +} + +func (p *Peer) respond(id string, trace Trace, result any, err error) { + f := frame{Version: 1, Kind: "response", ID: id, Traceparent: trace.Parent, Tracestate: trace.State} + if err != nil { + var public *PublicError + switch { + case errors.As(err, &public) && public != nil && public.Code != "" && public.Message != "": + f.Error = public + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + f.Error = &PublicError{Code: "cancelled", Message: "Request cancelled"} + default: + f.Error = &PublicError{Code: "internal", Message: "Internal error"} + } + } else { + f.Result, err = MarshalJSON(result) + if err != nil { + f.Error = &PublicError{Code: "internal", Message: "Internal error"} + f.Result = nil + } + } + if err := p.enqueue(p.ctx, f); err != nil && p.ctx.Err() == nil { + // An oversized/unencodable result cannot leave the remote call hanging. + fallback := frame{Version: 1, Kind: "response", ID: id, Error: &PublicError{Code: "internal", Message: "Response could not be encoded"}, + Traceparent: trace.Parent, Tracestate: trace.State} + if retryErr := p.enqueue(p.ctx, fallback); retryErr != nil { + p.fail(retryErr) + } + } +} + +func (p *Peer) eventLoop() { + for { + select { + case <-p.done: + return + case queued := <-p.events: + event := queued.event + ctx := withIncomingMeta(p.options.Propagator.Extract(p.ctx, queued.trace), queued.meta) + ctx = context.WithValue(ctx, wireFrameKey{}, frame{Version: 1, Kind: "event", Event: event.Name, Data: event.Data, + Traceparent: queued.trace.Parent, Tracestate: queued.trace.State, Meta: queued.meta}) + p.observeDelivered(queued) + p.mu.Lock() + handler := p.eventHandlers[event.Name] + fallback := p.eventFallback + listeners := make([]func(context.Context, Event), 0, len(p.listeners)) + for _, l := range p.listeners { + listeners = append(listeners, l) + } + p.mu.Unlock() + if fallback != nil { + if attached := fallback(event.Name); attached != nil { + handler = attached + } + } + func() { + defer func() { + if recover() != nil { + p.fail(errors.New("duplex event handler panic")) + } + }() + if handler != nil { + handler(ctx, p, event.Data) + } + for _, listener := range listeners { + listener(ctx, event) + } + }() + } + } +} + +func decodeFrame(data []byte) (frame, error) { + var f frame + if err := scalarjson.Raw(data); err != nil { + return f, err + } + // Validate members separately so duplicate fields and explicit members from + // another frame kind cannot disappear into Go zero values while decoding. + fields, err := frameMembers(data) + if err != nil { + return f, err + } + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&f); err != nil { + return f, fmt.Errorf("invalid duplex frame: %w", err) + } + if err := d.Decode(new(any)); err != io.EOF { + return f, errors.New("invalid trailing duplex frame content") + } + if f.Version != 1 { + return f, errors.New("unsupported duplex frame version") + } + valid := false + allowed := map[string]bool{"version": true, "kind": true, "traceparent": true, "tracestate": true} + switch f.Kind { + case "request": + allowed["id"], allowed["method"], allowed["params"], allowed["meta"] = true, true, true, true + valid = f.ID != "" && f.Method != "" && len(f.Params) > 0 && len(f.Result) == 0 && f.Error == nil && f.Event == "" && len(f.Data) == 0 + case "response": + allowed["id"], allowed["result"], allowed["error"] = true, true, true + valid = f.ID != "" && f.Method == "" && len(f.Params) == 0 && (len(f.Result) > 0) != (f.Error != nil) && f.Event == "" && len(f.Data) == 0 && f.Meta == nil + _, hasResult := fields["result"] + _, hasError := fields["error"] + valid = valid && hasResult != hasError + case "event": + allowed["event"], allowed["data"], allowed["meta"] = true, true, true + valid = f.ID == "" && f.Method == "" && len(f.Params) == 0 && len(f.Result) == 0 && f.Error == nil && f.Event != "" && len(f.Data) > 0 + case "cancel": + allowed["id"] = true + valid = f.ID != "" && f.Method == "" && len(f.Params) == 0 && len(f.Result) == 0 && f.Error == nil && f.Event == "" && len(f.Data) == 0 && f.Meta == nil + } + for name := range fields { + if !allowed[name] { + valid = false + } + } + if f.Error != nil && (f.Error.Code == "" || f.Error.Message == "") { + valid = false + } + // A trace the peer cannot read is a trace it would carry wrongly; tracestate + // has no form of its own and travels alone when an intermediary strips one. + if _, traced := fields["traceparent"]; traced && !validTraceparent(f.Traceparent) { + valid = false + } + // Meta maps names to strings and may be empty; keys under the reserved + // prefix are the profile's to define and it defines none in this version, + // so a frame carrying one is refused rather than read as a consumer's. + if raw, carried := fields["meta"]; carried && !validMeta(raw) { + valid = false + } + if !valid { + return f, errors.New("invalid duplex frame shape") + } + return f, nil +} + +func frameMembers(data []byte) (map[string]json.RawMessage, error) { + d := json.NewDecoder(bytes.NewReader(data)) + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return nil, errors.New("duplex frame must be an object") + } + members := make(map[string]json.RawMessage) + for d.More() { + key, err := d.Token() + if err != nil { + return nil, err + } + name, ok := key.(string) + if !ok { + return nil, errors.New("invalid duplex field name") + } + if _, exists := members[name]; exists { + return nil, fmt.Errorf("duplicate duplex field %q", name) + } + var value json.RawMessage + if err := d.Decode(&value); err != nil { + return nil, err + } + members[name] = value + } + if _, err := d.Token(); err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, errors.New("invalid trailing duplex frame content") + } + return members, nil +} diff --git a/engine/go/peer_pacing_test.go b/engine/go/peer_pacing_test.go new file mode 100644 index 0000000..5d2d705 --- /dev/null +++ b/engine/go/peer_pacing_test.go @@ -0,0 +1,141 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + ws "github.com/Bitspark/nightseam/runtime/go" +) + +type pacedOutputObserver struct { + pressure chan ws.Backpressure +} + +func (o *pacedOutputObserver) Observe(event ws.ObserverEvent) { + if pressure, ok := event.(ws.Backpressure); ok { + o.pressure <- pressure + } +} + +func TestPublicPeerFullOutputPacesUntilCallerCancellation(t *testing.T) { + for _, operation := range []string{"event", "request"} { + t.Run(operation, func(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + observed := &pacedOutputObserver{pressure: make(chan ws.Backpressure, 4)} + _, destination := delayedWritePair(t, control, ws.Options{ + QueueCapacity: 1, + WriteTimeout: 5 * time.Second, + Observer: observed, + }, ws.Options{}) + if err := destination.Emit(context.Background(), "first", 1); err != nil { + t.Fatal(err) + } + receive(t, control.started) + if err := destination.Emit(context.Background(), "second", 2); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { + if operation == "event" { + finished <- destination.Emit(ctx, "cancelled", 3) + } else { + finished <- destination.Call(ctx, "cancelled", 3, nil) + } + }() + // Pressure is an admission barrier: the caller is waiting while the + // accepted write and one queued frame remain held by the consumer. + pressure := receive(t, observed.pressure) + if pressure.Stalled || pressure.Queued != 1 { + t.Fatalf("full public queue = %+v, want pacing at capacity", pressure) + } + cancel() + err := receive(t, finished) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled producer = %v, want caller cancellation", err) + } + wantUnpublished(t, err, true) + if err := destination.Err(); err != nil { + t.Fatalf("cancelling an unadmitted producer ended its carrier: %v", err) + } + }) + } +} + +func TestPublicPeerFullOutputResumesWhenConsumerDrains(t *testing.T) { + for _, operation := range []string{"event", "request"} { + t.Run(operation, func(t *testing.T) { + release := make(chan struct{}) + var once sync.Once + allowWrites := func() { once.Do(func() { close(release) }) } + defer allowWrites() + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + observed := &pacedOutputObserver{pressure: make(chan ws.Backpressure, 4)} + prefix := make(chan int, 3) + _, destination := delayedWritePair(t, control, ws.Options{ + QueueCapacity: 1, + WriteTimeout: 5 * time.Second, + Observer: observed, + }, ws.Options{ + Handlers: map[string]ws.Handler{ + "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { return data, nil }, + }, + Events: map[string]ws.EventHandler{ + "item": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { + var item int + if err := json.Unmarshal(data, &item); err != nil { + t.Error(err) + } + prefix <- item + }, + }, + }) + if err := destination.Emit(context.Background(), "item", 1); err != nil { + t.Fatal(err) + } + receive(t, control.started) + if err := destination.Emit(context.Background(), "item", 2); err != nil { + t.Fatal(err) + } + finished := make(chan error, 1) + go func() { + if operation == "event" { + finished <- destination.Emit(context.Background(), "item", 3) + } else { + var result int + err := destination.Call(context.Background(), "echo", 3, &result) + if err == nil && result != 3 { + t.Errorf("resumed request result = %d, want 3", result) + } + finished <- err + } + }() + if pressure := receive(t, observed.pressure); pressure.Stalled { + t.Fatalf("transient full public queue was declared stalled: %+v", pressure) + } + allowWrites() + if err := receive(t, finished); err != nil { + t.Fatalf("resumed producer = %v", err) + } + count := 2 + if operation == "event" { + count = 3 + } + for want := 1; want <= count; want++ { + if got := receive(t, prefix); got != want { + t.Fatalf("accepted prefix = %d, want %d", got, want) + } + } + if err := destination.Err(); err != nil { + t.Fatalf("resumed producer ended its carrier: %v", err) + } + }) + } +} diff --git a/engine/go/peer_test.go b/engine/go/peer_test.go new file mode 100644 index 0000000..1204dc4 --- /dev/null +++ b/engine/go/peer_test.go @@ -0,0 +1,612 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" + "github.com/coder/websocket" +) + +func receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case value := <-channel: + return value + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for peer activity") + var zero T + return zero + } +} + +func newPair(t *testing.T, serverOptions, clientOptions ws.Options) (*ws.Peer, *ws.Peer) { + t.Helper() + connected := make(chan *ws.Peer, 1) + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: serverOptions, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *ws.Peer) { connected <- peer }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Options: clientOptions}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + remote := receive(t, connected) + t.Cleanup(func() { _ = remote.Close() }) + return client, remote +} + +// rawPeer is one peer reached over a raw connection, so a test spells the +// frames it sends byte for byte rather than letting a peer encode them. +func rawPeer(t *testing.T, options ws.Options) (*ws.Peer, *websocket.Conn, context.Context) { + t.Helper() + connected := make(chan *ws.Peer, 1) + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: options, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *ws.Peer) { connected <- peer }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + conn, _, err := websocket.Dial(ctx, server.URL, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.CloseNow() }) + return receive(t, connected), conn, ctx +} + +func readFrame(ctx context.Context, t *testing.T, conn *websocket.Conn) map[string]json.RawMessage { + t.Helper() + kind, data, err := conn.Read(ctx) + if err != nil || kind != websocket.MessageText { + t.Fatalf("read frame: kind=%v error=%v", kind, err) + } + var members map[string]json.RawMessage + if err := json.Unmarshal(data, &members); err != nil { + t.Fatalf("decode frame %s: %v", data, err) + } + return members +} + +func TestReverseCallCompletesWhileOriginalRequestIsOutstanding(t *testing.T) { + client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + var response int + if err := peer.Call(ctx, "reverse", 6, &response); err != nil { + return nil, err + } + return response + 1, nil + }, + "inner": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { + var value int + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return value * 7, nil + }, + }}, ws.Options{Handlers: map[string]ws.Handler{ + "reverse": func(ctx context.Context, peer *ws.Peer, data json.RawMessage) (any, error) { + var response int + err := peer.Call(ctx, "inner", data, &response) + return response, err + }, + }}) + var result int + if err := client.Call(context.Background(), "outer", nil, &result); err != nil { + t.Fatal(err) + } + if result != 43 { + t.Fatalf("nested duplex result = %d, want 43", result) + } +} + +func TestEventsTravelInBothDirections(t *testing.T) { + clientEvents, serverEvents := make(chan string, 2), make(chan string, 2) + eventHandler := func(output chan<- string) ws.EventHandler { + return func(_ context.Context, _ *ws.Peer, data json.RawMessage) { output <- string(data) } + } + client, server := newPair(t, + ws.Options{Events: map[string]ws.EventHandler{"progress": eventHandler(serverEvents)}}, + ws.Options{Events: map[string]ws.EventHandler{"progress": eventHandler(clientEvents)}}) + observed := make(chan ws.Event, 1) + unsubscribe := client.OnEvent(func(_ context.Context, event ws.Event) { observed <- event }) + for _, value := range []int{1, 2} { + if err := client.Emit(context.Background(), "progress", value); err != nil { + t.Fatal(err) + } + } + if err := server.Emit(context.Background(), "progress", "done"); err != nil { + t.Fatal(err) + } + if got := receive(t, serverEvents); got != "1" { + t.Fatalf("first server event = %s", got) + } + if got := receive(t, serverEvents); got != "2" { + t.Fatalf("second server event = %s", got) + } + if got := receive(t, clientEvents); got != `"done"` { + t.Fatalf("client event = %s", got) + } + if got := receive(t, observed); got.Name != "progress" || string(got.Data) != `"done"` { + t.Fatalf("observed event = %+v", got) + } + unsubscribe() + unsubscribe() +} + +func TestPublicErrorsArePreservedAndInternalFailuresHidden(t *testing.T) { + client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "public": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { + return nil, fmt.Errorf("wrapper: %w", &ws.PublicError{Code: "conflict", Message: "Changed", Data: json.RawMessage(`{"revision":3}`)}) + }, + "private": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { + return nil, errors.New("private database password") + }, + "panic": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { + panic("private panic details") + }, + "unencodable": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { + return make(chan int), nil + }, + "ok": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return true, nil }, + }}, ws.Options{}) + for _, test := range []struct{ method, code, message string }{ + {"public", "conflict", "Changed"}, + {"private", "internal", "Internal error"}, + {"panic", "internal", "Internal error"}, + {"unencodable", "internal", "Internal error"}, + {"missing", "method_not_found", "Unknown method"}, + } { + t.Run(test.method, func(t *testing.T) { + err := client.Call(context.Background(), test.method, nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != test.code || public.Message != test.message { + t.Fatalf("error = %v, want %s: %s", err, test.code, test.message) + } + if test.method == "public" && string(public.Data) != `{"revision":3}` { + t.Fatalf("public error data = %s", public.Data) + } + if strings.Contains(err.Error(), "private") { + t.Fatalf("internal error leaked: %v", err) + } + }) + } + var result bool + if err := client.Call(context.Background(), "ok", nil, &result); err != nil || !result { + t.Fatalf("connection did not survive handler failure: result=%v err=%v", result, err) + } +} + +func TestCallerCancellationReachesRemoteHandler(t *testing.T) { + started, cancelled := make(chan struct{}), make(chan error, 1) + client, server := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + cancelled <- ctx.Err() + return nil, ctx.Err() + }, + }}, ws.Options{}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + returned := make(chan error, 1) + go func() { returned <- client.Call(ctx, "wait", nil, nil) }() + receive(t, started) + cancel() + if err := receive(t, returned); !errors.Is(err, context.Canceled) { + t.Fatalf("caller result = %v", err) + } + if err := receive(t, cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("handler context = %v", err) + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("request cancellation closed connection: client=%v server=%v", client.Err(), server.Err()) + } +} + +func TestSaturationRejectsNewWorkButStillRoutesReverseResponses(t *testing.T) { + reverseStarted, release := make(chan struct{}), make(chan struct{}) + client, _ := newPair(t, ws.Options{ + MaxConcurrentHandlers: 1, + Handlers: map[string]ws.Handler{ + "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + var result string + err := peer.Call(ctx, "reverse", nil, &result) + return result, err + }, + "extra": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return "unexpected", nil }, + }, + }, ws.Options{Handlers: map[string]ws.Handler{ + "reverse": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + close(reverseStarted) + select { + case <-release: + return "released", nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + }}) + type callResult struct { + value string + err error + } + returned := make(chan callResult, 1) + go func() { + var value string + err := client.Call(context.Background(), "outer", nil, &value) + returned <- callResult{value, err} + }() + receive(t, reverseStarted) + err := client.Call(context.Background(), "extra", nil, nil) + var public *ws.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("saturated request returned %v, want busy", err) + } + close(release) + if got := receive(t, returned); got.err != nil || got.value != "released" { + t.Fatalf("pending reverse response was blocked by handler saturation: %+v", got) + } +} + +func TestStalledEventConsumerDisconnects(t *testing.T) { + started := make(chan struct{}) + // A stalled consumer is paced for one write deadline before it is + // disconnected; the deadline is short here so the pacing is not the wait. + client, server := newPair(t, ws.Options{}, ws.Options{ + QueueCapacity: 1, + WriteTimeout: 200 * time.Millisecond, + Events: map[string]ws.EventHandler{ + "progress": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) { + close(started) + <-ctx.Done() + }, + }, + }) + if err := server.Emit(context.Background(), "progress", 1); err != nil { + t.Fatal(err) + } + receive(t, started) + for _, value := range []int{2, 3} { + if err := server.Emit(context.Background(), "progress", value); err != nil { + t.Fatal(err) + } + } + receive(t, client.Done()) + if !errors.Is(client.Err(), ws.ErrBackpressure) { + t.Fatalf("stalled event consumer error = %v", client.Err()) + } + receive(t, server.Done()) +} + +func TestDisconnectCancelsHandlersAndRejectsPendingCalls(t *testing.T) { + started, stopped := make(chan struct{}), make(chan struct{}) + client, server := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ + "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(stopped) + return nil, ctx.Err() + }, + }}, ws.Options{}) + returned := make(chan error, 1) + go func() { returned <- client.Call(context.Background(), "wait", nil, nil) }() + receive(t, started) + _ = server.Close() + receive(t, stopped) + if err := receive(t, returned); err == nil { + t.Fatal("pending call succeeded after disconnect") + } + receive(t, client.Done()) +} + +func TestServerRequiresAndEnforcesAuthenticationAndOriginPolicies(t *testing.T) { + authenticate := func(r *http.Request) (context.Context, error) { return r.Context(), nil } + allowOrigin := func(*http.Request) bool { return true } + for _, options := range []ws.ServerOptions{{}, {Authenticate: authenticate}, {CheckOrigin: allowOrigin}} { + if _, err := ws.NewHandler(options); err == nil { + t.Fatal("server accepted missing explicit policy") + } + } + type userKey struct{} + handler, err := ws.NewHandler(ws.ServerOptions{ + Authenticate: func(r *http.Request) (context.Context, error) { + if r.Header.Get("Authorization") != "Bearer valid" { + return nil, errors.New("private authentication failure") + } + return context.WithValue(r.Context(), userKey{}, "alice"), nil + }, + CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://allowed.example" }, + Options: ws.Options{Handlers: map[string]ws.Handler{ + "identity": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + return ctx.Value(userKey{}), nil + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + for _, test := range []struct { + origin, authorization string + status int + }{ + {"https://denied.example", "Bearer valid", http.StatusForbidden}, + {"https://allowed.example", "Bearer wrong", http.StatusUnauthorized}, + } { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + peer, response, err := ws.Dial(ctx, server.URL, ws.DialOptions{HTTPHeader: http.Header{ + "Origin": {test.origin}, "Authorization": {test.authorization}, + }}) + cancel() + if peer != nil { + _ = peer.Close() + } + if err == nil || response == nil || response.StatusCode != test.status { + t.Fatalf("rejected handshake = peer %v, response %v, error %v; want %d", peer, response, err, test.status) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{HTTPHeader: http.Header{ + "Origin": {"https://allowed.example"}, "Authorization": {"Bearer valid"}, + }}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var identity string + if err := client.Call(ctx, "identity", nil, &identity); err != nil || identity != "alice" { + t.Fatalf("authenticated identity = %q, error=%v", identity, err) + } +} + +func TestMalformedWireFramesDisconnect(t *testing.T) { + // Each row is a frame that would be served but for the one member named: + // a traceparent of another form is refused as any other malformed frame is. + for _, data := range []string{ + `{"version":2,"kind":"event","event":"progress","data":1}`, + `{"version":1,"kind":"request","id":"s:1","method":"wait","params":null}`, + `{"version":1,"kind":"response","id":"s:1","result":null,"error":{"code":"bad","message":"bad"}}`, + `{"version":1,"kind":"event","event":"progress","data":1,"extra":true}`, + `{"version":1,"kind":"event","event":"progress","data":1,"traceparent":"nonsense"}`, + `{"version":1,"kind":"request","id":"c:1","method":"wait","params":{},"traceparent":"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01"}`, + `{"version":1,"kind":"request","id":"c:1","method":"wait","params":{},"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99"}`, + `{"version":1,"kind":"cancel","id":"c:1","traceparent":""}`, + } { + t.Run(data, func(t *testing.T) { + peer, conn, ctx := rawPeer(t, ws.Options{}) + if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { + t.Fatal(err) + } + receive(t, peer.Done()) + if peer.Err() == nil { + t.Fatal("malformed frame closed without error") + } + }) + } +} + +// The members are optional on every kind and the peer emits none of its own: +// what a frame carries it carries past the decoder, and the frame is served. +func TestTraceContextTravelsOnEveryFrameKind(t *testing.T) { + const trace = `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"congo=t61rcWkgMzE"` + events, cancelled := make(chan string, 2), make(chan struct{}) + peer, conn, ctx := rawPeer(t, ws.Options{ + Events: map[string]ws.EventHandler{ + "progress": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { events <- string(data) }, + }, + Handlers: map[string]ws.Handler{ + "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { + var answer string + if err := peer.Call(ctx, "reverse", nil, &answer); err != nil { + return nil, err + } + return answer, nil + }, + "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + <-ctx.Done() + close(cancelled) + return nil, ctx.Err() + }, + }, + }) + write := func(data string) { + t.Helper() + if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { + t.Fatal(err) + } + } + write(`{"version":1,"kind":"event","event":"progress","data":1,` + trace + `}`) + if got := receive(t, events); got != "1" { + t.Fatalf("traced event = %s", got) + } + // A traced request is served, and the response to the reverse call it makes + // is itself traced: both kinds cross the decoder in one exchange. + write(`{"version":1,"kind":"request","id":"c:1","method":"outer","params":{},` + trace + `}`) + reverse := readFrame(ctx, t, conn) + if string(reverse["method"]) != `"reverse"` { + t.Fatalf("reverse request = %v", reverse) + } + write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back",` + trace + `}`) + if response := readFrame(ctx, t, conn); string(response["id"]) != `"c:1"` || string(response["result"]) != `"back"` { + t.Fatalf("response to traced request = %v", response) + } + // An intermediary may strip one member and not the other. + write(`{"version":1,"kind":"event","event":"progress","data":2,"tracestate":"congo=t61rcWkgMzE"}`) + if got := receive(t, events); got != "2" { + t.Fatalf("event carrying tracestate alone = %s", got) + } + write(`{"version":1,"kind":"request","id":"c:2","method":"wait","params":{},` + trace + `}`) + write(`{"version":1,"kind":"cancel","id":"c:2",` + trace + `}`) + receive(t, cancelled) + if peer.Err() != nil { + t.Fatalf("a traced frame closed the connection: %v", peer.Err()) + } +} + +// TestSubprotocolNegotiation holds what the handshake selected against what +// both peers report, and the profile is spoken over the connection either +// way: nothing about it turns on a subprotocol. +func TestSubprotocolNegotiation(t *testing.T) { + // The ticket case: a browser can carry one nowhere but in the offer, and + // accepts the handshake only if it comes back unchanged. + ticket := func(_ *http.Request, offered []string) string { + for _, token := range offered { + if strings.HasPrefix(token, "ticket.") { + return token + } + } + return "" + } + for _, test := range []struct { + name string + server ws.ServerOptions + offer []string + selected string + }{ + {name: "both name it", server: ws.ServerOptions{Subprotocols: []string{"a", "b"}}, offer: []string{"b"}, selected: "b"}, + {name: "the offer meets none of them", server: ws.ServerOptions{Subprotocols: []string{"a", "b"}}, offer: []string{"c"}}, + {name: "the server names none", offer: []string{"a"}}, + {name: "neither side names one", server: ws.ServerOptions{}}, + {name: "a ticket is selected back unchanged", server: ws.ServerOptions{SelectSubprotocol: ticket}, offer: []string{"ticket.4f9c", "a"}, selected: "ticket.4f9c"}, + {name: "the hook selects none", server: ws.ServerOptions{Subprotocols: []string{"a"}, SelectSubprotocol: ticket}, offer: []string{"a"}}, + } { + t.Run(test.name, func(t *testing.T) { + connected := make(chan *ws.Peer, 1) + options := test.server + options.Authenticate = func(r *http.Request) (context.Context, error) { return r.Context(), nil } + options.CheckOrigin = func(*http.Request) bool { return true } + options.OnConnect = func(peer *ws.Peer) { connected <- peer } + options.Options = ws.Options{Handlers: map[string]ws.Handler{ + "selected": func(_ context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { return peer.Subprotocol(), nil }, + }} + handler, err := ws.NewHandler(options) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Subprotocols: test.offer}) + if err != nil { + t.Fatalf("dial offering %v: %v", test.offer, err) + } + defer client.Close() + remote := receive(t, connected) + defer remote.Close() + if got := client.Subprotocol(); got != test.selected { + t.Fatalf("client subprotocol = %q, want %q", got, test.selected) + } + if got := remote.Subprotocol(); got != test.selected { + t.Fatalf("server subprotocol = %q, want %q", got, test.selected) + } + // And the connection carries the profile whatever was selected, + // which the server reads back over it. + var answer string + if err := client.Call(ctx, "selected", nil, &answer); err != nil { + t.Fatalf("call over a connection negotiating %q: %v", test.selected, err) + } + if answer != test.selected { + t.Fatalf("subprotocol a handler read = %q, want %q", answer, test.selected) + } + }) + } +} + +// TestSubprotocolIsNoneOverAnyOtherTransport: a peer that is not over a +// WebSocket negotiated nothing and says so. +func TestSubprotocolIsNoneOverAnyOtherTransport(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + clientConn, serverConn := duplex.Pipe(1 << 20) + client, err := ws.NewPeer(ctx, clientConn, ws.ClientRole, ws.Options{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + server, err := ws.NewPeer(ctx, serverConn, ws.ServerRole, ws.Options{}) + if err != nil { + t.Fatal(err) + } + defer server.Close() + if client.Subprotocol() != "" || server.Subprotocol() != "" { + t.Fatalf("subprotocols over a pipe = %q and %q", client.Subprotocol(), server.Subprotocol()) + } +} + +// TestDialRefusesAHandshakeThatNeverAnswers: ConnectTimeout is the Go twin of +// TypeScript's connectTimeoutMs โ€” a listener that takes the connection and +// never answers the upgrade is refused with the same code, connect_timeout, +// with nothing opened and the caller's own context untouched, the deadline +// having been the handshake's and not the connection's. A negative bound is +// refused before anything is dialled at all. +func TestDialRefusesAHandshakeThatNeverAnswers(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan struct{}) + defer close(done) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { <-done; conn.Close() }() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := "ws://" + listener.Addr().String() + started := time.Now() + peer, _, err := ws.Dial(ctx, url, ws.DialOptions{ConnectTimeout: 50 * time.Millisecond}) + if peer != nil { + t.Fatal("a dial past its bound opened a peer") + } + var refusal *ws.PublicError + if !errors.As(err, &refusal) || refusal.Code != "connect_timeout" { + t.Fatalf("dial error = %v, want the code connect_timeout", err) + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Fatalf("the dial waited %v past its 50ms bound", elapsed) + } + if ctx.Err() != nil { + t.Fatal("the handshake's deadline ended the caller's own context") + } + + if _, _, err := ws.Dial(ctx, url, ws.DialOptions{ConnectTimeout: -time.Second}); err == nil || !strings.Contains(err.Error(), "must not be negative") { + t.Fatalf("a negative connect timeout = %v, want a refusal", err) + } +} diff --git a/engine/go/prepare_test.go b/engine/go/prepare_test.go new file mode 100644 index 0000000..76bca78 --- /dev/null +++ b/engine/go/prepare_test.go @@ -0,0 +1,170 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" + ws "github.com/Bitspark/nightseam/runtime/go" + "github.com/Bitspark/nightseam/tunnel/go" + "github.com/coder/websocket" +) + +// A tunnel installed in Prepare is there before the peer has read anything, +// so the client's first channel.open โ€” the natural first act of a consumer +// that came for a channel โ€” meets a handler rather than method_not_found. +// The hook takes a millisecond here on purpose: with Prepare, how long the +// install takes cannot matter, because no frame is read while it runs. The +// same server with the same install in OnConnect refuses 868 of these 1000 +// opens; with the tunnel installed in Prepare, none. +func TestATunnelInstalledInPrepareMeetsTheFirstChannelOpen(t *testing.T) { + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: ws.Options{Prepare: func(peer *ws.Peer) error { + time.Sleep(time.Millisecond) + _, err := tunnel.New(peer, tunnel.Options{}) + return err + }}, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + iterations := 3000 + if testing.Short() { + iterations = 250 + } + for i := range iterations { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{}) + if err != nil { + cancel() + t.Fatalf("iteration %d: dial: %v", i, err) + } + carrier, err := tunnel.New(client, tunnel.Options{}) + if err != nil { + cancel() + t.Fatalf("iteration %d: tunnel: %v", i, err) + } + channel, err := carrier.OpenConnection(ctx, "probe", "") + if err != nil { + var public *ws.PublicError + if errors.As(err, &public) { + t.Fatalf("iteration %d: the first open was refused %s", i, public.Code) + } + t.Fatalf("iteration %d: the first open was refused: %v", i, err) + } + _ = channel.Close(ctx, duplex.CodeNormal, "") + _ = client.Close() + cancel() + } +} + +// Prepare is where a peer's own handlers go, and it holds the peer alone: +// Handle inside it registers on a peer nothing has reached yet. +func TestPrepareInstallsBeforeTheFirstFrameAndOnConnectSeesALivePeer(t *testing.T) { + order := make(chan string, 2) + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: ws.Options{Prepare: func(peer *ws.Peer) error { + order <- "prepare" + return peer.Handle("probe", func(context.Context, *ws.Peer, json.RawMessage) (any, error) { + return map[string]any{"ready": true}, nil + }) + }}, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(*ws.Peer) { order <- "connect" }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var answer struct { + Ready bool `json:"ready"` + } + if err := client.Call(ctx, "probe", map[string]any{}, &answer); err != nil || !answer.Ready { + t.Fatalf("the handler Prepare installed did not answer: %v", err) + } + if first, second := receive(t, order), receive(t, order); first != "prepare" || second != "connect" { + t.Fatalf("hooks ran %s then %s", first, second) + } +} + +// A Prepare that fails fails the construction, on each of the three +// constructors: nothing is returned that could read a frame. +func TestPrepareFailingFailsNewPeer(t *testing.T) { + refusal := errors.New("this peer serves nothing") + near, far := duplex.Pipe(1 << 20) + defer far.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + peer, err := ws.NewPeer(ctx, near, ws.ClientRole, ws.Options{Prepare: func(*ws.Peer) error { return refusal }}) + if !errors.Is(err, refusal) || peer != nil { + t.Fatalf("NewPeer answered peer=%v error=%v", peer, err) + } +} + +func TestPrepareFailingFailsAcceptAndClosesTheSocket(t *testing.T) { + refusal := errors.New("this server serves nothing") + accepted := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := ws.Accept(w, r, ws.ServerOptions{ + Options: ws.Options{Prepare: func(*ws.Peer) error { return refusal }}, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + }) + accepted <- err + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, server.URL, nil) + if err != nil { + t.Fatal(err) + } + defer conn.CloseNow() + // The upgrade was answered, so the client is told why the profile will + // not be spoken over it rather than left reading a socket in silence. + _, _, err = conn.Read(ctx) + var closed websocket.CloseError + if !errors.As(err, &closed) || closed.Code != websocket.StatusPolicyViolation { + t.Fatalf("the refused socket ended with %v", err) + } + if err := receive(t, accepted); !errors.Is(err, refusal) { + t.Fatalf("Accept answered %v", err) + } +} + +func TestPrepareFailingFailsDial(t *testing.T) { + refusal := errors.New("this client serves nothing") + handler, err := ws.NewHandler(ws.ServerOptions{ + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + peer, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Options: ws.Options{Prepare: func(*ws.Peer) error { return refusal }}}) + if !errors.Is(err, refusal) || peer != nil { + t.Fatalf("Dial answered peer=%v error=%v", peer, err) + } +} diff --git a/engine/go/seam_test.go b/engine/go/seam_test.go new file mode 100644 index 0000000..eb2374b --- /dev/null +++ b/engine/go/seam_test.go @@ -0,0 +1,236 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// TestPeerSpeaksTheProfileOverAnyConnection: two peers over an in-memory +// pipe, no socket anywhere, complete a call, a reverse call, an event and a +// cancellation, and a closed pipe ends both. The profile is written to the +// seam, not to a WebSocket. +func TestPeerSpeaksTheProfileOverAnyConnection(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + clientConn, serverConn := duplex.Pipe(1 << 20) + blocked := make(chan struct{}) + server, err := NewPeer(ctx, serverConn, ServerRole, Options{Handlers: map[string]Handler{ + "echo": func(ctx context.Context, p *Peer, raw json.RawMessage) (any, error) { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + var back string + if err := p.Call(ctx, "reverse", s, &back); err != nil { + return nil, err + } + return back, nil + }, + "block": func(ctx context.Context, p *Peer, raw json.RawMessage) (any, error) { + <-ctx.Done() + close(blocked) + return nil, ctx.Err() + }, + }}) + if err != nil { + t.Fatal(err) + } + defer server.Close() + observed := make(chan Event, 1) + client, err := NewPeer(ctx, clientConn, ClientRole, Options{ + Handlers: map[string]Handler{"reverse": func(ctx context.Context, p *Peer, raw json.RawMessage) (any, error) { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes), nil + }}, + Events: map[string]EventHandler{"changed": func(ctx context.Context, p *Peer, raw json.RawMessage) { + observed <- Event{Name: "changed", Data: raw} + }}, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + var result string + if err := client.Call(ctx, "echo", "seam", &result); err != nil { + t.Fatal(err) + } + if result != "maes" { + t.Fatalf("a call and its reverse call over the pipe returned %q", result) + } + if err := server.Emit(ctx, "changed", map[string]int{"count": 7}); err != nil { + t.Fatal(err) + } + select { + case event := <-observed: + if string(event.Data) != `{"count":7}` { + t.Fatalf("the event arrived as %s", event.Data) + } + case <-ctx.Done(): + t.Fatal("no event arrived over the pipe") + } + short, cancelShort := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancelShort() + if err := client.Call(short, "block", nil, nil); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("a cancelled call returned %v", err) + } + select { + case <-blocked: + case <-ctx.Done(): + t.Fatal("the cancellation never reached the handler over the pipe") + } + if err := clientConn.Close(ctx, duplex.CodeNormal, "done"); err != nil { + t.Fatal(err) + } + select { + case <-server.Done(): + case <-ctx.Done(): + t.Fatal("closing the connection did not end the server peer") + } + var closed *duplex.CloseError + if err := server.Err(); !errors.As(err, &closed) || closed.Code != duplex.CodeNormal || closed.Reason != "done" { + t.Fatalf("the server peer ended with %v, not the close it was sent", err) + } +} + +// TestPeerRefusesAFrameOverItsLimit: a connection whose maker set a laxer +// limit than the peer's still cannot hand the peer an oversized frame. +func TestPeerRefusesAFrameOverItsLimit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + near, far := duplex.Pipe(1 << 20) + peer, err := NewPeer(ctx, near, ClientRole, Options{MaxFrameBytes: 512}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + big := make([]byte, 600) + for i := range big { + big[i] = ' ' + } + copy(big, `{"version":1,"kind":"event","event":"e","data":1`) + big[len(big)-1] = '}' + if err := far.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: big}); err != nil { + t.Fatal(err) + } + select { + case <-peer.Done(): + case <-ctx.Done(): + t.Fatal("the peer accepted a frame over its limit") + } + if err := peer.Err(); err == nil || err.Error() != "duplex frame exceeds size limit" { + t.Fatalf("the peer ended with %v", err) + } +} + +// TestHowAPeerEndsAConnectionIsWhatItsObserverIsTold: the profile closes with +// 4011 and a reason when the other side broke it, so that a gateway or a +// proxy between the two has a code to act on; a close this side chose carries +// 1000; and a transport there is nothing to say over is aborted, which the +// far side reads as 1006. The observer is told the code the wire carried in +// every one of them and never one it did not. +func TestHowAPeerEndsAConnectionIsWhatItsObserverIsTold(t *testing.T) { + for _, c := range []struct { + name string + end func(ctx context.Context, cancel context.CancelFunc, peer *Peer, far duplex.Conn) + code duplex.Code + reason string + local bool + }{ + { + name: "a malformed frame is refused", + end: func(ctx context.Context, _ context.CancelFunc, _ *Peer, far duplex.Conn) { + _ = far.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte(`{"version":1,"kind":"event"}`)}) + }, + code: duplex.CodeDuplex, + reason: "invalid duplex frame shape", + local: true, + }, + { + name: "a frame of the wrong kind is refused", + end: func(ctx context.Context, _ context.CancelFunc, _ *Peer, far duplex.Conn) { + _ = far.Send(ctx, duplex.Frame{Kind: duplex.Binary, Data: []byte{0}}) + }, + code: duplex.CodeDuplex, + reason: "duplex requires JSON text frames", + local: true, + }, + { + name: "a close this side chose", + end: func(context.Context, context.CancelFunc, *Peer, duplex.Conn) {}, + code: duplex.CodeNormal, + local: true, + }, + { + name: "a context that ended", + end: func(_ context.Context, cancel context.CancelFunc, _ *Peer, _ duplex.Conn) { + cancel() + }, + code: duplex.CodeAbnormalClosure, + local: true, + }, + { + name: "a far side that aborted", + end: func(_ context.Context, _ context.CancelFunc, _ *Peer, far duplex.Conn) { + _ = far.Abort() + }, + code: duplex.CodeAbnormalClosure, + local: false, + }, + } { + t.Run(c.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + near, far := duplex.Pipe(1 << 20) + closes := make(chan ConnectionClosed, 1) + peer, err := NewPeer(ctx, near, ServerRole, Options{Observer: observerFunc(func(event ObserverEvent) { + if closed, ok := event.(ConnectionClosed); ok { + closes <- closed + } + })}) + if err != nil { + t.Fatal(err) + } + c.end(ctx, cancel, peer, far) + if c.code == duplex.CodeNormal { + _ = peer.Close() + } + var closed ConnectionClosed + select { + case closed = <-closes: + case <-time.After(5 * time.Second): + t.Fatal("the connection did not end") + } + if closed.Code != int(c.code) || closed.Reason != c.reason || closed.Local != c.local { + t.Fatalf("the observer was told %d %q local=%v, want %d %q local=%v", + closed.Code, closed.Reason, closed.Local, int(c.code), c.reason, c.local) + } + // And the far side reads what this side sent, which is the whole + // reason the code is decided here rather than reported here. + if !c.local { + return + } + read, cancelRead := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelRead() + var wire *duplex.CloseError + if _, err := far.Receive(read); !errors.As(err, &wire) { + t.Fatalf("the far side read %v, not a close", err) + } + if wire.Code != c.code || wire.Reason != c.reason { + t.Fatalf("the far side read %d %q, want %d %q", int(wire.Code), wire.Reason, int(c.code), c.reason) + } + }) + } +} diff --git a/engine/go/serial_test.go b/engine/go/serial_test.go new file mode 100644 index 0000000..21ed1a9 --- /dev/null +++ b/engine/go/serial_test.go @@ -0,0 +1,151 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + + ws "github.com/Bitspark/nightseam/runtime/go" + "github.com/coder/websocket" +) + +func echoOptions() ws.Options { + return ws.Options{Handlers: map[string]ws.Handler{ + "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { return data, nil }, + }} +} + +// TestTheSerialTableIsHeldAsItJudges: every row of tables/serials.json, held +// the way the peer holds what arrives โ€” the first frame published, then the +// request that follows it โ€” so that the two runtimes and the suite read one +// description of the order, this one. +func TestTheSerialTableIsHeldAsItJudges(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "conformance", "tables", "serials.json")) + if err != nil { + t.Fatal(err) + } + var table struct { + Rows []struct { + Name string + Before, Frame string + Valid bool + } + } + if err := json.Unmarshal(data, &table); err != nil { + t.Fatal(err) + } + if len(table.Rows) == 0 { + t.Fatal("the serials table has no rows") + } + for _, row := range table.Rows { + t.Run(row.Name, func(t *testing.T) { + peer, conn, ctx := rawPeer(t, echoOptions()) + for _, frame := range []string{row.Before, row.Frame} { + if err := conn.Write(ctx, websocket.MessageText, []byte(frame)); err != nil { + t.Fatal(err) + } + } + if !row.Valid { + receive(t, peer.Done()) + if peer.Err() == nil { + t.Fatal("a serial that did not increase was admitted") + } + return + } + if members := readFrame(ctx, t, conn); string(members["kind"]) != `"response"` { + t.Fatalf("frame was %s", members["kind"]) + } + if peer.Err() != nil { + t.Fatalf("an admissible serial ended the connection: %v", peer.Err()) + } + }) + } +} + +// Only a request advances the mark. A response answers a serial the receiver +// itself took, and a control names one it already admitted. +func TestOnlyRequestAdmissionAdvancesTheMark(t *testing.T) { + peer, conn, ctx := rawPeer(t, ws.Options{Handlers: map[string]ws.Handler{ + "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { + <-ctx.Done() + return nil, ctx.Err() + }, + }}) + for _, frame := range []string{ + `{"version":1,"kind":"request","id":"c:4","method":"wait","params":null}`, + `{"version":1,"kind":"cancel","id":"c:4"}`, + `{"version":1,"kind":"response","id":"s:1","result":null}`, + `{"version":1,"kind":"request","id":"c:5","method":"wait","params":null}`, + } { + if err := conn.Write(ctx, websocket.MessageText, []byte(frame)); err != nil { + t.Fatal(err) + } + } + if members := readFrame(ctx, t, conn); string(members["id"]) != `"c:4"` { + t.Fatalf("first answer was %s", members["id"]) + } + if peer.Err() != nil { + t.Fatalf("a control or a response advanced the mark: %v", peer.Err()) + } +} + +// Serials are published in the order they were reserved, whatever order the +// callers that took them are scheduled in. +func TestConcurrentCallsPublishSerialsInOrder(t *testing.T) { + peer, conn, ctx := rawPeer(t, ws.Options{}) + calling, withdraw := context.WithCancel(context.Background()) + var wait sync.WaitGroup + t.Cleanup(func() { withdraw(); wait.Wait() }) + for range 24 { + wait.Add(1) + go func() { + defer wait.Done() + _ = peer.Call(calling, "probe", nil, nil) + }() + } + previous := uint64(0) + for range 24 { + members := readFrame(ctx, t, conn) + if string(members["kind"]) != `"request"` { + continue + } + var id string + if err := json.Unmarshal(members["id"], &id); err != nil { + t.Fatal(err) + } + serial, err := strconv.ParseUint(strings.TrimPrefix(id, "s:"), 10, 64) + if err != nil { + t.Fatal(err) + } + if serial <= previous { + t.Fatalf("published %d after %d", serial, previous) + } + previous = serial + } +} + +// Every carrier bridge mints its own serials on its own connection and maps +// replies back: an inner peer's ids are its own, whatever ids arrived. +func TestACarrierBridgeMintsItsOwnSerials(t *testing.T) { + peer, conn, ctx := rawPeer(t, ws.Options{}) + // The peer's Wire takes a request whose id is the sender's; publishing it + // onward is the bridge's own request, with a serial of the bridge's. + wire := peer.Wire() + go func() { _ = ws.CallWire(context.Background(), wire, []string{"probe"}, nil, nil) }() + members := readFrame(ctx, t, conn) + var id string + if err := json.Unmarshal(members["id"], &id); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(id, "s:") { + t.Fatalf("the bridge published %q rather than a serial of its own", id) + } + if serial, err := strconv.ParseUint(strings.TrimPrefix(id, "s:"), 10, 64); err != nil || serial == 0 { + t.Fatalf("the bridge published %q", id) + } +} diff --git a/engine/go/unicode_peer_test.go b/engine/go/unicode_peer_test.go new file mode 100644 index 0000000..ca6ab7e --- /dev/null +++ b/engine/go/unicode_peer_test.go @@ -0,0 +1,38 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "testing" + + ws "github.com/Bitspark/nightseam/runtime/go" +) + +func TestPeerRefusesMalformedOutgoingUnicode(t *testing.T) { + client, server := newPair(t, ws.Options{}, ws.Options{}) + server.Handle("echo", func(_ context.Context, _ *ws.Peer, raw json.RawMessage) (any, error) { return raw, nil }) + server.Handle("bad", func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return string([]byte{0xff}), nil }) + ctx := context.Background() + for _, value := range []any{string([]byte{0xff}), map[string]any{"x": string([]byte{0xff})}, json.RawMessage(`"\uD800"`)} { + if err := client.Emit(ctx, "probe", value); err == nil { + t.Fatalf("emitted %T", value) + } + var result any + if err := client.Call(ctx, "echo", value, &result); err == nil { + t.Fatalf("called with %T", value) + } + } + if err := client.Emit(ctx, string([]byte{0xff}), nil); err == nil { + t.Fatal("emitted malformed event name") + } + if err := client.Emit(ws.WithMeta(ctx, map[string]string{"x": string([]byte{0xff})}), "probe", nil); err == nil { + t.Fatal("emitted malformed metadata") + } + var result string + if err := client.Call(ctx, "bad", nil, &result); err == nil { + t.Fatal("malformed response was silently replaced") + } + if err := client.Call(ctx, "echo", "๐Ÿ˜€๏ฟฝ", &result); err != nil || result != "๐Ÿ˜€๏ฟฝ" { + t.Fatalf("valid Unicode after refusal: %q, %v", result, err) + } +} diff --git a/engine/go/wire.go b/engine/go/wire.go new file mode 100644 index 0000000..3586a97 --- /dev/null +++ b/engine/go/wire.go @@ -0,0 +1,867 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "maps" + "sync" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +type routedFrame struct { + path []string + message duplex.Message + call *routedCall + refusal error +} + +// A request reserves one cancellation at admission. Completed requests retain +// their reservation until an already queued control has drained, so repeated +// completion/admission cannot turn the control queue into an unbounded buffer. +type routedCall struct { + cancel context.CancelFunc + completed bool + cancelQueued bool + cancelled bool +} + +type returnKey struct { + address *duplex.ReturnAddress + id string +} + +// peerWire is the existing peer's relative dispatch surface. Its return +// associations stay beside the peer's carrier pending/incoming tables; views +// in duplex only choose a path and never correlate an id. +type peerWire struct { + peer *Peer + queue []routedFrame + dataQueued int + wake chan struct{} + mu sync.Mutex + incoming map[returnKey]*routedCall + receiver *wireRegistration +} + +type wireRegistration struct { + receiver duplex.Receiver +} + +type wireFrameKey struct{} +type wireDispatchContext struct { + ctx context.Context + peer *Peer + frame frame + panic func(any) + maxFrameBytes int64 + completion *wireCompletion +} + +// Only a local handler can supply the cause of its cancellation. Serialized +// public errors, even one named cancelled, retain their ordinary error outcome. +type wireCompletion struct { + mu sync.Mutex + cancellation error +} + +// An event has no reply or request lifetime. Its local capability only retains +// the context already established by the receiving runtime across queued local +// composition; it is never reconstructed from event data or metadata. +type wireEventContext struct{ ctx context.Context } + +func (*wireEventContext) Send([]string, duplex.Message) error { + return errors.New("an event context is not a return address") +} +func eventContextOf(message duplex.Message) (context.Context, bool) { + if message.Return != nil { + if held, ok := message.Return.Wire.(*wireEventContext); ok { + return held.ctx, true + } + } + return nil, false +} +func withWireEventContext(message duplex.Message, ctx context.Context) duplex.Message { + message.Return = &duplex.ReturnAddress{Wire: &wireEventContext{ctx: ctx}} + return message +} + +// Wire selects this peer's root origin. Repeated selection shares the peer, +// its queues and its carrier lifetime. +func (p *Peer) Wire() duplex.Endpoint { + p.wireOnce.Do(func() { + p.wire = &peerWire{peer: p, wake: make(chan struct{}, 1), incoming: map[returnKey]*routedCall{}} + p.mu.Lock() + p.requestFallback = p.wire.namespaceHandler + p.eventFallback = p.wire.namespaceEvent + p.mu.Unlock() + go p.wire.run() + }) + return p.wire +} + +func (w *peerWire) Send(path []string, message duplex.Message) error { + name, err := duplex.EncodePath(path) + if err != nil { + return err + } + if name == "" && (message.Frame.Kind == duplex.ProfileRequest || message.Frame.Kind == duplex.ProfileEvent) { + return errors.New("a root wire operation needs a nonempty path") + } + if err := w.peer.Err(); err != nil { + return err + } + if (message.Frame.Kind == duplex.ProfileRequest || message.Frame.Kind == duplex.ProfileCancel) && (message.Return == nil || message.Return.Wire == nil) { + return errors.New("a wire request or cancellation requires a return address") + } + if message.Frame.Kind != duplex.ProfileRequest && message.Frame.Kind != duplex.ProfileEvent && message.Frame.Kind != duplex.ProfileCancel { + return errors.New("a response is sent to its request's return address") + } + if err := validateWireFrame(name, message.Frame, w.peer.options.MaxFrameBytes); err != nil { + return err + } + message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) + message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) + message.Frame.Meta = maps.Clone(message.Frame.Meta) + delivered := routedFrame{path: append([]string(nil), path...), message: message} + key := returnKey{message.Return, message.Frame.ID} + w.mu.Lock() + if err := w.peer.Err(); err != nil { + w.mu.Unlock() + return err + } + if message.Frame.Kind == duplex.ProfileCancel { + call := w.incoming[key] + if call == nil || call.completed || call.cancelQueued || call.cancelled { + w.mu.Unlock() + return nil + } + call.cancelQueued = true + delivered.call = call + } else { + if w.dataQueued >= w.peer.options.QueueCapacity { + depth := w.dataQueued + w.mu.Unlock() + w.peer.observeBackpressure(depth, true, w.peer.options.WriteTimeout) + w.peer.fail(ErrBackpressure) + return ErrBackpressure + } + w.dataQueued++ + if message.Frame.Kind == duplex.ProfileRequest { + if w.incoming[key] != nil { + delivered.refusal = &PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} + } else if len(w.incoming) >= w.peer.options.MaxPendingRequests { + delivered.refusal = &PublicError{Code: "busy", Message: "Outstanding call limit reached"} + } else { + delivered.call = &routedCall{} + w.incoming[key] = delivered.call + } + } + } + w.queue = append(w.queue, delivered) + w.mu.Unlock() + select { + case w.wake <- struct{}{}: + default: + } + return nil +} + +// retireLocked never removes a newer admission that reused the same local +// return identity. It is called with w.mu held on completion and control drain. +func (w *peerWire) retireLocked(key returnKey, call *routedCall) { + if call.completed && !call.cancelQueued && w.incoming[key] == call { + delete(w.incoming, key) + } +} + +func (w *peerWire) complete(key returnKey, call *routedCall) { + w.mu.Lock() + call.completed = true + w.retireLocked(key, call) + w.mu.Unlock() +} + +func (w *peerWire) next() (routedFrame, bool) { + w.mu.Lock() + defer w.mu.Unlock() + if len(w.queue) == 0 { + return routedFrame{}, false + } + delivered := w.queue[0] + w.queue[0] = routedFrame{} + w.queue = w.queue[1:] + if delivered.message.Frame.Kind != duplex.ProfileCancel { + w.dataQueued-- + } + return delivered, true +} + +func (w *peerWire) Close(code duplex.Code, reason string) error { + w.peer.end(ErrClosed, code, reason) + return nil +} + +func (w *peerWire) run() { + defer func() { + w.mu.Lock() + var receivers []duplex.Receiver + var cancels []context.CancelFunc + if w.receiver != nil { + receivers = append(receivers, w.receiver.receiver) + } + w.receiver = nil + for _, call := range w.incoming { + if call.cancel != nil { + cancels = append(cancels, call.cancel) + } + } + w.incoming = map[returnKey]*routedCall{} + w.queue = nil + w.dataQueued = 0 + w.mu.Unlock() + for _, cancel := range cancels { + cancel() + } + for _, receiver := range receivers { + if receiver.Closed != nil { + receiver.Closed(duplex.CodeGoingAway, "peer ended") + } + } + }() + for { + select { + case <-w.peer.Done(): + return + default: + } + delivered, exists := w.next() + if !exists { + select { + case <-w.peer.Done(): + return + case <-w.wake: + } + continue + } + f := delivered.message.Frame + key := returnKey{delivered.message.Return, f.ID} + switch f.Kind { + case duplex.ProfileCancel: + w.mu.Lock() + state := delivered.call + var cancel context.CancelFunc + if w.incoming[key] == state { + state.cancelQueued = false + state.cancelled = true + if !state.completed { + cancel = state.cancel + } + w.retireLocked(key, state) + } + w.mu.Unlock() + if cancel != nil { + cancel() + } + case duplex.ProfileRequest: + if delivered.refusal != nil { + sendWireResponse(delivered.message, nil, delivered.refusal) + continue + } + state := delivered.call + ctx := w.peer.options.Propagator.Extract(w.peer.Context(), Trace{Parent: f.Traceparent, State: f.Tracestate}) + ctx = WithMeta(ctx, f.Meta) + ctx, cancel := context.WithCancel(ctx) + name, err := duplex.EncodePath(delivered.path) + var call *admittedCall + if err == nil { + call, err = w.peer.beginCallTrace(ctx, name, f.Params, &Trace{Parent: f.Traceparent, State: f.Tracestate}, true) + } + if err != nil { + cancel() + w.complete(key, state) + sendWireResponse(delivered.message, nil, WithoutUnpublishedProof(err)) + continue + } + w.mu.Lock() + state.cancel = func() { call.withdraw(); cancel() } + w.mu.Unlock() + go func() { + var result json.RawMessage + err := call.await(&result) + cancel() + // Retire before delivering the response: its callback can admit + // another request, but a queued cancellation still owns budget. + w.complete(key, state) + sendWireResponse(delivered.message, result, WithoutUnpublishedProof(err)) + }() + case duplex.ProfileEvent: + name, err := duplex.EncodePath(delivered.path) + ctx := w.peer.options.Propagator.Extract(w.peer.Context(), Trace{Parent: f.Traceparent, State: f.Tracestate}) + if err == nil { + err = w.peer.emitTrace(WithMeta(ctx, f.Meta), name, f.Data, &Trace{Parent: f.Traceparent, State: f.Tracestate}, true) + } + if err != nil { + w.peer.fail(err) + } + } + } +} + +func (w *peerWire) Receive(receiver duplex.Receiver) (func(), error) { + registration := &wireRegistration{receiver: receiver} + w.mu.Lock() + defer w.mu.Unlock() + if err := w.peer.Err(); err != nil { + return nil, err + } + if w.receiver != nil { + return nil, duplex.ErrReceiverExists + } + w.receiver = registration + return func() { + w.mu.Lock() + if w.receiver == registration { + w.receiver = nil + } + w.mu.Unlock() + }, nil +} + +// The profile presents canonical addressed operations to its one attachment. +// Registration and path precedence belong to an explicit Dispatcher. +func (w *peerWire) namespace(name string) ([]string, *wireRegistration) { + path, err := duplex.DecodePath(name) + if err != nil { + return nil, nil + } + w.mu.Lock() + defer w.mu.Unlock() + registration := w.receiver + if registration == nil { + return nil, nil + } + return path, registration +} + +func (w *peerWire) namespaceHandler(name string) Handler { + path, registration := w.namespace(name) + if registration == nil { + return nil + } + return w.requestReceiver(path, registration.receiver) +} + +func (w *peerWire) namespaceEvent(name string) EventHandler { + path, registration := w.namespace(name) + if registration == nil { + return nil + } + return w.eventReceiver(path, registration.receiver) +} + +func (w *peerWire) requestReceiver(path []string, receiver duplex.Receiver) Handler { + return func(ctx context.Context, _ *Peer, params json.RawMessage) (any, error) { + if receiver.Message == nil { + return nil, &PublicError{Code: "method_not_found", Message: "Unknown method"} + } + var result json.RawMessage + incoming, _ := ctx.Value(wireFrameKey{}).(frame) + dispatch := &wireDispatchContext{ctx: ctx, peer: w.peer, frame: incoming} + // The chosen registration stays with this request. Later detach or + // replacement cannot redirect its correlated cancellation. + err := callWire(WithMeta(ctx, MetaFrom(ctx)), &receiverWire{receiver: receiver}, append([]string{}, path...), params, &result, dispatch) + return result, err + } +} + +func (w *peerWire) eventReceiver(path []string, receiver duplex.Receiver) EventHandler { + return func(ctx context.Context, _ *Peer, data json.RawMessage) { + if receiver.Message == nil { + return + } + incoming, _ := ctx.Value(wireFrameKey{}).(frame) + receiver.Message(append([]string{}, path...), withWireEventContext(duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: data, Traceparent: incoming.Traceparent, Tracestate: incoming.Tracestate, Meta: MetaFrom(ctx)}}, ctx)) + } +} + +// ForwardWire joins two existing origins without allocating a peer or channel. +// Detach removes only the forwarding registrations; both wires remain owned by +// their callers. Each root remains responsible for ending its failed carrier. +func ForwardWire(inbound, outbound duplex.Endpoint) (func(), error) { + if inbound == nil || outbound == nil { + return nil, errors.New("wire forwarding requires two origins") + } + var mu sync.Mutex + var detaches []func() + ended := false + stop := func() { + mu.Lock() + if ended { + mu.Unlock() + return + } + ended = true + owned := detaches + detaches = nil + mu.Unlock() + for _, detach := range owned { + detach() + } + } + receiver := func(destination duplex.Wire) duplex.Receiver { + return duplex.Receiver{Closed: func(duplex.Code, string) { stop() }, Message: func(path []string, message duplex.Message) { + if err := destination.Send(path, message); err != nil { + stop() + if message.Frame.Kind == duplex.ProfileRequest { + sendWireResponse(message, nil, WithoutUnpublishedProof(err)) + } + } + }} + } + for _, direction := range []struct{ source, destination duplex.Endpoint }{{inbound, outbound}, {outbound, inbound}} { + detach, err := direction.source.Receive(receiver(direction.destination)) + if err != nil { + stop() + return nil, err + } + mu.Lock() + active := !ended + if active { + detaches = append(detaches, detach) + } + mu.Unlock() + if !active { + detach() + return nil, ErrClosed + } + } + return stop, nil +} + +// receiverWire is used only inside the peer's already asynchronous request +// dispatch. It reuses the same completion primitive when handing a decoded +// request to a generated wire receiver. +type receiverWire struct{ receiver duplex.Receiver } + +func (w *receiverWire) Send(path []string, message duplex.Message) error { + w.receiver.Message(path, message) + return nil +} + +type replyWire struct { + id string + reply chan pendingResult + done chan struct{} + once sync.Once + dispatch *wireDispatchContext + invocation *Invocation +} + +// Invocation exposes this return capability's lifecycle to the runtime that +// owns it. The vocabulary reaches it through Send like any participant's. +func (w *replyWire) Invocation() *Invocation { return w.invocation } + +func (w *replyWire) wireDispatch() *wireDispatchContext { return w.dispatch } + +func (w *replyWire) Send(path []string, message duplex.Message) error { + if len(path) != 0 { + return w.invocation.Deliver(path, message) + } + if message.Frame.Kind != duplex.ProfileResponse || message.Frame.ID != w.id { + return errors.New("invalid wire response") + } + var limit int64 + if w.dispatch != nil { + limit = w.dispatch.maxFrameBytes + if limit == 0 && w.dispatch.peer != nil { + limit = w.dispatch.peer.options.MaxFrameBytes + } + } + if err := validateWireFrame("", message.Frame, limit); err != nil { + return err + } + r := pendingResult{result: message.Frame.Result} + if f := message.Frame.Error; f != nil { + r.err = &PublicError{Code: f.Code, Message: f.Message, Data: f.Data} + if f.Code == "cancelled" && w.dispatch != nil && w.dispatch.ctx.Err() != nil && w.dispatch.completion != nil { + completion := w.dispatch.completion + completion.mu.Lock() + if completion.cancellation != nil { + r.err = completion.cancellation + } + completion.mu.Unlock() + } + } + select { + case <-w.done: + return ErrClosed + default: + } + select { + case w.reply <- r: + w.invocation.Settle() + return nil + default: + return errors.New("duplicate wire response") + } +} +func (w *replyWire) finish() error { + w.once.Do(func() { + close(w.done) + w.invocation.Settle() + w.invocation.DispatchDone() + }) + return nil +} + +// CallWire calls a relative operation through the peer's request primitive. +// Its local return address is independent of every other call's identifier. +func CallWire(ctx context.Context, wire duplex.Wire, path []string, params, result any, options ...WireCallOptions) error { + return callWire(ctx, wire, path, params, result, nil, options...) +} +func callWire(ctx context.Context, wire duplex.Wire, path []string, params, result any, dispatch *wireDispatchContext, options ...WireCallOptions) (err error) { + if ctx == nil || wire == nil { + return Unpublished(errors.New("a wire call requires a context and wire")) + } + if err := ctx.Err(); err != nil { + return Unpublished(err) + } + name, err := duplex.EncodePath(path) + if err != nil { + return Unpublished(errors.New("a wire call requires a valid operation path")) + } + encoded, err := MarshalJSON(params) + if err != nil { + return Unpublished(err) + } + var observation WireCallOptions + if len(options) > 0 { + observation = options[0] + } + if observation.RequestTimeout < 0 { + return Unpublished(errors.New("wire request timeout must not be negative")) + } + // A forwarded request already has its carrier's admitted deadline. + if dispatch == nil { + timeout := observation.RequestTimeout + if timeout == 0 { + timeout = 30 * time.Second + } + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + if dispatch != nil { + copied := *dispatch + copied.completion = &wireCompletion{} + dispatch = &copied + } + returning := &replyWire{id: "c:1", reply: make(chan pendingResult, 1), done: make(chan struct{}), dispatch: dispatch, invocation: NewInvocation(DefaultInvocationLimits(), nil)} + defer returning.finish() + address := &duplex.ReturnAddress{Wire: returning} + var trace Trace + if dispatch != nil { + trace = Trace{Parent: dispatch.frame.Traceparent, State: dispatch.frame.Tracestate} + } else { + propagator := observation.Propagator + if propagator == nil { + propagator = DefaultPropagator + } + trace = propagator.Inject(ctx) + } + finish := observeWireRequest(observation.Observer, observation.Family, name, false, trace) + defer func() { finish(err) }() + request := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: returning.id, Params: encoded, Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)}, Return: address} + if err := wire.Send(path, request); err != nil { + return Unpublished(err) + } + cancelRemote, err := awaitReply(ctx, returning.reply, returning.done, func() error { return ErrClosed }, result) + finish(err) + if cancelRemote { + _ = wire.Send(path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: returning.id, Traceparent: trace.Parent, Tracestate: trace.State}, Return: address}) + if dispatch != nil { + // This waiter is the carrier's admitted handler, not the outgoing + // caller. Cancellation reaches the body immediately, but its slot + // remains occupied until the receiver actually finishes its work. + _, err = awaitReply(context.WithoutCancel(ctx), returning.reply, returning.done, func() error { return ErrClosed }, result) + } + } + return err +} + +// WireHandler is a typed adapter's decoded request body, independent of the +// concrete carrier. The runtime supplies cancellation and response routing. +type WireHandler func(context.Context, json.RawMessage) (any, error) + +// WireEventHandler receives an event body beside its carried context. +type WireEventHandler func(context.Context, json.RawMessage) error + +// WireHandlers groups a method and event that share one declared name. +type WireHandlers struct { + Request WireHandler + Event WireEventHandler + Observer Observer + Family string +} + +// AdapterContext carries runtime options used when constructing model wires. +type AdapterContext struct { + Options Options + ValueEnvironment ValueEnvironment +} + +// EmitWire admits one event at a relative path. The return says only that the +// destination accepted it; processing and transport remain asynchronous. +func EmitWire(ctx context.Context, wire duplex.Wire, path []string, data any, options ...WireEmitOptions) error { + if ctx == nil || wire == nil { + return Unpublished(errors.New("a wire event requires a context and wire")) + } + if err := ctx.Err(); err != nil { + return Unpublished(err) + } + name, err := duplex.EncodePath(path) + if err != nil { + return Unpublished(errors.New("a wire event requires a valid operation path")) + } + encoded, err := MarshalJSON(data) + if err != nil { + return Unpublished(err) + } + propagator := DefaultPropagator + if len(options) > 0 && options[0].Propagator != nil { + propagator = options[0].Propagator + } + trace := propagator.Inject(ctx) + if len(options) > 0 && options[0].Observer != nil { + observeWire(options[0].Observer, EventEmitted{At: time.Now(), Name: name, Bytes: len(encoded), Trace: trace, Family: options[0].Family}) + } + return Unpublished(wire.Send(path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: encoded, Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)}})) +} + +// HandleWire registers one relative operation. The receiver returns before +// running application code, and request cancellation uses its return address. +func HandleWire(wire HandlerRegistry, path []string, handler WireHandler) (func(), error) { + if wire == nil || handler == nil { + return nil, errors.New("a wire handler requires a wire and body") + } + return RegisterWire(wire, path, WireHandlers{Request: handler}) +} + +// RegisterWire installs a single receiver for a declared method, event, or both. +// The one detach removes the group; an event-only path refuses requests. +func RegisterWire(wire HandlerRegistry, path []string, handlers WireHandlers) (func(), error) { + if wire == nil || (handlers.Request == nil && handlers.Event == nil) { + return nil, errors.New("wire registration requires a wire and at least one handler") + } + name, err := duplex.EncodePath(path) + if err != nil { + return nil, err + } + var mu sync.Mutex + incoming := map[returnKey]context.CancelFunc{} + return wire.Register(path, duplex.Receiver{ + Closed: func(duplex.Code, string) { + mu.Lock() + defer mu.Unlock() + for _, cancel := range incoming { + cancel() + } + }, + Message: func(_ []string, message duplex.Message) { + if message.Frame.Kind == duplex.ProfileEvent { + if handlers.Event != nil { + if handlers.Observer != nil { + observeWire(handlers.Observer, EventDelivered{At: time.Now(), Name: name, Bytes: len(message.Frame.Data), + Trace: Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}, Family: handlers.Family}) + } + ctx, associated := eventContextOf(message) + if !associated { + ctx = DefaultPropagator.Extract(context.Background(), Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) + } + if err := invokeWireEvent(withIncomingMeta(ctx, message.Frame.Meta), handlers.Event, message.Frame.Data); err != nil { + _ = wire.Close(duplex.CodeProtocolError, "wire event rejected") + } + } + return + } + key := returnKey{message.Return, message.Frame.ID} + if message.Frame.Kind == duplex.ProfileCancel { + mu.Lock() + cancel := incoming[key] + mu.Unlock() + if cancel != nil { + cancel() + } + return + } + if message.Frame.Kind != duplex.ProfileRequest { + return + } + finish := observeWireRequest(handlers.Observer, handlers.Family, name, true, + Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) + if handlers.Request == nil { + err := &PublicError{Code: "method_not_found", Message: "Unknown method"} + finish(sendWireResponse(message, nil, err)) + return + } + var dispatch *wireDispatchContext + base := context.Background() + if message.Return != nil { + if returning, ok := message.Return.Wire.(interface{ wireDispatch() *wireDispatchContext }); ok { + dispatch = returning.wireDispatch() + } + } + if dispatch != nil { + base = dispatch.ctx + } + ctx := DefaultPropagator.Extract(base, Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) + ctx, cancel := context.WithCancel(withIncomingMeta(ctx, message.Frame.Meta)) + mu.Lock() + if incoming[key] != nil { + mu.Unlock() + cancel() + err := &PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} + finish(sendWireResponse(message, nil, err)) + return + } + incoming[key] = cancel + mu.Unlock() + // The body runs after this receiver returns, so returning is not + // completion. The lease says so to whoever admitted the request: + // an early answer to the caller cannot retire an invocation whose + // body is still running. A return capability that carries no + // lifecycle still gets ordinary addressed delivery. + // A bound reached is a refusal; any other refusal means this + // return capability carries no lifecycle, and ordinary addressed + // delivery goes on without one. + body, leaseErr := BeginInvocationBody(message) + if errors.Is(leaseErr, ErrInvocationLimit) { + mu.Lock() + delete(incoming, key) + mu.Unlock() + cancel() + err := &PublicError{Code: "busy", Message: "Invocation participation limit reached"} + finish(sendWireResponse(message, nil, err)) + return + } + go func() { + defer func() { body.Done(); cancel(); mu.Lock(); delete(incoming, key); mu.Unlock() }() + result, err := invokeWireHandler(ctx, handlers.Request, message.Frame.Params, dispatch) + if err == nil { + err = ctx.Err() + } + data, marshalErr := MarshalJSON(result) + if err == nil { + err = marshalErr + } + if dispatch != nil && dispatch.completion != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { + dispatch.completion.mu.Lock() + dispatch.completion.cancellation = err + dispatch.completion.mu.Unlock() + } + finish(sendWireResponse(message, data, WithoutUnpublishedProof(err))) + }() + }, + }) +} + +func invokeWireEvent(ctx context.Context, handler WireEventHandler, data json.RawMessage) (err error) { + defer func() { + if recover() != nil { + err = errors.New("wire event handler panic") + } + }() + return handler(ctx, data) +} + +func invokeWireHandler(ctx context.Context, handler WireHandler, params json.RawMessage, dispatch *wireDispatchContext) (result any, err error) { + defer func() { + if value := recover(); value != nil { + if dispatch != nil { + if dispatch.panic != nil { + dispatch.panic(value) + } else if dispatch.peer != nil { + dispatch.peer.observePanic(dispatch.frame, value) + } + } + err = errors.New("wire handler panic") + } + }() + return handler(ctx, params) +} + +func sendWireResponse(request duplex.Message, result json.RawMessage, err error) error { + if request.Return == nil || request.Return.Wire == nil { + return ErrClosed + } + f := duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: request.Frame.ID, Result: result, Traceparent: request.Frame.Traceparent, Tracestate: request.Frame.Tracestate} + if err != nil { + var public *PublicError + switch { + case errors.As(err, &public) && public != nil && public.Code != "" && public.Message != "": + f.Error = &duplex.ProfileError{Code: public.Code, Message: public.Message, Data: public.Data} + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + f.Error = &duplex.ProfileError{Code: "cancelled", Message: "Request cancelled"} + case errors.Is(err, ErrClosed): + f.Error = &duplex.ProfileError{Code: "disconnected", Message: "Connection ended; outcome may be unknown"} + default: + f.Error = &duplex.ProfileError{Code: "internal", Message: "Internal error"} + } + f.Result = nil + // Preserve the local cancellation cause, but otherwise observe exactly + // the normalized public error selected for this response. + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + err = &PublicError{Code: f.Error.Code, Message: f.Error.Message, Data: f.Error.Data} + } + } + if sendErr := request.Return.Wire.Send(nil, duplex.Message{Frame: f}); sendErr != nil { + // A malformed or oversized public result must settle as a bounded + // refusal, just as the carrier peer's respond does. + f.Result = nil + f.Error = &duplex.ProfileError{Code: "internal", Message: "Response could not be encoded"} + if fallbackErr := request.Return.Wire.Send(nil, duplex.Message{Frame: f}); fallbackErr == nil { + return &PublicError{Code: f.Error.Code, Message: f.Error.Message} + } + // A caller that already withdrew cannot receive either response. Its + // selected refusal remains that refusal; a failed success is no success. + if err == nil { + return WithoutUnpublishedProof(sendErr) + } + } + return err +} + +// The structured boundary uses the profile's existing validator. A logical +// return address identifies an origin independently of the carrier role, so +// either profile identifier prefix is valid before the peer remaps it. +func validateWireFrame(name string, value duplex.ProfileFrame, limit int64) error { + f := frame{Version: value.Version, Kind: string(value.Kind), ID: value.ID, + Params: value.Params, Result: value.Result, Data: value.Data, + Traceparent: value.Traceparent, Tracestate: value.Tracestate, Meta: value.Meta} + if value.Error != nil { + f.Error = &PublicError{Code: value.Error.Code, Message: value.Error.Message, Data: value.Error.Data} + } + switch value.Kind { + case duplex.ProfileRequest: + f.Method = name + case duplex.ProfileEvent: + f.Event = name + } + data, err := MarshalJSON(f) + if err != nil { + return err + } + if limit > 0 && int64(len(data)) > limit { + return errors.New("wire frame exceeds the carrier limit") + } + if _, err := decodeFrame(data); err != nil { + return err + } + if f.ID != "" && !validID(f.ID, "c:") && !validID(f.ID, "s:") { + return errors.New("invalid wire request identifier") + } + return nil +} diff --git a/engine/websocket/go/websocket.go b/engine/websocket/go/websocket.go new file mode 100644 index 0000000..fe7bc49 --- /dev/null +++ b/engine/websocket/go/websocket.go @@ -0,0 +1,204 @@ +package runtime + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/coder/websocket" + + "github.com/Bitspark/nightseam/duplex/go" + "github.com/Bitspark/nightseam/duplex/go/ws" +) + +// ServerOptions requires an explicit authentication and origin policy. The +// authenticated context is the base context for every incoming invocation. +type ServerOptions struct { + Options Options + Authenticate func(*http.Request) (context.Context, error) + CheckOrigin func(*http.Request) bool + // OnConnect is called with a peer that is already live: it has read + // frames and may have answered them. It is where a server uses the peer + // โ€” calls it, keeps it, waits on it. What a peer must serve is installed + // in Options.Prepare, which runs before it reads anything; a handler + // installed here can be too late for the other side's first request. + OnConnect func(*Peer) + // Subprotocols are what the server will select, in its own order of + // preference, from what a client offers; empty selects none, which is + // the default and what every consumer that sets nothing keeps. The + // profile names itself here nowhere and refuses nothing on this ground + // (docs/wire/profile.md). + Subprotocols []string + // SelectSubprotocol answers with the one subprotocol to select out of + // what this request offered, "" for none. It is the selection, not a + // filter over Subprotocols: a browser's ticket travels in the offer and + // is accepted only if it is selected back unchanged, which no fixed + // list can do. Nil selects the first offered that Subprotocols names. + SelectSubprotocol func(r *http.Request, offered []string) string +} + +func (o ServerOptions) validate() error { + if o.Authenticate == nil || o.CheckOrigin == nil { + return errors.New("duplex server requires explicit authentication and origin policies") + } + _, err := o.Options.normalized() + return err +} + +// Accept upgrades an authenticated request to a WebSocket and speaks the +// profile over it. The caller must keep its HTTP handler alive until +// Peer.Done; NewHandler implements that lifetime contract. +func Accept(w http.ResponseWriter, r *http.Request, options ServerOptions) (*Peer, error) { + if err := options.validate(); err != nil { + http.Error(w, "Invalid server configuration", http.StatusInternalServerError) + return nil, err + } + if !options.CheckOrigin(r) { + http.Error(w, "Origin denied", http.StatusForbidden) + return nil, errors.New("duplex origin denied") + } + ctx, err := options.Authenticate(r) + if err != nil || ctx == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, errors.New("duplex authentication failed") + } + o, _ := options.Options.normalized() + accept := &websocket.AcceptOptions{InsecureSkipVerify: true, Subprotocols: options.Subprotocols} + if options.SelectSubprotocol != nil { + // The library selects the first offered that it is given; given the + // hook's one answer, the hook is the selection. An answer nobody + // offered selects none, as offering none does. + accept.Subprotocols = nil + if selected := options.SelectSubprotocol(r, offeredSubprotocols(r)); selected != "" { + accept.Subprotocols = []string{selected} + } + } + socket, err := websocket.Accept(w, r, accept) + if err != nil { + return nil, err + } + conn := ws.New(socket, o.MaxFrameBytes) + peer, err := newPeer(ctx, conn, ServerRole, options.Options, socket.Subprotocol()) + if err != nil { + // Everything else newPeer refuses was refused by validate above, so + // this is Prepare's own error: the upgrade is answered and the + // profile will not be spoken over it, and a socket left open in + // silence behind a 101 is the one thing the client cannot read. + refuseConnection(conn, o.WriteTimeout) + return nil, err + } + return peer, nil +} + +// refuseConnection ends a socket the handshake opened and the peer above it +// never took, with the code a policy refusal carries everywhere (1008) rather +// than the abort a live peer's failure would leave. +func refuseConnection(conn duplex.Conn, timeout time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _ = conn.Close(ctx, duplex.CodePolicyViolation, "the connection was refused before the profile began") +} + +// NewHandler serves the profile at an HTTP endpoint: each request that +// passes CheckOrigin and Authenticate is upgraded to a WebSocket and becomes +// a server-role peer with the options given. The generated binding's +// NewHandler wraps this with the family's handlers installed. +func NewHandler(options ServerOptions) (http.Handler, error) { + if err := options.validate(); err != nil { + return nil, err + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peer, err := Accept(w, r, options) + if err != nil { + return + } + defer peer.Close() + if options.OnConnect != nil { + options.OnConnect(peer) + } + select { + case <-peer.Done(): + case <-r.Context().Done(): + } + }), nil +} + +// DialOptions is what Dial opens a WebSocket with: the peer's Options, the +// headers and client of the HTTP upgrade, the bound on the handshake, and +// the subprotocols to offer. +type DialOptions struct { + Options Options + HTTPHeader http.Header + HTTPClient *http.Client + // ConnectTimeout bounds the handshake alone, as TypeScript's + // connectTimeoutMs does, and takes the same default of 30 seconds where + // it is zero. A dial that has not become a connection by then is refused + // with the code connect_timeout and nothing is opened; the connection's + // own lifetime is the context's, as it is without one. + ConnectTimeout time.Duration + // Subprotocols are offered to the server in order of preference; the + // default offers none. A server that selects none leaves the connection + // with none and the profile is spoken over it either way โ€” but a browser + // refuses a handshake whose offer went unselected, so a client that + // offers must be met by a server that selects (docs/wire/profile.md). + Subprotocols []string +} + +// Dial opens a WebSocket and speaks the profile over it. It uses ctx for the +// handshake and the connection lifetime. Use a separate context for each +// Call; cancelling the dialing context disconnects the peer. +func Dial(ctx context.Context, url string, options DialOptions) (*Peer, *http.Response, error) { + if ctx == nil { + return nil, nil, errors.New("duplex dial requires a context") + } + if options.ConnectTimeout < 0 { + return nil, nil, errors.New("duplex connect timeout must not be negative") + } + o, err := options.Options.normalized() + if err != nil { + return nil, nil, err + } + timeout := options.ConnectTimeout + if timeout == 0 { + timeout = 30 * time.Second + } + // The deadline is the handshake's and not the connection's: once the + // upgrade is answered, net/http stops cancelling the body it handed over, + // so the peer below outlives this context and ends with ctx instead. + dialing, settled := context.WithTimeout(ctx, timeout) + defer settled() + socket, response, err := websocket.Dial(dialing, url, &websocket.DialOptions{HTTPHeader: options.HTTPHeader, HTTPClient: options.HTTPClient, Subprotocols: options.Subprotocols}) + if err != nil { + if ctx.Err() == nil && errors.Is(dialing.Err(), context.DeadlineExceeded) { + return nil, response, &PublicError{Code: "connect_timeout", Message: "Connection timed out."} + } + return nil, response, err + } + conn := ws.New(socket, o.MaxFrameBytes) + peer, err := newPeer(ctx, conn, ClientRole, options.Options, socket.Subprotocol()) + if err != nil { + // As in Accept: the only error left here is Prepare's, and the + // server is told the connection was refused rather than left with a + // socket this side will never speak over. + refuseConnection(conn, o.WriteTimeout) + return nil, response, err + } + return peer, response, nil +} + +// offeredSubprotocols is what the request offers as Sec-WebSocket-Protocol, +// in the client's order of preference, across however many header lines it +// spelled them over. +func offeredSubprotocols(r *http.Request) []string { + var offered []string + for _, line := range r.Header.Values("Sec-WebSocket-Protocol") { + for _, token := range strings.Split(line, ",") { + if token = strings.TrimSpace(token); token != "" { + offered = append(offered, token) + } + } + } + return offered +} diff --git a/internal/profile/go/json.go b/internal/profile/go/json.go new file mode 100644 index 0000000..bffc762 --- /dev/null +++ b/internal/profile/go/json.go @@ -0,0 +1,67 @@ +package runtime + +import ( + "encoding/json" + + "github.com/Bitspark/nightseam/internal/scalarjson" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + jsonv1 "github.com/go-json-experiment/json/v1" +) + +// ValidateUnicodeJSON checks original JSON text for malformed Unicode, +// including strings a decoder would discard as duplicate members. It does +// not replace JSON syntax or envelope validation performed by its caller. +func ValidateUnicodeJSON(data []byte) error { return scalarjson.Raw(data) } + +// The external implementation has its own v1 Number type. Preserve the +// encoding/json.Number used by this runtime and by its existing consumers. +var wireMarshalers = jsonv2.MarshalToFunc(func(encoder *jsontext.Encoder, value json.Number) error { + // With jsonv2 enabled the external Number aliases the standard type. + // Disable this adapter in the inner call so that alias cannot recurse. + return jsonv2.MarshalEncode(encoder, jsonv1.Number(value), jsonv2.WithMarshalers(nil)) +}) + +// MarshalObject writes an object whose members are already encoded, in the +// order given. It is what a generated live codec builds its value with: a +// live value is assembled member by member, because each callable in it has +// to become a binding first, and a map would write the members in another +// order than the record declares them. +func MarshalObject(order []string, members map[string]json.RawMessage) ([]byte, error) { + var out []byte + out = append(out, '{') + first := true + for _, name := range order { + member, present := members[name] + if !present { + continue + } + if !first { + out = append(out, ',') + } + first = false + key, err := MarshalJSON(name) + if err != nil { + return nil, err + } + out = append(out, key...) + out = append(out, ':') + out = append(out, member...) + } + out = append(out, '}') + if err := ValidateUnicodeJSON(out); err != nil { + return nil, err + } + return out, nil +} + +// MarshalJSON encodes a wire value without replacing malformed Unicode. +// Generated codecs use it before validation, while invalid UTF-8 in Go +// strings and unpaired surrogate escapes in raw encodings are still visible. +func MarshalJSON(value any) ([]byte, error) { + // Preserve the standard encoder's field, omission, number and method + // rules, changing only Unicode replacement. Validating custom text + // afterward is too late; invoking its encoder twice changes its behavior. + return jsonv2.Marshal(value, jsonv1.DefaultOptionsV1(), jsontext.AllowInvalidUTF8(false), jsonv2.WithMarshalers(wireMarshalers)) +} diff --git a/internal/profile/go/json_test.go b/internal/profile/go/json_test.go new file mode 100644 index 0000000..6d1cc65 --- /dev/null +++ b/internal/profile/go/json_test.go @@ -0,0 +1,69 @@ +package runtime + +import ( + "encoding/json" + "testing" +) + +type unicodeText struct { + calls *int + text string +} + +func (v unicodeText) MarshalText() ([]byte, error) { *v.calls++; return []byte(v.text), nil } + +// Encoder hooks must run exactly once, and malformed text must be refused +// before encoding/json has a chance to replace it with an ordinary U+FFFD. +func TestStrictEncoderCallsHooksOnce(t *testing.T) { + for _, text := range []string{"hello", "๐Ÿ˜€๏ฟฝ", string([]byte{0xff})} { + calls := 0 + _, err := MarshalJSON(struct{ Text unicodeText }{unicodeText{&calls, text}}) + if calls != 1 { + t.Fatalf("MarshalText called %d times", calls) + } + if (err != nil) != (text == string([]byte{0xff})) { + t.Fatalf("text %q: %v", text, err) + } + calls = 0 + _, err = MarshalJSON(map[unicodeText]string{{&calls, text}: "value"}) + if calls != 1 || (err != nil) != (text == string([]byte{0xff})) { + t.Fatalf("map key %q: calls=%d, err=%v", text, calls, err) + } + } + for _, raw := range []string{`"\uD800"`, `{"x":"\uD800","x":"valid"}`, string([]byte{'"', 0xff, '"'})} { + if _, err := MarshalJSON(struct{ Raw unicodeRaw }{unicodeRaw(raw)}); err == nil { + t.Fatalf("accepted custom output %q", raw) + } + } +} + +func TestStrictEncoderPreservesValidEncoding(t *testing.T) { + type embedded struct{ Visible string } + for _, value := range []any{ + []json.Number{"", "1e100", "-2"}, + map[json.Number]int{"not-a-number": 1}, + struct { + Number json.Number `json:",string"` + }{json.Number("1e100")}, + struct { + embedded + Omitted string `json:"-"` + Empty string `json:",omitempty"` + Number int `json:",string"` + }{embedded{"<๐Ÿ˜€๏ฟฝ>"}, string([]byte{0xff}), "", 42}, + struct { + Absent Optional[string] `json:",omitzero"` + Null Nullable[string] + }{Optional[string]{Value: string([]byte{0xff})}, Null[string]()}, + map[string]any{"z": json.Number("1e100"), "a": []byte{0xff, 0xfe}, "raw": json.RawMessage(`{"a": 1}`)}, + } { + want, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + got, err := MarshalJSON(value) + if err != nil || string(got) != string(want) { + t.Fatalf("strict=%s, std=%s, err=%v", got, want, err) + } + } +} diff --git a/internal/profile/go/scalar.go b/internal/profile/go/scalar.go new file mode 100644 index 0000000..89424fc --- /dev/null +++ b/internal/profile/go/scalar.go @@ -0,0 +1,141 @@ +// Package scalarjson guards Unicode before encoding/json can replace it. +// It is independent of the declaration language and the wire profile. +package scalarjson + +import ( + "errors" + "reflect" + "unicode/utf8" +) + +var errUnicode = errors.New("invalid Unicode: expected Unicode scalar strings") + +// Raw checks original JSON text, including overwritten members. JSON syntax +// remains the decoder's job; this scan only rules out lossy string decoding. +func Raw(data []byte) error { + if !utf8.Valid(data) { + return errUnicode + } + inString := false + for i := 0; i < len(data); i++ { + if data[i] == '"' { + inString = !inString + continue + } + if !inString || data[i] != '\\' { + continue + } + i++ + if i >= len(data) || data[i] != 'u' { + continue + } + unit, ok := hexUnit(data, i+1) + if !ok { + continue + } + i += 4 + if unit >= 0xdc00 && unit <= 0xdfff { + return errUnicode + } + if unit < 0xd800 || unit > 0xdbff { + continue + } + if i+2 >= len(data) || data[i+1] != '\\' || data[i+2] != 'u' { + return errUnicode + } + low, ok := hexUnit(data, i+3) + if !ok || low < 0xdc00 || low > 0xdfff { + return errUnicode + } + i += 6 + } + return nil +} + +func hexUnit(data []byte, start int) (uint16, bool) { + if start+4 > len(data) { + return 0, false + } + var unit uint16 + for _, digit := range data[start : start+4] { + unit <<= 4 + switch { + case digit >= '0' && digit <= '9': + unit |= uint16(digit - '0') + case digit >= 'a' && digit <= 'f': + unit |= uint16(digit - 'a' + 10) + case digit >= 'A' && digit <= 'F': + unit |= uint16(digit - 'A' + 10) + default: + return 0, false + } + } + return unit, true +} + +// Value checks in-memory descriptor and type-expression strings. It invokes +// no encoding hooks: a descriptor's spelling is what the interpreter reads. +func Value(value any) error { return check(reflect.ValueOf(value), map[visit]bool{}) } + +type visit struct { + typ reflect.Type + ptr uintptr +} + +func check(value reflect.Value, seen map[visit]bool) error { + if !value.IsValid() { + return nil + } + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return nil + } + return check(value.Elem(), seen) + case reflect.Pointer, reflect.Map, reflect.Slice: + if value.IsNil() { + return nil + } + key := visit{value.Type(), value.Pointer()} + if seen[key] { + return nil + } + seen[key] = true + defer delete(seen, key) + } + switch value.Kind() { + case reflect.String: + if !utf8.ValidString(value.String()) { + return errUnicode + } + case reflect.Pointer: + return check(value.Elem(), seen) + case reflect.Map: + iter := value.MapRange() + for iter.Next() { + if err := check(iter.Key(), seen); err != nil { + return err + } + if err := check(iter.Value(), seen); err != nil { + return err + } + } + case reflect.Slice, reflect.Array: + for i := 0; i < value.Len(); i++ { + if err := check(value.Index(i), seen); err != nil { + return err + } + } + case reflect.Struct: + for i := 0; i < value.NumField(); i++ { + field := value.Type().Field(i) + if !field.IsExported() { + continue + } + if err := check(value.Field(i), seen); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/profile/go/unicode_test.go b/internal/profile/go/unicode_test.go new file mode 100644 index 0000000..82b0b4f --- /dev/null +++ b/internal/profile/go/unicode_test.go @@ -0,0 +1,82 @@ +package runtime + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// TestUnicodeDomain is the same source table read by the TypeScript runtime. +// Decode only the table wrapper; the runtime must see each original JSON text. +func TestUnicodeDomain(t *testing.T) { + data, err := os.ReadFile("../../conformance/tables/unicode.json") + if err != nil { + t.Fatal(err) + } + var table struct { + Rows []struct { + Name, Raw string + Valid bool + } + } + if err := json.Unmarshal(data, &table); err != nil { + t.Fatal(err) + } + schema := MustSchema(`{"types":{}}`, "", nil) + for _, row := range table.Rows { + t.Run(row.Name, func(t *testing.T) { + err := schema.ValidateExpressionRaw("json", []byte(row.Raw)) + if (err == nil) != row.Valid { + t.Fatalf("raw valid=%v: %v", row.Valid, err) + } + if !row.Valid && err.Error() != "invalid Unicode: expected Unicode scalar strings" { + t.Fatalf("diagnostic: %v", err) + } + frame := `{"version":1,"kind":"event","event":"probe","data":` + row.Raw + `}` + _, err = decodeFrame([]byte(frame)) + if (err == nil) != row.Valid { + t.Fatalf("frame valid=%v: %v", row.Valid, err) + } + // Even an unused descriptor extension cannot normalize a string. + _, err = NewSchema([]byte(`{"types":{},"extension":`+row.Raw+`}`), "", nil) + if (err == nil) != row.Valid { + t.Fatalf("descriptor valid=%v: %v", row.Valid, err) + } + }) + } +} + +func TestUnicodeBeforeGoJSONReplacement(t *testing.T) { + schema := MustSchema(`{"types":{}}`, "", nil) + for _, raw := range [][]byte{[]byte{'"', 0xff, '"'}, []byte{'"', 0xed, 0xa0, 0x80, '"'}} { + if err := schema.ValidateExpressionRaw("json", raw); err == nil { + t.Fatal("accepted malformed UTF-8") + } + if _, err := NewSchema(append(append([]byte(`{"types":{},"x":`), raw...), '}'), "", nil); err == nil { + t.Fatal("accepted malformed descriptor UTF-8") + } + } + for _, value := range []any{ + map[json.Number]int{json.Number(string([]byte{0xff})): 1}, + string([]byte{0xff}), map[string]any{"nested": []any{string([]byte{0xff})}}, + map[string]int{string([]byte{0xff}): 1}, json.RawMessage(`"\uD800"`), + unicodeRaw(`"\uD800"`), Some(string([]byte{0xff})), NonNull(string([]byte{0xff})), + } { + if err := schema.ValidateValue("json", value); err == nil { + t.Errorf("accepted malformed value %T", value) + } + } + for _, raw := range []string{`{"types":{"Literal":{"kind":"alias","type":{"literal":"\uD800"}}}}`, `{"types":{"Enum":{"kind":"enum","values":["\uDC00"]}}}`, `{"types":{"Union":{"kind":"union","tag":"kind","value":"value","variants":{"\uD800":{"empty":true}}}}}`} { + if _, err := NewSchema([]byte(raw), "", nil); err == nil { + t.Fatalf("descriptor normalized: %s", raw) + } + } + if err := schema.ValidateExpressionRaw(map[string]any{"literal": string([]byte{0xff})}, []byte(`"๏ฟฝ"`)); err == nil || !strings.Contains(err.Error(), "Unicode") { + t.Fatalf("in-memory expression: %v", err) + } +} + +type unicodeRaw string + +func (v unicodeRaw) MarshalJSON() ([]byte, error) { return []byte(v), nil } diff --git a/transports/go/pipe.go b/transports/go/pipe.go new file mode 100644 index 0000000..b169b5c --- /dev/null +++ b/transports/go/pipe.go @@ -0,0 +1,123 @@ +package duplex + +import ( + "context" + "fmt" + "sync" +) + +// Pipe returns two connected ends in memory: what one sends, the other +// receives, in order. Each direction holds at most a few frames in flight, +// as a socket holds some bytes; past that a Send waits for a Receive, so +// the pipe carries backpressure as a transport does. A Close on one end is +// a CloseError on the other; an Abort is CodeAbnormalClosure there, as a +// dropped socket would be. It carries the duplex profile in tests without +// a socket, and it is the transport a protocol is held to before a real one +// is. +func Pipe(limit int64) (Conn, Conn) { + a := &pipeEnd{limit: limit, frames: make(chan Frame, inFlight), done: make(chan struct{})} + b := &pipeEnd{limit: limit, frames: make(chan Frame, inFlight), done: make(chan struct{})} + a.remote, b.remote = b, a + return a, b +} + +// inFlight is how many frames a pipe holds per direction before a Send +// waits. +const inFlight = 8 + +type pipeEnd struct { + limit int64 + frames chan Frame // frames this end sends; the remote end receives from it + remote *pipeEnd + done chan struct{} + once sync.Once + mu sync.Mutex + closed *CloseError // what the remote will be told; nil after Abort +} + +func (e *pipeEnd) end(closed *CloseError) { + e.once.Do(func() { + e.mu.Lock() + e.closed = closed + e.mu.Unlock() + close(e.done) + }) +} + +// remoteError is what this end returns once the remote end ended: its +// close, or an abnormal closure if it aborted. +func (e *pipeEnd) remoteError() error { + e.remote.mu.Lock() + defer e.remote.mu.Unlock() + if e.remote.closed != nil { + return &CloseError{Code: e.remote.closed.Code, Reason: e.remote.closed.Reason} + } + return &CloseError{Code: CodeAbnormalClosure} +} + +func (e *pipeEnd) Send(ctx context.Context, frame Frame) error { + if frame.Kind != Text && frame.Kind != Binary { + return ErrNoKind + } + select { + case <-e.done: + return ErrClosed + case <-e.remote.done: + return e.remoteError() + default: + } + data := make([]byte, len(frame.Data)) + copy(data, frame.Data) + select { + case e.frames <- Frame{Kind: frame.Kind, Data: data}: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-e.done: + return ErrClosed + case <-e.remote.done: + return e.remoteError() + } +} + +func (e *pipeEnd) Receive(ctx context.Context) (Frame, error) { + select { + case <-e.done: + return Frame{}, ErrClosed + default: + } + deliver := func(frame Frame) (Frame, error) { + if e.limit > 0 && int64(len(frame.Data)) > e.limit { + _ = e.Abort() + return Frame{}, fmt.Errorf("duplex frame of %d bytes exceeds the receive limit of %d", len(frame.Data), e.limit) + } + return frame, nil + } + select { + case frame := <-e.remote.frames: + return deliver(frame) + case <-ctx.Done(): + return Frame{}, ctx.Err() + case <-e.done: + return Frame{}, ErrClosed + case <-e.remote.done: + // What the remote sent before it closed is still delivered, in order, + // before its close is. + select { + case frame := <-e.remote.frames: + return deliver(frame) + default: + return Frame{}, e.remoteError() + } + } +} + +func (e *pipeEnd) Close(ctx context.Context, code Code, reason string) error { + e.end(&CloseError{Code: code, Reason: reason}) + return nil +} + +func (e *pipeEnd) Abort() error { + e.end(nil) + return nil +} diff --git a/transports/go/pipe_test.go b/transports/go/pipe_test.go new file mode 100644 index 0000000..e04dc1c --- /dev/null +++ b/transports/go/pipe_test.go @@ -0,0 +1,16 @@ +package duplex_test + +import ( + "testing" + + "github.com/Bitspark/nightseam/duplex/go" + "github.com/Bitspark/nightseam/duplex/go/duplextest" +) + +// TestPipeIsAConformingTransport: the in-memory pipe keeps every promise of +// the seam, so a protocol proven over it is proven over the seam. +func TestPipeIsAConformingTransport(t *testing.T) { + duplextest.Run(t, func(t *testing.T, limit int64) (duplex.Conn, duplex.Conn) { + return duplex.Pipe(limit) + }) +} diff --git a/transports/go/transport.go b/transports/go/transport.go new file mode 100644 index 0000000..99b07a1 --- /dev/null +++ b/transports/go/transport.go @@ -0,0 +1,136 @@ +// Package duplex is the seam beneath every protocol: a +// frames duplex connection. It is ordered, message-framed, bidirectional and +// closed explicitly with a code and a reason, and it is nothing else โ€” no +// JSON, no requests, no correlation, no events, no reconnection. The +// nightseam.duplex/1 profile runs over it, and through the profile every +// family of API; beneath it the transport is a WebSocket today +// and may be something else tomorrow, and neither side of the seam knows +// which. +// +// Framing is the transport's: a WebSocket has message boundaries of its own, +// a byte stream would need a framing of its own, and either way a frame +// arrives whole or not at all. Close semantics travel: the codes are the +// WebSocket registry's numbers on every transport, so that a policy +// violation or an oversized frame is refused the same way everywhere. +package duplex + +import ( + "context" + "errors" + "fmt" + + bitwire "github.com/Bitspark/bitwire/wire/go" +) + +// Kind is what a frame carries: text, which the profile requires to be JSON, +// or bytes. +type Kind int + +const ( + // Text is a frame of UTF-8 text, what the profile's JSON envelopes travel as. + Text Kind = iota + 1 + // Binary is a frame of bytes, opaque to the seam. + Binary +) + +// ErrNoKind is refused by every transport for a frame whose Kind is neither +// Text nor Binary. +var ErrNoKind = errors.New("duplex frame of no kind") + +func (k Kind) String() string { + switch k { + case Text: + return "text" + case Binary: + return "binary" + } + return fmt.Sprintf("kind(%d)", int(k)) +} + +// Frame is one message: it is sent whole and received whole, in order. +type Frame struct { + Kind Kind + Data []byte +} + +// Code is a close code. The numbers are the WebSocket registry's, kept on +// every transport so that a close means the same thing whatever carried it. +type Code = bitwire.Code + +const ( + // CodeNormal is a close both sides meant. + CodeNormal Code = 1000 + // CodeGoingAway is a side shutting down. + CodeGoingAway Code = 1001 + // CodeProtocolError is a frame the receiver could not take as the + // protocol above the seam defines one. + CodeProtocolError Code = 1002 + // CodeUnsupportedData is a frame of a kind the receiver does not speak, + // such as a binary frame where JSON text was expected. + CodeUnsupportedData Code = 1003 + // CodeNoStatus is what a side sees when the other closed with no code: + // never sent, only read. + CodeNoStatus Code = 1005 + // CodeAbnormalClosure is what a side sees when the other ended with no + // close at all: an abort, or a dropped transport. + CodeAbnormalClosure Code = 1006 + // CodePolicyViolation is a frame that parses and is refused anyway. + CodePolicyViolation Code = 1008 + // CodeTooLarge is a frame over the receiver's limit. + CodeTooLarge Code = 1009 + // CodeInternalError is a failure of the receiver's own. + CodeInternalError Code = 1011 +) + +// Application codes are the range a protocol above the seam may use for its +// own reasons; the duplex profile closes with CodeDuplex. +const ( + CodeApplicationFirst Code = 4000 + CodeApplicationLast Code = 4999 + CodeDuplex Code = 4011 +) + +// ErrClosed is what Send and Receive return once the connection was closed +// or aborted on this side. +var ErrClosed = errors.New("duplex connection closed") + +// CloseError is what Receive returns once the remote side closed: the code +// and the reason it gave, which a protocol above may act on. +type CloseError struct { + Code Code + Reason string +} + +func (e *CloseError) Error() string { + if e.Reason == "" { + return fmt.Sprintf("duplex connection closed by the remote side (%d)", int(e.Code)) + } + return fmt.Sprintf("duplex connection closed by the remote side (%d): %s", int(e.Code), e.Reason) +} + +// Conn is a frames duplex connection. A transport implements it; a protocol +// uses it and nothing beneath it. Send and Receive may be called +// concurrently with each other, and each in turn from one goroutine at a +// time. Once Close or Abort was called, every Send and Receive returns +// ErrClosed; once the remote closed, Receive returns a *CloseError and Send +// fails. +type Conn interface { + // Send writes one frame after every frame sent before it. It blocks while + // the transport cannot take more, until ctx ends: backpressure is the + // caller's to wait out or to give up on, never hidden in a buffer that + // grows. + Send(ctx context.Context, frame Frame) error + // Receive returns the next frame in the order it was sent. A frame larger + // than the connection's receive limit is not delivered: Receive returns + // an error and the connection is dead, because a limit that could be + // exceeded first is not a limit. + Receive(ctx context.Context) (Frame, error) + // Close ends the connection with a code and a reason the remote side + // will see, waiting for its acknowledgement until ctx ends where the + // transport has one. + Close(ctx context.Context, code Code, reason string) error + // Abort ends the connection at once, with no handshake and nothing sent, + // and releases every blocked Send and Receive. It is what a protocol + // does when the remote side has misbehaved or the consumer has stalled. + Abort() error +} diff --git a/transports/go/transporttest/conformance.go b/transports/go/transporttest/conformance.go new file mode 100644 index 0000000..6e08ca2 --- /dev/null +++ b/transports/go/transporttest/conformance.go @@ -0,0 +1,153 @@ +// Package duplextest holds every transport to what a frames duplex +// connection promises. A transport's own tests call Run with a way to make +// a connected pair; what Run checks is the seam's contract and nothing of +// the transport, so a protocol written against the seam can trust the same +// four things wherever it runs: frames arrive in order and whole, a send +// waits when the receiver does not, a close carries its code and reason +// across, and a frame over the limit is refused before it is delivered. +package duplextest + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// Connect makes a fresh connected pair with the given receive limit on both +// ends. The test closes what it opened; the transport may register cleanup +// with t. +type Connect func(t *testing.T, limit int64) (a, b duplex.Conn) + +// Run holds a transport to the seam. +func Run(t *testing.T, connect Connect) { + t.Helper() + t.Run("frames arrive in order and whole, text and binary alike", func(t *testing.T) { + a, b := connect(t, 1<<20) + defer a.Abort() + defer b.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + const n = 64 + errs := make(chan error, 1) + go func() { + for i := 0; i < n; i++ { + kind := duplex.Text + if i%3 == 0 { + kind = duplex.Binary + } + if err := a.Send(ctx, duplex.Frame{Kind: kind, Data: []byte(fmt.Sprintf("frame %02d", i))}); err != nil { + errs <- err + return + } + } + errs <- nil + }() + for i := 0; i < n; i++ { + frame, err := b.Receive(ctx) + if err != nil { + t.Fatalf("receive %d: %v", i, err) + } + want := duplex.Text + if i%3 == 0 { + want = duplex.Binary + } + if frame.Kind != want || !bytes.Equal(frame.Data, []byte(fmt.Sprintf("frame %02d", i))) { + t.Fatalf("frame %d arrived as %s %q", i, frame.Kind, frame.Data) + } + } + if err := <-errs; err != nil { + t.Fatal(err) + } + }) + t.Run("a send waits while the receiver does not receive", func(t *testing.T) { + a, b := connect(t, 1<<20) + defer a.Abort() + defer b.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + big := bytes.Repeat([]byte("x"), 256<<10) + // Nobody receives on b. Sooner or later the transport can take no + // more and Send must wait โ€” until the context ends, not forever, and + // not into a buffer of its own that hides the stall. + for i := 0; i < 400; i++ { + err := a.Send(ctx, duplex.Frame{Kind: duplex.Binary, Data: big}) + if err == nil { + continue + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("a blocked send failed with %v, not the context's deadline", err) + } + return + } + t.Fatal("400 frames of 256 KiB were accepted with nobody receiving; the transport buffers without bound") + }) + t.Run("a close carries its code and reason across", func(t *testing.T) { + a, b := connect(t, 1<<20) + defer a.Abort() + defer b.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := a.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte("last")}); err != nil { + t.Fatal(err) + } + if frame, err := b.Receive(ctx); err != nil || string(frame.Data) != "last" { + t.Fatalf("receive before close: %q, %v", frame.Data, err) + } + go func() { _ = a.Close(ctx, duplex.CodePolicyViolation, "an observer sent a deciding frame") }() + _, err := b.Receive(ctx) + var closed *duplex.CloseError + if !errors.As(err, &closed) { + t.Fatalf("receive after the remote closed: %v", err) + } + if closed.Code != duplex.CodePolicyViolation || closed.Reason != "an observer sent a deciding frame" { + t.Fatalf("the close arrived as %d %q", closed.Code, closed.Reason) + } + if _, err := b.Receive(ctx); err == nil { + t.Fatal("a closed connection received") + } + if err := a.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte("late")}); !errors.Is(err, duplex.ErrClosed) { + t.Fatalf("send after closing: %v", err) + } + if _, err := a.Receive(ctx); !errors.Is(err, duplex.ErrClosed) { + t.Fatalf("receive after closing: %v", err) + } + }) + t.Run("a frame over the limit is refused, and the connection with it", func(t *testing.T) { + a, b := connect(t, 1024) + defer a.Abort() + defer b.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + go func() { _ = a.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: bytes.Repeat([]byte("y"), 1025)}) }() + if frame, err := b.Receive(ctx); err == nil { + t.Fatalf("a frame of %d bytes was delivered over a limit of 1024", len(frame.Data)) + } + if _, err := b.Receive(ctx); err == nil { + t.Fatal("the connection received again after refusing a frame over the limit") + } + }) + t.Run("an abort ends the connection at once on both sides", func(t *testing.T) { + a, b := connect(t, 1<<20) + defer b.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + received := make(chan error, 1) + started := make(chan struct{}) + go func() { close(started); _, err := b.Receive(ctx); received <- err }() + <-started + if err := a.Abort(); err != nil { + t.Fatal(err) + } + if err := <-received; err == nil || errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("the remote side of an aborted connection did not end on abort: %v", err) + } + if _, err := a.Receive(ctx); !errors.Is(err, duplex.ErrClosed) { + t.Fatalf("receive on the aborted side: %v", err) + } + }) +} diff --git a/transports/websocket/go/websocket.go b/transports/websocket/go/websocket.go new file mode 100644 index 0000000..338dac6 --- /dev/null +++ b/transports/websocket/go/websocket.go @@ -0,0 +1,127 @@ +// Package ws carries a frames duplex connection over a WebSocket. It is the +// one transport there is today, and the only package that knows the +// seam is a WebSocket: a message is a frame, a close frame is a close, and +// the read limit is the receive limit. +package ws + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/coder/websocket" + + "github.com/Bitspark/nightseam/duplex/go" +) + +// New wraps an open WebSocket as a frames duplex connection with the given +// receive limit: a message larger than it fails the read and the +// connection, as the seam requires. Never read or write the WebSocket after +// handing it over. +func New(conn *websocket.Conn, limit int64) duplex.Conn { + conn.SetReadLimit(limit) + return &connection{conn: conn} +} + +type connection struct { + conn *websocket.Conn + mu sync.Mutex + done bool +} + +func (c *connection) closed() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.done +} + +func (c *connection) end() { + c.mu.Lock() + c.done = true + c.mu.Unlock() +} + +func kindOf(t websocket.MessageType) (duplex.Kind, error) { + switch t { + case websocket.MessageText: + return duplex.Text, nil + case websocket.MessageBinary: + return duplex.Binary, nil + } + return 0, fmt.Errorf("websocket message of unknown type %d", int(t)) +} + +func typeOf(k duplex.Kind) (websocket.MessageType, error) { + switch k { + case duplex.Text: + return websocket.MessageText, nil + case duplex.Binary: + return websocket.MessageBinary, nil + } + return 0, duplex.ErrNoKind +} + +func (c *connection) Send(ctx context.Context, frame duplex.Frame) error { + if c.closed() { + return duplex.ErrClosed + } + t, err := typeOf(frame.Kind) + if err != nil { + return err + } + if err := c.conn.Write(ctx, t, frame.Data); err != nil { + return c.translate(ctx, err) + } + return nil +} + +func (c *connection) Receive(ctx context.Context) (duplex.Frame, error) { + if c.closed() { + return duplex.Frame{}, duplex.ErrClosed + } + t, data, err := c.conn.Read(ctx) + if err != nil { + return duplex.Frame{}, c.translate(ctx, err) + } + kind, err := kindOf(t) + if err != nil { + _ = c.Abort() + return duplex.Frame{}, err + } + return duplex.Frame{Kind: kind, Data: data}, nil +} + +// translate says what an error of the WebSocket means at the seam: the +// context's own end, ErrClosed once this side ended the connection, the +// remote side's close with its code and reason, or the failure itself. +func (c *connection) translate(ctx context.Context, err error) error { + if ctx.Err() != nil && errors.Is(err, ctx.Err()) { + return ctx.Err() + } + if c.closed() { + return duplex.ErrClosed + } + var closeErr websocket.CloseError + if errors.As(err, &closeErr) { + return &duplex.CloseError{Code: duplex.Code(closeErr.Code), Reason: closeErr.Reason} + } + return err +} + +// Close sends a close frame with the code and reason and waits for the +// remote side's acknowledgement; the WebSocket library bounds that wait +// itself, so ctx is not consulted. +func (c *connection) Close(ctx context.Context, code duplex.Code, reason string) error { + if c.closed() { + return duplex.ErrClosed + } + c.end() + return c.conn.Close(websocket.StatusCode(code), reason) +} + +// Abort closes the socket at once with no close frame. +func (c *connection) Abort() error { + c.end() + return c.conn.CloseNow() +} diff --git a/transports/websocket/go/websocket_test.go b/transports/websocket/go/websocket_test.go new file mode 100644 index 0000000..dd9c12b --- /dev/null +++ b/transports/websocket/go/websocket_test.go @@ -0,0 +1,45 @@ +package ws_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/Bitspark/nightseam/duplex/go" + "github.com/Bitspark/nightseam/duplex/go/duplextest" + "github.com/Bitspark/nightseam/duplex/go/ws" +) + +// connect opens a WebSocket pair over a test server and wraps both ends. +func connect(t *testing.T, limit int64) (duplex.Conn, duplex.Conn) { + t.Helper() + accepted := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + accepted <- conn + <-r.Context().Done() + })) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + serverSide := <-accepted + return ws.New(client, limit), ws.New(serverSide, limit) +} + +// TestWebSocketIsAConformingTransport: a WebSocket keeps every promise of +// the seam, so the profile and the relays above it may forget it is one. +func TestWebSocketIsAConformingTransport(t *testing.T) { + duplextest.Run(t, connect) +} From 042df79d2934a14f74aa58208642d2f2a298769e Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:25:08 +0200 Subject: [PATCH 02/39] transports, profile: adapt the seam, pipe, WebSocket and bitwire/1 envelope Move Nightseam's duplex seam into transports/go and its WebSocket adapter into transports/websocket/go, and the envelope codec, canonical path encoding and Unicode guard into internal/profile/go. Carrier-contract rules the port adds (Bitwire carrier draft, research R26 and R27): - one closed classification: transports.ErrClosed, which a remote CloseError and a dropped WebSocket also satisfy; - Sendable separates sendable close codes from observe-only 1005, 1006 and 1015, which Close refuses with ErrUnsendableCode before any library sees them; - a frame over the receive limit ends the pipe with 1009 on both sides, and ends a WebSocket promptly instead of leaving a half-closed socket. The envelope accepts and refuses exactly what v0.6.0 does. The bitwire/1 tables are vendored byte for byte under vectors/bitwire-1. Co-Authored-By: Claude Opus 5.5 (1M context) --- go.mod | 5 + go.sum | 4 + internal/profile/go/frame.go | 294 ++++++++++ internal/profile/go/json.go | 47 +- internal/profile/go/json_test.go | 6 +- internal/profile/go/path_test.go | 30 + internal/profile/go/scalar.go | 76 +-- internal/profile/go/unicode_test.go | 50 +- transports/go/pipe.go | 14 +- transports/go/pipe_test.go | 10 +- transports/go/transport.go | 104 ++-- transports/go/transporttest/conformance.go | 73 ++- transports/websocket/go/websocket.go | 78 ++- transports/websocket/go/websocket_test.go | 12 +- vectors/bitwire-1/README.md | 18 + vectors/bitwire-1/frames.json | 621 +++++++++++++++++++++ vectors/bitwire-1/serials.json | 37 ++ vectors/bitwire-1/unicode.json | 25 + 18 files changed, 1249 insertions(+), 255 deletions(-) create mode 100644 internal/profile/go/frame.go create mode 100644 internal/profile/go/path_test.go create mode 100644 vectors/bitwire-1/README.md create mode 100644 vectors/bitwire-1/frames.json create mode 100644 vectors/bitwire-1/serials.json create mode 100644 vectors/bitwire-1/unicode.json diff --git a/go.mod b/go.mod index 45a5375..4cf6e6c 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,8 @@ module github.com/Bitspark/bitruntime go 1.26.0 require github.com/Bitspark/bitwire v0.3.0 + +require ( + github.com/coder/websocket v1.8.15 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect +) diff --git a/go.sum b/go.sum index c4435bf..d216717 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ github.com/Bitspark/bitwire v0.3.0 h1:RXgSS3XR1rHBMXu8dDvP3RHF16E8Uyt2aTfATRXzbhA= github.com/Bitspark/bitwire v0.3.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= diff --git a/internal/profile/go/frame.go b/internal/profile/go/frame.go new file mode 100644 index 0000000..c1801cc --- /dev/null +++ b/internal/profile/go/frame.go @@ -0,0 +1,294 @@ +// Package profile holds the bitwire/1 envelope: its JSON frame, the canonical +// encoding of an addressed path into the frame's method or event name, and the +// validation a peer applies before it admits a frame. It is shared by the +// in-process pair, the protocol engine and the helpers, and is not public API. +package profile + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "unicode/utf8" + + wire "github.com/Bitspark/bitwire/wire/go" +) + +// ErrPath refuses a path segment that is not Unicode-scalar text, or an +// encoded name that is not the canonical form of one. +var ErrPath = errors.New("invalid wire path") + +// Frame is one bitwire/1 envelope as it travels. The path of an addressed +// request or event is its canonical encoding in Method or Event. +type Frame struct { + Version int `json:"version"` + Kind string `json:"kind"` + ID string `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *wire.ProfileError `json:"error,omitempty"` + Event string `json:"event,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + // W3C Trace Context, which every kind may carry and none requires. + Traceparent string `json:"traceparent,omitempty"` + Tracestate string `json:"tracestate,omitempty"` + // What is about a call rather than the call, which a request and an event + // may carry. The profile carries it verbatim and reads nothing into it. + Meta map[string]string `json:"meta,omitempty"` +} + +// MetaReserved prefixes the meta keys the profile keeps for itself. Revision 1 +// defines none, so every key under it is refused, on the way out as on the way +// in. The prefix keeps its historical spelling until a later revision. +const MetaReserved = "nightseam." + +// EncodePath concatenates UTF-8 byte-length-prefixed scalar-string segments. +// The empty path is "", while a single empty segment is "0:". +func EncodePath(path []string) (string, error) { + var encoded strings.Builder + for _, segment := range path { + if !utf8.ValidString(segment) { + return "", ErrPath + } + encoded.WriteString(strconv.Itoa(len(segment))) + encoded.WriteByte(':') + encoded.WriteString(segment) + } + return encoded.String(), nil +} + +// DecodePath accepts only the canonical form of EncodePath, without Unicode +// normalization or interpretation of dots, slashes or empty segments. +func DecodePath(encoded string) ([]string, error) { + path := []string{} + for encoded != "" { + colon := strings.IndexByte(encoded, ':') + if colon <= 0 { + return nil, ErrPath + } + digits := encoded[:colon] + if len(digits) > 1 && digits[0] == '0' { + return nil, ErrPath + } + for _, digit := range digits { + if digit < '0' || digit > '9' { + return nil, ErrPath + } + } + length, err := strconv.ParseUint(digits, 10, 64) + encoded = encoded[colon+1:] + if err != nil || length > uint64(len(encoded)) { + return nil, ErrPath + } + segment := encoded[:int(length)] + if !utf8.ValidString(segment) { + return nil, ErrPath + } + path = append(path, segment) + encoded = encoded[int(length):] + } + return path, nil +} + +// ValidPath says whether every segment is Unicode-scalar text: the access +// law's rule, independent of how the profile encodes a path. +func ValidPath(path []string) bool { + for _, segment := range path { + if !utf8.ValidString(segment) { + return false + } + } + return true +} + +// Decode admits one envelope exactly as the profile allows it. +func Decode(data []byte) (Frame, error) { + var f Frame + if err := RawUnicode(data); err != nil { + return f, err + } + // Validate members separately so duplicate fields and explicit members from + // another frame kind cannot disappear into Go zero values while decoding. + fields, err := frameMembers(data) + if err != nil { + return f, err + } + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&f); err != nil { + return f, fmt.Errorf("invalid duplex frame: %w", err) + } + if err := d.Decode(new(any)); err != io.EOF { + return f, errors.New("invalid trailing duplex frame content") + } + if f.Version != 1 { + return f, errors.New("unsupported duplex frame version") + } + valid := false + allowed := map[string]bool{"version": true, "kind": true, "traceparent": true, "tracestate": true} + switch f.Kind { + case "request": + allowed["id"], allowed["method"], allowed["params"], allowed["meta"] = true, true, true, true + valid = f.ID != "" && f.Method != "" && len(f.Params) > 0 && len(f.Result) == 0 && f.Error == nil && f.Event == "" && len(f.Data) == 0 + case "response": + allowed["id"], allowed["result"], allowed["error"] = true, true, true + valid = f.ID != "" && f.Method == "" && len(f.Params) == 0 && (len(f.Result) > 0) != (f.Error != nil) && f.Event == "" && len(f.Data) == 0 && f.Meta == nil + _, hasResult := fields["result"] + _, hasError := fields["error"] + valid = valid && hasResult != hasError + case "event": + allowed["event"], allowed["data"], allowed["meta"] = true, true, true + valid = f.ID == "" && f.Method == "" && len(f.Params) == 0 && len(f.Result) == 0 && f.Error == nil && f.Event != "" && len(f.Data) > 0 + case "cancel": + allowed["id"] = true + valid = f.ID != "" && f.Method == "" && len(f.Params) == 0 && len(f.Result) == 0 && f.Error == nil && f.Event == "" && len(f.Data) == 0 && f.Meta == nil + } + for name := range fields { + if !allowed[name] { + valid = false + } + } + if f.Error != nil && (f.Error.Code == "" || f.Error.Message == "") { + valid = false + } + // A trace the peer cannot read is a trace it would carry wrongly; tracestate + // has no form of its own and travels alone when an intermediary strips one. + if _, traced := fields["traceparent"]; traced && !ValidTraceparent(f.Traceparent) { + valid = false + } + // Meta maps names to strings and may be empty; keys under the reserved + // prefix are the profile's to define and it defines none in this version, + // so a frame carrying one is refused rather than read as a consumer's. + if raw, carried := fields["meta"]; carried && !validMeta(raw) { + valid = false + } + if !valid { + return f, errors.New("invalid duplex frame shape") + } + return f, nil +} + +func frameMembers(data []byte) (map[string]json.RawMessage, error) { + d := json.NewDecoder(bytes.NewReader(data)) + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return nil, errors.New("duplex frame must be an object") + } + members := make(map[string]json.RawMessage) + for d.More() { + key, err := d.Token() + if err != nil { + return nil, err + } + name, ok := key.(string) + if !ok { + return nil, errors.New("invalid duplex field name") + } + if _, exists := members[name]; exists { + return nil, fmt.Errorf("duplicate duplex field %q", name) + } + var value json.RawMessage + if err := d.Decode(&value); err != nil { + return nil, err + } + members[name] = value + } + if _, err := d.Token(); err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, errors.New("invalid trailing duplex frame content") + } + return members, nil +} + +// validMeta holds meta to what a carriage is, reading the member as it was +// spelled rather than as the frame holds it: an object, since a member spelled +// null is not an absent one; every value a string, which map[string]string +// cannot tell from a null it would read as the empty one; and no key of the +// reserved prefix. +func validMeta(raw json.RawMessage) bool { + var values map[string]json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil || values == nil { + return false + } + for key, value := range values { + if strings.HasPrefix(key, MetaReserved) || len(value) == 0 || value[0] != '"' { + return false + } + } + return true +} + +// ValidID holds a request identifier to the profile's form: the role's prefix +// followed by a decimal serial without leading zeros, at most 20 digits. +func ValidID(id, prefix string) bool { + if !strings.HasPrefix(id, prefix) { + return false + } + n := strings.TrimPrefix(id, prefix) + if n == "" || n[0] == '0' { + return false + } + for _, r := range n { + if r < '0' || r > '9' { + return false + } + } + return len(n) <= 20 +} + +// ValidTraceparent holds a traceparent to the one form W3C Trace Context gives +// it: version, trace id, parent id and flags, lower-case hexadecimal, dashed. +func ValidTraceparent(value string) bool { + if len(value) != 55 || value[2] != '-' || value[35] != '-' || value[52] != '-' { + return false + } + for i := 0; i < len(value); i++ { + if i == 2 || i == 35 || i == 52 { + continue + } + if c := value[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// Validate holds a structured frame to the envelope it would travel as, with +// name as its method or event, and to a carrier's frame limit. A logical return +// address identifies an origin independently of the carrier role, so either +// identifier prefix is valid before a peer remaps it. +func Validate(name string, value wire.ProfileFrame, limit int64) error { + f := Frame{Version: value.Version, Kind: string(value.Kind), ID: value.ID, + Params: value.Params, Result: value.Result, Data: value.Data, + Traceparent: value.Traceparent, Tracestate: value.Tracestate, Meta: value.Meta} + if value.Error != nil { + copied := *value.Error + f.Error = &copied + } + switch value.Kind { + case wire.ProfileRequest: + f.Method = name + case wire.ProfileEvent: + f.Event = name + } + data, err := MarshalJSON(f) + if err != nil { + return err + } + if limit > 0 && int64(len(data)) > limit { + return errors.New("wire frame exceeds the carrier limit") + } + if _, err := Decode(data); err != nil { + return err + } + if f.ID != "" && !ValidID(f.ID, "c:") && !ValidID(f.ID, "s:") { + return errors.New("invalid wire request identifier") + } + return nil +} diff --git a/internal/profile/go/json.go b/internal/profile/go/json.go index bffc762..7e0aa83 100644 --- a/internal/profile/go/json.go +++ b/internal/profile/go/json.go @@ -1,10 +1,8 @@ -package runtime +package profile import ( "encoding/json" - "github.com/Bitspark/nightseam/internal/scalarjson" - jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" jsonv1 "github.com/go-json-experiment/json/v1" @@ -13,52 +11,19 @@ import ( // ValidateUnicodeJSON checks original JSON text for malformed Unicode, // including strings a decoder would discard as duplicate members. It does // not replace JSON syntax or envelope validation performed by its caller. -func ValidateUnicodeJSON(data []byte) error { return scalarjson.Raw(data) } +func ValidateUnicodeJSON(data []byte) error { return RawUnicode(data) } // The external implementation has its own v1 Number type. Preserve the -// encoding/json.Number used by this runtime and by its existing consumers. +// encoding/json.Number used by this runtime and its consumers. var wireMarshalers = jsonv2.MarshalToFunc(func(encoder *jsontext.Encoder, value json.Number) error { // With jsonv2 enabled the external Number aliases the standard type. // Disable this adapter in the inner call so that alias cannot recurse. return jsonv2.MarshalEncode(encoder, jsonv1.Number(value), jsonv2.WithMarshalers(nil)) }) -// MarshalObject writes an object whose members are already encoded, in the -// order given. It is what a generated live codec builds its value with: a -// live value is assembled member by member, because each callable in it has -// to become a binding first, and a map would write the members in another -// order than the record declares them. -func MarshalObject(order []string, members map[string]json.RawMessage) ([]byte, error) { - var out []byte - out = append(out, '{') - first := true - for _, name := range order { - member, present := members[name] - if !present { - continue - } - if !first { - out = append(out, ',') - } - first = false - key, err := MarshalJSON(name) - if err != nil { - return nil, err - } - out = append(out, key...) - out = append(out, ':') - out = append(out, member...) - } - out = append(out, '}') - if err := ValidateUnicodeJSON(out); err != nil { - return nil, err - } - return out, nil -} - -// MarshalJSON encodes a wire value without replacing malformed Unicode. -// Generated codecs use it before validation, while invalid UTF-8 in Go -// strings and unpaired surrogate escapes in raw encodings are still visible. +// MarshalJSON encodes a wire value without replacing malformed Unicode, so +// invalid UTF-8 in Go strings and unpaired surrogate escapes in raw encodings +// are refused rather than silently repaired. func MarshalJSON(value any) ([]byte, error) { // Preserve the standard encoder's field, omission, number and method // rules, changing only Unicode replacement. Validating custom text diff --git a/internal/profile/go/json_test.go b/internal/profile/go/json_test.go index 6d1cc65..712d6e1 100644 --- a/internal/profile/go/json_test.go +++ b/internal/profile/go/json_test.go @@ -1,4 +1,4 @@ -package runtime +package profile import ( "encoding/json" @@ -51,10 +51,6 @@ func TestStrictEncoderPreservesValidEncoding(t *testing.T) { Empty string `json:",omitempty"` Number int `json:",string"` }{embedded{"<๐Ÿ˜€๏ฟฝ>"}, string([]byte{0xff}), "", 42}, - struct { - Absent Optional[string] `json:",omitzero"` - Null Nullable[string] - }{Optional[string]{Value: string([]byte{0xff})}, Null[string]()}, map[string]any{"z": json.Number("1e100"), "a": []byte{0xff, 0xfe}, "raw": json.RawMessage(`{"a": 1}`)}, } { want, err := json.Marshal(value) diff --git a/internal/profile/go/path_test.go b/internal/profile/go/path_test.go new file mode 100644 index 0000000..206a903 --- /dev/null +++ b/internal/profile/go/path_test.go @@ -0,0 +1,30 @@ +package profile + +import ( + "slices" + "testing" +) + +func TestPathEncodingIsCanonical(t *testing.T) { + for _, path := range [][]string{{}, {""}, {"a/b"}, {"a", "b"}, {"รฉ", "", "0:"}} { + encoded, err := EncodePath(path) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodePath(encoded) + if err != nil || !slices.Equal(decoded, path) { + t.Fatalf("%q -> %q -> %q, %v", path, encoded, decoded, err) + } + } + for _, encoded := range []string{"1", ":", "01:a", "2:a", "1:\xff", "x:", "-1:a"} { + if _, err := DecodePath(encoded); err == nil { + t.Fatalf("accepted %q", encoded) + } + } + if _, err := EncodePath([]string{"\xff"}); err == nil { + t.Fatal("encoded invalid UTF-8") + } + if got, _ := EncodePath([]string{"echo"}); got != "4:echo" { + t.Fatal(got) + } +} diff --git a/internal/profile/go/scalar.go b/internal/profile/go/scalar.go index 89424fc..6c2f230 100644 --- a/internal/profile/go/scalar.go +++ b/internal/profile/go/scalar.go @@ -1,18 +1,15 @@ -// Package scalarjson guards Unicode before encoding/json can replace it. -// It is independent of the declaration language and the wire profile. -package scalarjson +package profile import ( "errors" - "reflect" "unicode/utf8" ) var errUnicode = errors.New("invalid Unicode: expected Unicode scalar strings") -// Raw checks original JSON text, including overwritten members. JSON syntax +// RawUnicode checks original JSON text, including overwritten members. JSON syntax // remains the decoder's job; this scan only rules out lossy string decoding. -func Raw(data []byte) error { +func RawUnicode(data []byte) error { if !utf8.Valid(data) { return errUnicode } @@ -72,70 +69,3 @@ func hexUnit(data []byte, start int) (uint16, bool) { } return unit, true } - -// Value checks in-memory descriptor and type-expression strings. It invokes -// no encoding hooks: a descriptor's spelling is what the interpreter reads. -func Value(value any) error { return check(reflect.ValueOf(value), map[visit]bool{}) } - -type visit struct { - typ reflect.Type - ptr uintptr -} - -func check(value reflect.Value, seen map[visit]bool) error { - if !value.IsValid() { - return nil - } - switch value.Kind() { - case reflect.Interface: - if value.IsNil() { - return nil - } - return check(value.Elem(), seen) - case reflect.Pointer, reflect.Map, reflect.Slice: - if value.IsNil() { - return nil - } - key := visit{value.Type(), value.Pointer()} - if seen[key] { - return nil - } - seen[key] = true - defer delete(seen, key) - } - switch value.Kind() { - case reflect.String: - if !utf8.ValidString(value.String()) { - return errUnicode - } - case reflect.Pointer: - return check(value.Elem(), seen) - case reflect.Map: - iter := value.MapRange() - for iter.Next() { - if err := check(iter.Key(), seen); err != nil { - return err - } - if err := check(iter.Value(), seen); err != nil { - return err - } - } - case reflect.Slice, reflect.Array: - for i := 0; i < value.Len(); i++ { - if err := check(value.Index(i), seen); err != nil { - return err - } - } - case reflect.Struct: - for i := 0; i < value.NumField(); i++ { - field := value.Type().Field(i) - if !field.IsExported() { - continue - } - if err := check(value.Field(i), seen); err != nil { - return err - } - } - } - return nil -} diff --git a/internal/profile/go/unicode_test.go b/internal/profile/go/unicode_test.go index 82b0b4f..a986623 100644 --- a/internal/profile/go/unicode_test.go +++ b/internal/profile/go/unicode_test.go @@ -1,16 +1,16 @@ -package runtime +package profile import ( "encoding/json" "os" - "strings" + "path/filepath" "testing" ) -// TestUnicodeDomain is the same source table read by the TypeScript runtime. -// Decode only the table wrapper; the runtime must see each original JSON text. +// TestUnicodeDomain reads the same bitwire/1 table as the TypeScript runtime. +// Decode only the table wrapper; the decoder must see each original JSON text. func TestUnicodeDomain(t *testing.T) { - data, err := os.ReadFile("../../conformance/tables/unicode.json") + data, err := os.ReadFile(filepath.Join("..", "..", "..", "vectors", "bitwire-1", "unicode.json")) if err != nil { t.Fatal(err) } @@ -23,58 +23,40 @@ func TestUnicodeDomain(t *testing.T) { if err := json.Unmarshal(data, &table); err != nil { t.Fatal(err) } - schema := MustSchema(`{"types":{}}`, "", nil) + if len(table.Rows) == 0 { + t.Fatal("empty unicode table") + } for _, row := range table.Rows { t.Run(row.Name, func(t *testing.T) { - err := schema.ValidateExpressionRaw("json", []byte(row.Raw)) - if (err == nil) != row.Valid { + err := RawUnicode([]byte(row.Raw)) + if (err == nil) != row.Valid && row.Valid { t.Fatalf("raw valid=%v: %v", row.Valid, err) } - if !row.Valid && err.Error() != "invalid Unicode: expected Unicode scalar strings" { - t.Fatalf("diagnostic: %v", err) - } frame := `{"version":1,"kind":"event","event":"probe","data":` + row.Raw + `}` - _, err = decodeFrame([]byte(frame)) + _, err = Decode([]byte(frame)) if (err == nil) != row.Valid { t.Fatalf("frame valid=%v: %v", row.Valid, err) } - // Even an unused descriptor extension cannot normalize a string. - _, err = NewSchema([]byte(`{"types":{},"extension":`+row.Raw+`}`), "", nil) - if (err == nil) != row.Valid { - t.Fatalf("descriptor valid=%v: %v", row.Valid, err) - } }) } } func TestUnicodeBeforeGoJSONReplacement(t *testing.T) { - schema := MustSchema(`{"types":{}}`, "", nil) - for _, raw := range [][]byte{[]byte{'"', 0xff, '"'}, []byte{'"', 0xed, 0xa0, 0x80, '"'}} { - if err := schema.ValidateExpressionRaw("json", raw); err == nil { + for _, raw := range [][]byte{{'"', 0xff, '"'}, {'"', 0xed, 0xa0, 0x80, '"'}} { + if err := RawUnicode(raw); err == nil { t.Fatal("accepted malformed UTF-8") } - if _, err := NewSchema(append(append([]byte(`{"types":{},"x":`), raw...), '}'), "", nil); err == nil { - t.Fatal("accepted malformed descriptor UTF-8") - } } for _, value := range []any{ map[json.Number]int{json.Number(string([]byte{0xff})): 1}, string([]byte{0xff}), map[string]any{"nested": []any{string([]byte{0xff})}}, map[string]int{string([]byte{0xff}): 1}, json.RawMessage(`"\uD800"`), - unicodeRaw(`"\uD800"`), Some(string([]byte{0xff})), NonNull(string([]byte{0xff})), + unicodeRaw(`"\uD800"`), } { - if err := schema.ValidateValue("json", value); err == nil { - t.Errorf("accepted malformed value %T", value) - } - } - for _, raw := range []string{`{"types":{"Literal":{"kind":"alias","type":{"literal":"\uD800"}}}}`, `{"types":{"Enum":{"kind":"enum","values":["\uDC00"]}}}`, `{"types":{"Union":{"kind":"union","tag":"kind","value":"value","variants":{"\uD800":{"empty":true}}}}}`} { - if _, err := NewSchema([]byte(raw), "", nil); err == nil { - t.Fatalf("descriptor normalized: %s", raw) + if _, err := MarshalJSON(value); err == nil { + t.Errorf("encoded malformed value %T", value) } } - if err := schema.ValidateExpressionRaw(map[string]any{"literal": string([]byte{0xff})}, []byte(`"๏ฟฝ"`)); err == nil || !strings.Contains(err.Error(), "Unicode") { - t.Fatalf("in-memory expression: %v", err) - } } type unicodeRaw string diff --git a/transports/go/pipe.go b/transports/go/pipe.go index b169b5c..4c9db1e 100644 --- a/transports/go/pipe.go +++ b/transports/go/pipe.go @@ -1,4 +1,4 @@ -package duplex +package transports import ( "context" @@ -11,7 +11,8 @@ import ( // as a socket holds some bytes; past that a Send waits for a Receive, so // the pipe carries backpressure as a transport does. A Close on one end is // a CloseError on the other; an Abort is CodeAbnormalClosure there, as a -// dropped socket would be. It carries the duplex profile in tests without +// dropped socket would be. A frame over the receiver's limit ends the pipe +// with CodeTooLarge on both sides. It carries the protocol in tests without // a socket, and it is the transport a protocol is held to before a real one // is. func Pipe(limit int64) (Conn, Conn) { @@ -88,8 +89,10 @@ func (e *pipeEnd) Receive(ctx context.Context) (Frame, error) { } deliver := func(frame Frame) (Frame, error) { if e.limit > 0 && int64(len(frame.Data)) > e.limit { - _ = e.Abort() - return Frame{}, fmt.Errorf("duplex frame of %d bytes exceeds the receive limit of %d", len(frame.Data), e.limit) + // The receiver refuses with 1009, as a WebSocket closes on its read + // limit, so the sender observes why rather than an abort. + e.end(&CloseError{Code: CodeTooLarge, Reason: "frame exceeds the receive limit"}) + return Frame{}, fmt.Errorf("transport frame of %d bytes exceeds the receive limit of %d", len(frame.Data), e.limit) } return frame, nil } @@ -113,6 +116,9 @@ func (e *pipeEnd) Receive(ctx context.Context) (Frame, error) { } func (e *pipeEnd) Close(ctx context.Context, code Code, reason string) error { + if !Sendable(code) { + return ErrUnsendableCode + } e.end(&CloseError{Code: code, Reason: reason}) return nil } diff --git a/transports/go/pipe_test.go b/transports/go/pipe_test.go index e04dc1c..52267f8 100644 --- a/transports/go/pipe_test.go +++ b/transports/go/pipe_test.go @@ -1,16 +1,16 @@ -package duplex_test +package transports_test import ( "testing" - "github.com/Bitspark/nightseam/duplex/go" - "github.com/Bitspark/nightseam/duplex/go/duplextest" + transports "github.com/Bitspark/bitruntime/transports/go" + "github.com/Bitspark/bitruntime/transports/go/transporttest" ) // TestPipeIsAConformingTransport: the in-memory pipe keeps every promise of // the seam, so a protocol proven over it is proven over the seam. func TestPipeIsAConformingTransport(t *testing.T) { - duplextest.Run(t, func(t *testing.T, limit int64) (duplex.Conn, duplex.Conn) { - return duplex.Pipe(limit) + transporttest.Run(t, func(t *testing.T, limit int64) (transports.Conn, transports.Conn) { + return transports.Pipe(limit) }) } diff --git a/transports/go/transport.go b/transports/go/transport.go index 99b07a1..7532132 100644 --- a/transports/go/transport.go +++ b/transports/go/transport.go @@ -1,33 +1,34 @@ -// Package duplex is the seam beneath every protocol: a -// frames duplex connection. It is ordered, message-framed, bidirectional and -// closed explicitly with a code and a reason, and it is nothing else โ€” no -// JSON, no requests, no correlation, no events, no reconnection. The -// nightseam.duplex/1 profile runs over it, and through the profile every -// family of API; beneath it the transport is a WebSocket today -// and may be something else tomorrow, and neither side of the seam knows -// which. +// Package transports is the seam beneath every carrier: a frames duplex +// connection. It is ordered, message-framed, bidirectional and closed +// explicitly with a code and a reason, and it is nothing else โ€” no JSON, no +// requests, no correlation, no events, no reconnection. The bitwire/1 protocol +// engine runs over it; beneath it the transport is an in-memory pipe, a +// WebSocket or a framed byte stream, and neither side of the seam knows which. // // Framing is the transport's: a WebSocket has message boundaries of its own, -// a byte stream would need a framing of its own, and either way a frame -// arrives whole or not at all. Close semantics travel: the codes are the -// WebSocket registry's numbers on every transport, so that a policy -// violation or an oversized frame is refused the same way everywhere. -package duplex +// a byte stream needs a framing of its own, and either way a frame arrives +// whole or not at all. Close semantics travel: the codes are the WebSocket +// registry's numbers on every transport, so that a policy violation or an +// oversized frame is refused the same way everywhere. +// +// This package also holds the one classification of closed errors that every +// bitruntime carrier, operator and helper reports: ErrClosed. +package transports import ( "context" "errors" "fmt" - bitwire "github.com/Bitspark/bitwire/wire/go" + wire "github.com/Bitspark/bitwire/wire/go" ) -// Kind is what a frame carries: text, which the profile requires to be JSON, +// Kind is what a frame carries: text, which the protocol requires to be JSON, // or bytes. type Kind int const ( - // Text is a frame of UTF-8 text, what the profile's JSON envelopes travel as. + // Text is a frame of UTF-8 text, what the protocol's JSON envelopes travel as. Text Kind = iota + 1 // Binary is a frame of bytes, opaque to the seam. Binary @@ -35,7 +36,7 @@ const ( // ErrNoKind is refused by every transport for a frame whose Kind is neither // Text nor Binary. -var ErrNoKind = errors.New("duplex frame of no kind") +var ErrNoKind = errors.New("bitruntime: transport frame of no kind") func (k Kind) String() string { switch k { @@ -53,9 +54,10 @@ type Frame struct { Data []byte } -// Code is a close code. The numbers are the WebSocket registry's, kept on -// every transport so that a close means the same thing whatever carried it. -type Code = bitwire.Code +// Code is a close code, the Bitwire contract's own type. The numbers are the +// WebSocket registry's, kept on every transport so that a close means the +// same thing whatever carried it. +type Code = wire.Code const ( // CodeNormal is a close both sides meant. @@ -69,10 +71,10 @@ const ( // such as a binary frame where JSON text was expected. CodeUnsupportedData Code = 1003 // CodeNoStatus is what a side sees when the other closed with no code: - // never sent, only read. + // never sent, only observed. CodeNoStatus Code = 1005 // CodeAbnormalClosure is what a side sees when the other ended with no - // close at all: an abort, or a dropped transport. + // close at all: an abort, or a dropped transport. Never sent, only observed. CodeAbnormalClosure Code = 1006 // CodePolicyViolation is a frame that parses and is refused anyway. CodePolicyViolation Code = 1008 @@ -80,22 +82,49 @@ const ( CodeTooLarge Code = 1009 // CodeInternalError is a failure of the receiver's own. CodeInternalError Code = 1011 + // CodeTLSHandshake is what a side sees when a TLS handshake failed. Never + // sent, only observed. + CodeTLSHandshake Code = 1015 ) // Application codes are the range a protocol above the seam may use for its -// own reasons; the duplex profile closes with CodeDuplex. +// own reasons; bitwire/1 closes with CodeProtocol. const ( CodeApplicationFirst Code = 4000 CodeApplicationLast Code = 4999 - CodeDuplex Code = 4011 + CodeProtocol Code = 4011 ) -// ErrClosed is what Send and Receive return once the connection was closed -// or aborted on this side. -var ErrClosed = errors.New("duplex connection closed") +// Sendable says whether a close code may be sent. Codes that only describe +// what a side observed โ€” no status, an abnormal closure, a failed TLS +// handshake โ€” are never transmitted; an abort is how a side ends without one. +// Codes outside the WebSocket registry's usable ranges are not sendable either. +func Sendable(code Code) bool { + switch { + case code == CodeNoStatus, code == CodeAbnormalClosure, code == CodeTLSHandshake, code == 1004: + return false + case code >= 1000 && code <= 1014: + return true + case code >= 3000 && code <= 4999: + return true + } + return false +} + +// ErrClosed is the one classification of a closed carrier. A transport's Send +// and Receive return it once the connection was closed or aborted on this +// side; every bitruntime endpoint, operator and helper reports a closed or +// ended carrier as an error for which errors.Is(err, ErrClosed) holds, whatever +// layer noticed it. +var ErrClosed = errors.New("bitruntime: closed") + +// ErrUnsendableCode refuses a Close whose code may only be observed. Nothing +// is sent and the connection stays as it was; Abort ends it without a code. +var ErrUnsendableCode = errors.New("bitruntime: close code may only be observed") // CloseError is what Receive returns once the remote side closed: the code -// and the reason it gave, which a protocol above may act on. +// and the reason it gave, which a protocol above may act on. It is also a +// closed carrier, so errors.Is(err, ErrClosed) holds for it. type CloseError struct { Code Code Reason string @@ -103,11 +132,14 @@ type CloseError struct { func (e *CloseError) Error() string { if e.Reason == "" { - return fmt.Sprintf("duplex connection closed by the remote side (%d)", int(e.Code)) + return fmt.Sprintf("connection closed by the remote side (%d)", int(e.Code)) } - return fmt.Sprintf("duplex connection closed by the remote side (%d): %s", int(e.Code), e.Reason) + return fmt.Sprintf("connection closed by the remote side (%d): %s", int(e.Code), e.Reason) } +// Is classifies a remote close as a closed carrier. +func (e *CloseError) Is(target error) bool { return target == ErrClosed } + // Conn is a frames duplex connection. A transport implements it; a protocol // uses it and nothing beneath it. Send and Receive may be called // concurrently with each other, and each in turn from one goroutine at a @@ -122,15 +154,17 @@ type Conn interface { Send(ctx context.Context, frame Frame) error // Receive returns the next frame in the order it was sent. A frame larger // than the connection's receive limit is not delivered: Receive returns - // an error and the connection is dead, because a limit that could be - // exceeded first is not a limit. + // an error and the connection ends with CodeTooLarge, because a limit that + // could be exceeded first is not a limit. Receive(ctx context.Context) (Frame, error) // Close ends the connection with a code and a reason the remote side // will see, waiting for its acknowledgement until ctx ends where the - // transport has one. + // transport has one. A code that is not Sendable is refused with + // ErrUnsendableCode and nothing is sent. Close(ctx context.Context, code Code, reason string) error // Abort ends the connection at once, with no handshake and nothing sent, - // and releases every blocked Send and Receive. It is what a protocol - // does when the remote side has misbehaved or the consumer has stalled. + // and releases every blocked Send and Receive. The remote side observes + // CodeAbnormalClosure. It is what a protocol does when the remote side + // has misbehaved or the consumer has stalled. Abort() error } diff --git a/transports/go/transporttest/conformance.go b/transports/go/transporttest/conformance.go index 6e08ca2..5bfd6f7 100644 --- a/transports/go/transporttest/conformance.go +++ b/transports/go/transporttest/conformance.go @@ -1,11 +1,12 @@ -// Package duplextest holds every transport to what a frames duplex +// Package transporttest holds every transport to what a frames duplex // connection promises. A transport's own tests call Run with a way to make // a connected pair; what Run checks is the seam's contract and nothing of // the transport, so a protocol written against the seam can trust the same -// four things wherever it runs: frames arrive in order and whole, a send -// waits when the receiver does not, a close carries its code and reason -// across, and a frame over the limit is refused before it is delivered. -package duplextest +// things wherever it runs: frames arrive in order and whole, a send waits +// when the receiver does not, a close carries its code and reason across, +// an observe-only code is never sent, and a frame over the limit is refused +// before it is delivered, with 1009 for its sender. +package transporttest import ( "bytes" @@ -15,13 +16,13 @@ import ( "testing" "time" - "github.com/Bitspark/nightseam/duplex/go" + transports "github.com/Bitspark/bitruntime/transports/go" ) // Connect makes a fresh connected pair with the given receive limit on both // ends. The test closes what it opened; the transport may register cleanup // with t. -type Connect func(t *testing.T, limit int64) (a, b duplex.Conn) +type Connect func(t *testing.T, limit int64) (a, b transports.Conn) // Run holds a transport to the seam. func Run(t *testing.T, connect Connect) { @@ -36,11 +37,11 @@ func Run(t *testing.T, connect Connect) { errs := make(chan error, 1) go func() { for i := 0; i < n; i++ { - kind := duplex.Text + kind := transports.Text if i%3 == 0 { - kind = duplex.Binary + kind = transports.Binary } - if err := a.Send(ctx, duplex.Frame{Kind: kind, Data: []byte(fmt.Sprintf("frame %02d", i))}); err != nil { + if err := a.Send(ctx, transports.Frame{Kind: kind, Data: []byte(fmt.Sprintf("frame %02d", i))}); err != nil { errs <- err return } @@ -52,9 +53,9 @@ func Run(t *testing.T, connect Connect) { if err != nil { t.Fatalf("receive %d: %v", i, err) } - want := duplex.Text + want := transports.Text if i%3 == 0 { - want = duplex.Binary + want = transports.Binary } if frame.Kind != want || !bytes.Equal(frame.Data, []byte(fmt.Sprintf("frame %02d", i))) { t.Fatalf("frame %d arrived as %s %q", i, frame.Kind, frame.Data) @@ -75,7 +76,7 @@ func Run(t *testing.T, connect Connect) { // more and Send must wait โ€” until the context ends, not forever, and // not into a buffer of its own that hides the stall. for i := 0; i < 400; i++ { - err := a.Send(ctx, duplex.Frame{Kind: duplex.Binary, Data: big}) + err := a.Send(ctx, transports.Frame{Kind: transports.Binary, Data: big}) if err == nil { continue } @@ -92,30 +93,52 @@ func Run(t *testing.T, connect Connect) { defer b.Abort() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := a.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte("last")}); err != nil { + if err := a.Send(ctx, transports.Frame{Kind: transports.Text, Data: []byte("last")}); err != nil { t.Fatal(err) } if frame, err := b.Receive(ctx); err != nil || string(frame.Data) != "last" { t.Fatalf("receive before close: %q, %v", frame.Data, err) } - go func() { _ = a.Close(ctx, duplex.CodePolicyViolation, "an observer sent a deciding frame") }() + go func() { _ = a.Close(ctx, transports.CodePolicyViolation, "an observer sent a deciding frame") }() _, err := b.Receive(ctx) - var closed *duplex.CloseError + var closed *transports.CloseError if !errors.As(err, &closed) { t.Fatalf("receive after the remote closed: %v", err) } - if closed.Code != duplex.CodePolicyViolation || closed.Reason != "an observer sent a deciding frame" { + if closed.Code != transports.CodePolicyViolation || closed.Reason != "an observer sent a deciding frame" { t.Fatalf("the close arrived as %d %q", closed.Code, closed.Reason) } if _, err := b.Receive(ctx); err == nil { t.Fatal("a closed connection received") } - if err := a.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte("late")}); !errors.Is(err, duplex.ErrClosed) { + if err := a.Send(ctx, transports.Frame{Kind: transports.Text, Data: []byte("late")}); !errors.Is(err, transports.ErrClosed) { t.Fatalf("send after closing: %v", err) } - if _, err := a.Receive(ctx); !errors.Is(err, duplex.ErrClosed) { + if _, err := a.Receive(ctx); !errors.Is(err, transports.ErrClosed) { t.Fatalf("receive after closing: %v", err) } + if !errors.Is(closed, transports.ErrClosed) { + t.Fatal("a remote close is not classified as a closed carrier") + } + }) + t.Run("an observe-only code is never sent", func(t *testing.T) { + a, b := connect(t, 1<<20) + defer a.Abort() + defer b.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, code := range []transports.Code{transports.CodeNoStatus, transports.CodeAbnormalClosure, transports.CodeTLSHandshake, 0, 999, 2000, 5000} { + if err := a.Close(ctx, code, ""); !errors.Is(err, transports.ErrUnsendableCode) { + t.Fatalf("close with %d: %v", code, err) + } + } + // The refused closes sent nothing and ended nothing. + if err := a.Send(ctx, transports.Frame{Kind: transports.Text, Data: []byte("still open")}); err != nil { + t.Fatal(err) + } + if frame, err := b.Receive(ctx); err != nil || string(frame.Data) != "still open" { + t.Fatalf("after refused closes: %q, %v", frame.Data, err) + } }) t.Run("a frame over the limit is refused, and the connection with it", func(t *testing.T) { a, b := connect(t, 1024) @@ -123,13 +146,21 @@ func Run(t *testing.T, connect Connect) { defer b.Abort() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - go func() { _ = a.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: bytes.Repeat([]byte("y"), 1025)}) }() + go func() { _ = a.Send(ctx, transports.Frame{Kind: transports.Text, Data: bytes.Repeat([]byte("y"), 1025)}) }() if frame, err := b.Receive(ctx); err == nil { t.Fatalf("a frame of %d bytes was delivered over a limit of 1024", len(frame.Data)) } if _, err := b.Receive(ctx); err == nil { t.Fatal("the connection received again after refusing a frame over the limit") } + // The sender learns that the connection ended. Where the transport + // carries the receiver's close to it, the code it observes is 1009; a + // byte stream that loses the close to its own teardown reads 1006. + _, err := a.Receive(ctx) + var closed *transports.CloseError + if !errors.As(err, &closed) || (closed.Code != transports.CodeTooLarge && closed.Code != transports.CodeAbnormalClosure) { + t.Fatalf("the sender of a frame over the limit observed %v, not 1009", err) + } }) t.Run("an abort ends the connection at once on both sides", func(t *testing.T) { a, b := connect(t, 1<<20) @@ -146,7 +177,7 @@ func Run(t *testing.T, connect Connect) { if err := <-received; err == nil || errors.Is(err, context.DeadlineExceeded) { t.Fatalf("the remote side of an aborted connection did not end on abort: %v", err) } - if _, err := a.Receive(ctx); !errors.Is(err, duplex.ErrClosed) { + if _, err := a.Receive(ctx); !errors.Is(err, transports.ErrClosed) { t.Fatalf("receive on the aborted side: %v", err) } }) diff --git a/transports/websocket/go/websocket.go b/transports/websocket/go/websocket.go index 338dac6..ddd3607 100644 --- a/transports/websocket/go/websocket.go +++ b/transports/websocket/go/websocket.go @@ -1,8 +1,7 @@ -// Package ws carries a frames duplex connection over a WebSocket. It is the -// one transport there is today, and the only package that knows the -// seam is a WebSocket: a message is a frame, a close frame is a close, and -// the read limit is the receive limit. -package ws +// Package websocket carries a frames duplex connection over a WebSocket, and +// is the only package that knows the seam is one: a message is a frame, a +// close frame is a close, and the read limit is the receive limit. +package websocket import ( "context" @@ -12,14 +11,14 @@ import ( "github.com/coder/websocket" - "github.com/Bitspark/nightseam/duplex/go" + transports "github.com/Bitspark/bitruntime/transports/go" ) // New wraps an open WebSocket as a frames duplex connection with the given -// receive limit: a message larger than it fails the read and the -// connection, as the seam requires. Never read or write the WebSocket after -// handing it over. -func New(conn *websocket.Conn, limit int64) duplex.Conn { +// receive limit: a message larger than it fails the read and closes the +// connection with 1009, as the seam requires. Never read or write the +// WebSocket after handing it over. +func New(conn *websocket.Conn, limit int64) transports.Conn { conn.SetReadLimit(limit) return &connection{conn: conn} } @@ -42,29 +41,29 @@ func (c *connection) end() { c.mu.Unlock() } -func kindOf(t websocket.MessageType) (duplex.Kind, error) { +func kindOf(t websocket.MessageType) (transports.Kind, error) { switch t { case websocket.MessageText: - return duplex.Text, nil + return transports.Text, nil case websocket.MessageBinary: - return duplex.Binary, nil + return transports.Binary, nil } return 0, fmt.Errorf("websocket message of unknown type %d", int(t)) } -func typeOf(k duplex.Kind) (websocket.MessageType, error) { +func typeOf(k transports.Kind) (websocket.MessageType, error) { switch k { - case duplex.Text: + case transports.Text: return websocket.MessageText, nil - case duplex.Binary: + case transports.Binary: return websocket.MessageBinary, nil } - return 0, duplex.ErrNoKind + return 0, transports.ErrNoKind } -func (c *connection) Send(ctx context.Context, frame duplex.Frame) error { +func (c *connection) Send(ctx context.Context, frame transports.Frame) error { if c.closed() { - return duplex.ErrClosed + return transports.ErrClosed } t, err := typeOf(frame.Kind) if err != nil { @@ -76,45 +75,62 @@ func (c *connection) Send(ctx context.Context, frame duplex.Frame) error { return nil } -func (c *connection) Receive(ctx context.Context) (duplex.Frame, error) { +func (c *connection) Receive(ctx context.Context) (transports.Frame, error) { if c.closed() { - return duplex.Frame{}, duplex.ErrClosed + return transports.Frame{}, transports.ErrClosed } t, data, err := c.conn.Read(ctx) if err != nil { - return duplex.Frame{}, c.translate(ctx, err) + if errors.Is(err, websocket.ErrMessageTooBig) { + // The library has sent its 1009 and would now wait for a close + // handshake the sender has no reason to start. The connection is + // dead by the seam's contract, so end it: the sender observes the + // 1009 if it read it first, or the dropped transport otherwise. + c.end() + _ = c.conn.CloseNow() + return transports.Frame{}, err + } + return transports.Frame{}, c.translate(ctx, err) } kind, err := kindOf(t) if err != nil { _ = c.Abort() - return duplex.Frame{}, err + return transports.Frame{}, err } - return duplex.Frame{Kind: kind, Data: data}, nil + return transports.Frame{Kind: kind, Data: data}, nil } // translate says what an error of the WebSocket means at the seam: the // context's own end, ErrClosed once this side ended the connection, the -// remote side's close with its code and reason, or the failure itself. +// remote side's close with its code and reason, or โ€” for any other failure of +// a connection that is now unusable โ€” an abnormal closure, which is what a side +// observes when the transport dropped. Every outcome but the context's is a +// closed carrier. func (c *connection) translate(ctx context.Context, err error) error { if ctx.Err() != nil && errors.Is(err, ctx.Err()) { return ctx.Err() } if c.closed() { - return duplex.ErrClosed + return transports.ErrClosed } var closeErr websocket.CloseError if errors.As(err, &closeErr) { - return &duplex.CloseError{Code: duplex.Code(closeErr.Code), Reason: closeErr.Reason} + return &transports.CloseError{Code: transports.Code(closeErr.Code), Reason: closeErr.Reason} } - return err + return &transports.CloseError{Code: transports.CodeAbnormalClosure, Reason: err.Error()} } // Close sends a close frame with the code and reason and waits for the // remote side's acknowledgement; the WebSocket library bounds that wait -// itself, so ctx is not consulted. -func (c *connection) Close(ctx context.Context, code duplex.Code, reason string) error { +// itself, so ctx is not consulted. An observe-only code is refused before the +// library sees it: the library would refuse to send 1006 and then drop the +// socket anyway, leaving the remote side to observe the very code refused. +func (c *connection) Close(ctx context.Context, code transports.Code, reason string) error { + if !transports.Sendable(code) { + return transports.ErrUnsendableCode + } if c.closed() { - return duplex.ErrClosed + return transports.ErrClosed } c.end() return c.conn.Close(websocket.StatusCode(code), reason) diff --git a/transports/websocket/go/websocket_test.go b/transports/websocket/go/websocket_test.go index dd9c12b..bd579de 100644 --- a/transports/websocket/go/websocket_test.go +++ b/transports/websocket/go/websocket_test.go @@ -1,4 +1,4 @@ -package ws_test +package websocket_test import ( "context" @@ -10,13 +10,13 @@ import ( "github.com/coder/websocket" - "github.com/Bitspark/nightseam/duplex/go" - "github.com/Bitspark/nightseam/duplex/go/duplextest" - "github.com/Bitspark/nightseam/duplex/go/ws" + transports "github.com/Bitspark/bitruntime/transports/go" + "github.com/Bitspark/bitruntime/transports/go/transporttest" + ws "github.com/Bitspark/bitruntime/transports/websocket/go" ) // connect opens a WebSocket pair over a test server and wraps both ends. -func connect(t *testing.T, limit int64) (duplex.Conn, duplex.Conn) { +func connect(t *testing.T, limit int64) (transports.Conn, transports.Conn) { t.Helper() accepted := make(chan *websocket.Conn, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -41,5 +41,5 @@ func connect(t *testing.T, limit int64) (duplex.Conn, duplex.Conn) { // TestWebSocketIsAConformingTransport: a WebSocket keeps every promise of // the seam, so the profile and the relays above it may forget it is one. func TestWebSocketIsAConformingTransport(t *testing.T) { - duplextest.Run(t, connect) + transporttest.Run(t, connect) } diff --git a/vectors/bitwire-1/README.md b/vectors/bitwire-1/README.md new file mode 100644 index 0000000..ae59a34 --- /dev/null +++ b/vectors/bitwire-1/README.md @@ -0,0 +1,18 @@ +# bitwire/1 envelope vectors + +Byte-identical copies of Nightseam v0.6.0's profile tables, taken from +`conformance/tables/` at `5cc9723a24646c40ed1861f892b2b23eb6d785d7` (tag +`v0.6.0`). Bitwire decision 0008 defines `bitwire/1` as that release's +behavior, so these rows state what a `bitwire/1` peer accepts and refuses. + +| File | Holds | +| --- | --- | +| `frames.json` | Envelopes a peer of each role accepts or refuses | +| `serials.json` | Request-serial sequences a receiver admits or refuses | +| `unicode.json` | JSON texts whose strings are, or are not, Unicode scalar values | + +The Go and TypeScript native tests read these files. They are implementation +evidence, not Bitwire's independent conformance: Bitwire publishes the +normative tables and manifest of `bitwire/1` under its issue #39, and its +cases judge released bitruntime from a test-only module. When that +publication exists, these copies are replaced by it rather than edited. diff --git a/vectors/bitwire-1/frames.json b/vectors/bitwire-1/frames.json new file mode 100644 index 0000000..b4e9509 --- /dev/null +++ b/vectors/bitwire-1/frames.json @@ -0,0 +1,621 @@ +{ + "description": "Every envelope a peer must accept or refuse, as raw text: what the frame is, whether it is valid, and which side it is addressed to โ€” a server (it carries a client's ids, c:N, and answers a server's, s:N), a client, or either. The meta rows hold the carriage a request and an event may take: an object of strings, empty or not, refused on a response and a cancel, in any other form, and under the reserved nightseam. prefix. A refused frame ends the connection; the harness sends each row over a raw connection to a peer of the named role and holds that the peer ended, or did not.", + "rows": [ + { + "name": "valid Unicode payload", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"probe\",\"data\":{\"\\uD83D\\uDE00\":[\"\\uFFFD\",\"\\\\uD800\"]}}", + "valid": true, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired high surrogate in payload", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"probe\",\"data\":\"\\uD800\"}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired low surrogate in nested payload", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"probe\",\"data\":{\"items\":[\"\\uDC00\"]}}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired surrogate in payload key", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"probe\",\"data\":{\"\\uD800\":1}}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "overwritten unpaired surrogate", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"probe\",\"data\":{\"x\":\"\\uD800\",\"x\":\"valid\"}}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired surrogate in event name", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"\\uD800\",\"data\":null}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired surrogate in metadata", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"probe\",\"data\":null,\"meta\":{\"tenant\":\"\\uD800\"}}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired surrogate in request parameters", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":[\"\\uD800\"]}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired surrogate in response", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":\"\\uD800\"}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "unpaired surrogate in public error", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"error\":{\"code\":\"busy\",\"message\":\"\\uD800\"}}", + "valid": false, + "why": "Strings contain Unicode scalar values (#170 A)." + }, + { + "name": "request", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":null}", + "valid": true, + "why": "" + }, + { + "name": "response", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":null}", + "valid": true, + "why": "a response to a request the server made, so it carries the server's id" + }, + { + "name": "response with error", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"error\":{\"code\":\"busy\",\"message\":\"Try later\"}}", + "valid": true, + "why": "" + }, + { + "name": "event", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":null}", + "valid": true, + "why": "" + }, + { + "name": "cancel", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\"}", + "valid": true, + "why": "" + }, + { + "name": "request with trace", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\",\"tracestate\":\"congo=t61rcWkgMzE\"}", + "valid": true, + "why": "" + }, + { + "name": "event with tracestate alone", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":1,\"tracestate\":\"congo=t61rcWkgMzE\"}", + "valid": true, + "why": "an intermediary may strip one member and not the other" + }, + { + "name": "not an object", + "to": "either", + "frame": "\"frame\"", + "valid": false, + "why": "" + }, + { + "name": "null", + "to": "either", + "frame": "null", + "valid": false, + "why": "" + }, + { + "name": "array", + "to": "either", + "frame": "[]", + "valid": false, + "why": "" + }, + { + "name": "empty object", + "to": "either", + "frame": "{}", + "valid": false, + "why": "" + }, + { + "name": "not json", + "to": "either", + "frame": "{bad", + "valid": false, + "why": "" + }, + { + "name": "trailing content", + "to": "either", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\"} {}", + "valid": false, + "why": "" + }, + { + "name": "duplicate member", + "to": "either", + "frame": "{\"version\":1,\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\"}", + "valid": false, + "why": "" + }, + { + "name": "wrong version", + "to": "either", + "frame": "{\"version\":2,\"kind\":\"event\",\"event\":\"progress\",\"data\":1}", + "valid": false, + "why": "" + }, + { + "name": "unknown kind", + "to": "either", + "frame": "{\"version\":1,\"kind\":\"unknown\"}", + "valid": false, + "why": "" + }, + { + "name": "unknown member", + "to": "either", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"progress\",\"data\":1,\"extra\":true}", + "valid": false, + "why": "" + }, + { + "name": "request without params", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\"}", + "valid": false, + "why": "params is required, null when there are none" + }, + { + "name": "response with result and error", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":null,\"error\":{\"code\":\"bad\",\"message\":\"bad\"}}", + "valid": false, + "why": "" + }, + { + "name": "response with null error", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"error\":null}", + "valid": false, + "why": "" + }, + { + "name": "response with result and null error", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":null,\"error\":null}", + "valid": false, + "why": "" + }, + { + "name": "response with an empty error message", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"error\":{\"code\":\"denied\",\"message\":\"\"}}", + "valid": false, + "why": "an error is a code and a message and both are non-empty; a response nobody can read is no answer" + }, + { + "name": "response with an empty error code", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"error\":{\"code\":\"\",\"message\":\"Denied\"}}", + "valid": false, + "why": "" + }, + { + "name": "event with a foreign member", + "to": "either", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":null,\"id\":\"\"}", + "valid": false, + "why": "" + }, + { + "name": "cancel with a foreign member", + "to": "either", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"event\":\"\"}", + "valid": false, + "why": "" + }, + { + "name": "request with the receiver's own id", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"s:1\",\"method\":\"wait\",\"params\":null}", + "valid": false, + "why": "a server mints s:, so a request carrying one did not come from a client" + }, + { + "name": "response to nobody", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"c:1\",\"result\":1}", + "valid": false, + "why": "a response carries the id of a request the receiver made; c:1 is the sender's own" + }, + { + "name": "request with traceparent empty", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\"\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent nonsense", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\"nonsense\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent missing flags", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent uppercase", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent short trace id", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent leading space", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\" 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent extra field", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with traceparent not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"traceparent\":7}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "request with tracestate not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":{},\"tracestate\":7}", + "valid": false, + "why": "" + }, + { + "name": "response with traceparent empty", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\"\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent nonsense", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\"nonsense\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent missing flags", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent uppercase", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent short trace id", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent leading space", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\" 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent extra field", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with traceparent not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"traceparent\":7}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "response with tracestate not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"tracestate\":7}", + "valid": false, + "why": "" + }, + { + "name": "cancel with traceparent empty", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\"\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent nonsense", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\"nonsense\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent missing flags", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent uppercase", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent short trace id", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent leading space", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\" 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent extra field", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with traceparent not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"traceparent\":7}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "cancel with tracestate not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"tracestate\":7}", + "valid": false, + "why": "" + }, + { + "name": "event with traceparent empty", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\"\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent nonsense", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\"nonsense\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent missing flags", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent uppercase", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent short trace id", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent leading space", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\" 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent extra field", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99\"}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with traceparent not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"traceparent\":7}", + "valid": false, + "why": "traceparent is the W3C form or the frame is refused" + }, + { + "name": "event with tracestate not a string", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"notice\",\"data\":null,\"tracestate\":7}", + "valid": false, + "why": "" + }, + { + "name": "request with meta", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":{\"tenant\":\"acme\",\"idempotency\":\"k-1\"}}", + "valid": true, + "why": "" + }, + { + "name": "request with meta empty", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":{}}", + "valid": true, + "why": "a carriage with nothing in it is a carriage" + }, + { + "name": "event with meta", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":1,\"meta\":{\"cause\":\"nightly\"}}", + "valid": true, + "why": "" + }, + { + "name": "event with meta and trace", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":1,\"meta\":{\"tenant\":\"acme\"},\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\"}", + "valid": true, + "why": "the members are independent; a frame may carry both" + }, + { + "name": "response with meta", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:1\",\"result\":1,\"meta\":{\"tenant\":\"acme\"}}", + "valid": false, + "why": "what a server wants to say about an answer is the result's business" + }, + { + "name": "cancel with meta", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"cancel\",\"id\":\"c:1\",\"meta\":{\"tenant\":\"acme\"}}", + "valid": false, + "why": "meta is about a call, and a cancel withdraws one rather than making it" + }, + { + "name": "request with meta not an object", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":\"acme\"}", + "valid": false, + "why": "meta is an object of strings" + }, + { + "name": "request with meta an array", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":[\"acme\"]}", + "valid": false, + "why": "meta is an object of strings" + }, + { + "name": "request with meta null", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":null}", + "valid": false, + "why": "a member spelled null is a member in another form, not an absent one" + }, + { + "name": "request with meta a number value", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":{\"attempt\":2}}", + "valid": false, + "why": "every value of meta is a string" + }, + { + "name": "request with meta a null value", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":{\"tenant\":null}}", + "valid": false, + "why": "every value of meta is a string" + }, + { + "name": "event with meta a boolean value", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":1,\"meta\":{\"live\":true}}", + "valid": false, + "why": "every value of meta is a string" + }, + { + "name": "event with meta an object value", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":1,\"meta\":{\"who\":{\"id\":\"u1\"}}}", + "valid": false, + "why": "meta is flat; a value is a string and never a payload" + }, + { + "name": "request with a reserved meta key", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"read\",\"params\":{},\"meta\":{\"nightseam.deadline\":\"2026-01-01T00:00:00Z\"}}", + "valid": false, + "why": "keys under nightseam. are the profile's and this version defines none" + }, + { + "name": "event with a reserved meta key", + "to": "server", + "frame": "{\"version\":1,\"kind\":\"event\",\"event\":\"updated\",\"data\":1,\"meta\":{\"nightseam.cause\":\"nightly\"}}", + "valid": false, + "why": "keys under nightseam. are the profile's and this version defines none" + } + ] +} diff --git a/vectors/bitwire-1/serials.json b/vectors/bitwire-1/serials.json new file mode 100644 index 0000000..8f47d2c --- /dev/null +++ b/vectors/bitwire-1/serials.json @@ -0,0 +1,37 @@ +{ + "description": "Request serials, as raw text: a frame published first on the connection and the request that follows it on the same direction, and whether the receiver admits that second request. Each row is a sequence, not a judgement about one envelope โ€” the frames are each well formed, and what is judged is the order they arrive in. Structural validity is tables/frames.json. The harness sends before, then frame, over a raw connection to a peer of the named role, and holds that the peer ended with 4011, or did not.", + "rows": [ + { + "name": "a serial lower than the one before it", + "to": "server", + "before": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:5\",\"method\":\"echo\",\"params\":1}", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:3\",\"method\":\"echo\",\"params\":1}", + "valid": false, + "why": "a serial not greater than the previous requestโ€™s is a protocol violation (#439)" + }, + { + "name": "a serial equal to the one before it", + "to": "server", + "before": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:5\",\"method\":\"echo\",\"params\":1}", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:5\",\"method\":\"echo\",\"params\":1}", + "valid": false, + "why": "greater, not greater-or-equal: equal serials leave a later control ambiguous (#439)" + }, + { + "name": "the first serial of a direction, lower than the other roleโ€™s", + "to": "server", + "before": "{\"version\":1,\"kind\":\"response\",\"id\":\"s:4\",\"result\":null}", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:1\",\"method\":\"echo\",\"params\":1}", + "valid": true, + "why": "serials are per direction: the other roleโ€™s ids do not advance this mark (#439)" + }, + { + "name": "a serial that leaves a gap", + "to": "server", + "before": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:2\",\"method\":\"echo\",\"params\":1}", + "frame": "{\"version\":1,\"kind\":\"request\",\"id\":\"c:9\",\"method\":\"echo\",\"params\":1}", + "valid": true, + "why": "gaps are allowed: a reservation that never published still spent its serial (#439)" + } + ] +} diff --git a/vectors/bitwire-1/unicode.json b/vectors/bitwire-1/unicode.json new file mode 100644 index 0000000..3ec9f4c --- /dev/null +++ b/vectors/bitwire-1/unicode.json @@ -0,0 +1,25 @@ +{ + "description": "Unicode scalar strings, tested before JSON decoding can replace malformed escapes. Each raw member is JSON source carried as a string so the table itself is well-formed Unicode.", + "rows": [ + {"name":"ASCII", "raw":"\"hello\"", "valid":true}, + {"name":"escaped emoji", "raw":"\"\\uD83D\\uDE00\"", "valid":true}, + {"name":"literal emoji", "raw":"\"๐Ÿ˜€\"", "valid":true}, + {"name":"ordinary replacement character", "raw":"\"๏ฟฝ\"", "valid":true}, + {"name":"escaped replacement character", "raw":"\"\\uFFFD\"", "valid":true}, + {"name":"surrogate spelling is ASCII text", "raw":"\"\\\\uD800\"", "valid":true}, + {"name":"scalar limits", "raw":"[\"\\uD7FF\",\"\\uE000\",\"\\uDBFF\\uDFFF\"]", "valid":true}, + {"name":"paired member name", "raw":"{\"\\uD83D\\uDE00\":1}", "valid":true}, + {"name":"high surrogate", "raw":"\"\\uD800\"", "valid":false}, + {"name":"low surrogate", "raw":"\"\\uDC00\"", "valid":false}, + {"name":"high limit", "raw":"\"\\uDBFF\"", "valid":false}, + {"name":"low limit", "raw":"\"\\uDFFF\"", "valid":false}, + {"name":"reversed pair", "raw":"\"\\uDE00\\uD83D\"", "valid":false}, + {"name":"two high surrogates", "raw":"\"\\uD800\\uDBFF\"", "valid":false}, + {"name":"separated pair", "raw":"\"\\uD83Dx\\uDE00\"", "valid":false}, + {"name":"pair split between strings", "raw":"[\"\\uD83D\",\"\\uDE00\"]", "valid":false}, + {"name":"high followed by escaped backslash", "raw":"\"\\uD800\\\\uDC00\"", "valid":false}, + {"name":"nested arbitrary JSON", "raw":"{\"a\":[{\"b\":\"\\ud800\"}]}", "valid":false}, + {"name":"malformed member name", "raw":"{\"\\uD800\":1}", "valid":false}, + {"name":"overwritten malformed value", "raw":"{\"same\":\"\\uD800\",\"same\":\"valid\"}", "valid":false} + ] +} From ff4ce22a18a028a81600e5ef1c6deb32eedce3c4 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:25:15 +0200 Subject: [PATCH 03/39] transports: gofmt the conformance suite Co-Authored-By: Claude Opus 5.5 (1M context) --- transports/go/transporttest/conformance.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/transports/go/transporttest/conformance.go b/transports/go/transporttest/conformance.go index 5bfd6f7..1311eb9 100644 --- a/transports/go/transporttest/conformance.go +++ b/transports/go/transporttest/conformance.go @@ -146,7 +146,9 @@ func Run(t *testing.T, connect Connect) { defer b.Abort() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - go func() { _ = a.Send(ctx, transports.Frame{Kind: transports.Text, Data: bytes.Repeat([]byte("y"), 1025)}) }() + go func() { + _ = a.Send(ctx, transports.Frame{Kind: transports.Text, Data: bytes.Repeat([]byte("y"), 1025)}) + }() if frame, err := b.Receive(ctx); err == nil { t.Fatalf("a frame of %d bytes was delivered over a limit of 1024", len(frame.Data)) } From 90198018ac64c67b5df7a7c90b407b7709394b01 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:36:35 +0200 Subject: [PATCH 04/39] core, dispatch, engine: adapt the v0.6.0 runtime to Bitwire 0.3.0 core/go gains the addressed operators (At, Mount, Forward), the local pair (NewPair, with its own PairOptions), the invocation lifecycle, Respond, publication evidence, meta and trace propagation. dispatch/go holds the Dispatcher and the Call/Emit/Handle/Register helpers. engine/go is the bitwire/1 peer, presented only through its root Endpoint; engine/websocket/go sets up connections (Accept, NewHandler, Dial). Received context stays private to bitruntime in internal/delivery/go, which Bitwire 0.3 permits for Go. Fixed rather than ported: - nightseam#722: a closing pair answers every queued refusal, and the peer's root answers queued requests instead of dropping them (R28); - nightseam#658: a pair response frees its call's slot before the caller can hold the answer; - R26: every ended carrier's error satisfies transports.ErrClosed and keeps its cause, so a forwarded closed or overloaded destination answers disconnected rather than internal; - research 0001 row 14: Forward fails only the refused message instead of detaching both directions; - R27: an observe-only close code aborts the peer instead of being sent. Removed: the raw method-name API (Handle, Call, Emit on the peer), the observer hooks and family labels, pending their design with Bitwire's received-context revision. bitwire/1 frames are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/addressed.go | 163 +--- core/go/addressed_test.go | 474 ----------- core/go/errors.go | 96 +++ core/go/forward.go | 67 ++ core/go/invocation.go | 82 +- core/go/invocation_experiment_test.go | 567 ------------- core/go/invocation_test.go | 519 ------------ core/go/meta.go | 83 +- core/go/pair.go | 329 +++++--- core/go/pair_test.go | 330 -------- core/go/publication.go | 4 +- core/go/publication_test.go | 187 ----- core/go/trace.go | 8 +- core/go/trace_test.go | 233 ----- dispatch/go/bitwire_test.go | 62 -- dispatch/go/dispatch.go | 235 ++++++ dispatch/go/dispatcher.go | 125 +-- dispatch/go/dispatcher_ownership_test.go | 195 ----- dispatch/go/dispatcher_test.go | 85 -- dispatch/go/wire_cancel_reservation_test.go | 211 ----- dispatch/go/wire_event_context_test.go | 161 ---- dispatch/go/wire_namespace_test.go | 406 --------- dispatch/go/wire_options_test.go | 99 --- dispatch/go/wire_send_test.go | 130 --- dispatch/go/wire_test.go | 453 ---------- dispatch/go/wire_validation_test.go | 70 -- engine/go/backpressure_test.go | 344 -------- engine/go/carriage_test.go | 341 -------- engine/go/peer.go | 887 ++++++-------------- engine/go/peer_pacing_test.go | 141 ---- engine/go/peer_test.go | 612 -------------- engine/go/prepare_test.go | 170 ---- engine/go/seam_test.go | 236 ------ engine/go/serial_test.go | 151 ---- engine/go/smoke_test.go | 112 +++ engine/go/unicode_peer_test.go | 38 - engine/go/wire.go | 748 +++-------------- engine/websocket/go/websocket.go | 112 ++- internal/delivery/go/delivery.go | 152 ++++ internal/request/go/request.go | 187 +++++ transports/websocket/go/websocket.go | 3 + 41 files changed, 1646 insertions(+), 7962 deletions(-) delete mode 100644 core/go/addressed_test.go create mode 100644 core/go/errors.go create mode 100644 core/go/forward.go delete mode 100644 core/go/invocation_experiment_test.go delete mode 100644 core/go/invocation_test.go delete mode 100644 core/go/pair_test.go delete mode 100644 core/go/publication_test.go delete mode 100644 core/go/trace_test.go delete mode 100644 dispatch/go/bitwire_test.go create mode 100644 dispatch/go/dispatch.go delete mode 100644 dispatch/go/dispatcher_ownership_test.go delete mode 100644 dispatch/go/dispatcher_test.go delete mode 100644 dispatch/go/wire_cancel_reservation_test.go delete mode 100644 dispatch/go/wire_event_context_test.go delete mode 100644 dispatch/go/wire_namespace_test.go delete mode 100644 dispatch/go/wire_options_test.go delete mode 100644 dispatch/go/wire_send_test.go delete mode 100644 dispatch/go/wire_test.go delete mode 100644 dispatch/go/wire_validation_test.go delete mode 100644 engine/go/backpressure_test.go delete mode 100644 engine/go/carriage_test.go delete mode 100644 engine/go/peer_pacing_test.go delete mode 100644 engine/go/peer_test.go delete mode 100644 engine/go/prepare_test.go delete mode 100644 engine/go/seam_test.go delete mode 100644 engine/go/serial_test.go create mode 100644 engine/go/smoke_test.go delete mode 100644 engine/go/unicode_peer_test.go create mode 100644 internal/delivery/go/delivery.go create mode 100644 internal/request/go/request.go diff --git a/core/go/addressed.go b/core/go/addressed.go index 021ed14..44cf1e9 100644 --- a/core/go/addressed.go +++ b/core/go/addressed.go @@ -1,133 +1,49 @@ -package duplex +package core import ( "errors" + "slices" "sort" - "strconv" - "strings" "sync" - "unicode/utf8" - bitwire "github.com/Bitspark/bitwire/wire/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" ) -// ProfileKind is one of the profile's four frame kinds. Correlation and -// validation remain the peer's; a wire only carries the frame. -type ProfileKind = bitwire.ProfileKind - -const ( - ProfileRequest = bitwire.ProfileRequest - ProfileResponse = bitwire.ProfileResponse - ProfileEvent = bitwire.ProfileEvent - ProfileCancel = bitwire.ProfileCancel -) - -// ProfileError is public error data, without a runtime error dependency. -type ProfileError = bitwire.ProfileError - -// ProfileFrame carries a profile frame. The Send path is the request method or -// event name; keeping it outside this value prevents contradictory names. -// Payloads retain their JSON representation, including numeric precision. -type ProfileFrame = bitwire.ProfileFrame - -// ReturnAddress is a local address with stable pointer identity, even when its -// Wire implementation is not comparable. It is never an envelope member. -type ReturnAddress = bitwire.ReturnAddress - -// Message preserves a frame and its local return capability through routing. -type Message = bitwire.Message - -// Receiver receives deliveries relative to its wire's origin, and an ending. -// A root owns asynchronous dispatch; composition does not invoke Message itself. -type Receiver = bitwire.Receiver - -// Wire grants send access without receive attachment or lifecycle control. -type Wire = bitwire.Wire - -// Endpoint owns one receive attachment and its lifecycle. Path dispatch and -// sharing among selected receiving views belong to an explicit dispatcher. -type Endpoint = bitwire.Endpoint - -var ( - ErrPath = errors.New("invalid wire path") - ErrNoRoute = errors.New("wire path has no destination") - ErrReceiverExists = errors.New("endpoint already has a receiver") -) - -// EncodePath concatenates UTF-8 byte-length-prefixed scalar-string segments. -// The empty path is "", while a single empty segment is "0:". -func EncodePath(path []string) (string, error) { - var encoded strings.Builder - for _, segment := range path { - if !utf8.ValidString(segment) { - return "", ErrPath - } - encoded.WriteString(strconv.Itoa(len(segment))) - encoded.WriteByte(':') - encoded.WriteString(segment) - } - return encoded.String(), nil -} - -// DecodePath accepts only the canonical form of EncodePath, without Unicode -// normalization or interpretation of dots, slashes or empty segments. -func DecodePath(encoded string) ([]string, error) { - path := []string{} - for encoded != "" { - colon := strings.IndexByte(encoded, ':') - if colon <= 0 { - return nil, ErrPath - } - digits := encoded[:colon] - if len(digits) > 1 && digits[0] == '0' { - return nil, ErrPath - } - for _, digit := range digits { - if digit < '0' || digit > '9' { - return nil, ErrPath - } - } - length, err := strconv.ParseUint(digits, 10, 64) - encoded = encoded[colon+1:] - if err != nil || length > uint64(len(encoded)) { - return nil, ErrPath - } - segment := encoded[:int(length)] - if !utf8.ValidString(segment) { - return nil, ErrPath - } - path = append(path, segment) - encoded = encoded[int(length):] - } - return path, nil -} +// ErrReceiverExists refuses a second receive attachment to an endpoint. +var ErrReceiverExists = errors.New("bitruntime: endpoint already has a receiver") type selectedWire struct { - root Wire + root wire.AddressedWire prefix []string } -// At selects a relative path without allocating a peer, channel or queue. -// The selection grants only send access, even when path is empty. -func At(root Wire, path []string) Wire { - return &selectedWire{root: root, prefix: append([]string{}, path...)} +// At binds a relative path prefix to addressed access without allocating a +// peer, channel or queue: at(w, []) โ‰ƒ w and at(at(w, a), b) โ‰ƒ at(w, a ++ b). +// The result grants only send access, even when path is empty. This is +// addressed prefix binding, not structural selection: it succeeds for every +// path, and whether the root admits what is sent through it is the root's +// to decide. Select is the structural operation on a WireTree. +func At(root wire.AddressedWire, path []string) wire.AddressedWire { + return &selectedWire{root: root, prefix: slices.Clone(path)} } func (w *selectedWire) path(path []string) []string { return append(append([]string{}, w.prefix...), path...) } -func (w *selectedWire) Send(path []string, message Message) error { +func (w *selectedWire) Send(path []string, message wire.Message) error { return w.root.Send(w.path(path), message) } type mountedWire struct { - children map[string]Endpoint + children map[string]wire.Endpoint mu sync.Mutex closed bool current *mountedReceiver } type mountedReceiver struct { - receiver Receiver + receiver wire.Receiver active bool children []*mountedChild remaining int @@ -140,32 +56,33 @@ type mountedChild struct { // Mount consumes one path segment and delegates to that child. The map is // copied. A mount has no leaf at []; [""] can select an empty-string key. // Its single receive attachment borrows one attachment from each child. -// Closing a mount detaches those attachments and leaves every child usable. -func Mount(children map[string]Endpoint) Endpoint { - w := &mountedWire{children: make(map[string]Endpoint, len(children))} +// Closing a mount detaches those attachments and leaves every child usable: +// at(mount({k: w}), [k]) โ‰ƒ w while the mount is open. +func Mount(children map[string]wire.Endpoint) wire.Endpoint { + w := &mountedWire{children: make(map[string]wire.Endpoint, len(children))} for key, child := range children { w.children[key] = child } return w } -func (w *mountedWire) destination(path []string) (Endpoint, error) { +func (w *mountedWire) destination(path []string) (wire.Endpoint, error) { if w.closed { - return nil, ErrClosed + return nil, transports.ErrClosed } if len(path) == 0 { - return nil, ErrNoRoute + return nil, ErrMissingPath } - if _, err := EncodePath(path); err != nil { - return nil, err + if !profile.ValidPath(path) { + return nil, ErrInvalidPath } child := w.children[path[0]] if child == nil { - return nil, ErrNoRoute + return nil, ErrMissingPath } return child, nil } -func (w *mountedWire) Send(path []string, message Message) error { +func (w *mountedWire) Send(path []string, message wire.Message) error { w.mu.Lock() child, err := w.destination(path) w.mu.Unlock() @@ -174,11 +91,11 @@ func (w *mountedWire) Send(path []string, message Message) error { } return child.Send(append([]string{}, path[1:]...), message) } -func (w *mountedWire) Receive(receiver Receiver) (func(), error) { +func (w *mountedWire) Receive(receiver wire.Receiver) (func(), error) { w.mu.Lock() if w.closed { w.mu.Unlock() - return nil, ErrClosed + return nil, transports.ErrClosed } if w.current != nil { w.mu.Unlock() @@ -204,17 +121,17 @@ func (w *mountedWire) Receive(receiver Receiver) (func(), error) { active := attachment.active w.mu.Unlock() if !active { - return nil, ErrClosed + return nil, transports.ErrClosed } - detach, err := w.children[key].Receive(Receiver{ - Message: func(path []string, message Message) { + detach, err := w.children[key].Receive(wire.Receiver{ + Message: func(path []string, message wire.Message) { // The child owns capture of accepted invocations. A retained // delivery, including cancellation, keeps its original receiver. if receiver.Message != nil { receiver.Message(append([]string{key}, path...), message) } }, - Closed: func(code Code, reason string) { w.childEnded(attachment, slot, code, reason) }, + Closed: func(code wire.Code, reason string) { w.childEnded(attachment, slot, code, reason) }, }) w.mu.Lock() active = attachment.active && !slot.ended @@ -232,14 +149,14 @@ func (w *mountedWire) Receive(receiver Receiver) (func(), error) { if err != nil { return nil, err } - return nil, ErrClosed + return nil, transports.ErrClosed } } w.mu.Lock() active := attachment.active w.mu.Unlock() if !active { - return nil, ErrClosed + return nil, transports.ErrClosed } return func() { w.remove(attachment) }, nil } @@ -274,7 +191,7 @@ func (w *mountedWire) remove(attachment *mountedReceiver) { } } -func (w *mountedWire) childEnded(attachment *mountedReceiver, child *mountedChild, code Code, reason string) { +func (w *mountedWire) childEnded(attachment *mountedReceiver, child *mountedChild, code wire.Code, reason string) { w.mu.Lock() if !attachment.active || child.ended { w.mu.Unlock() @@ -299,7 +216,7 @@ func (w *mountedWire) childEnded(attachment *mountedReceiver, child *mountedChil } } -func (w *mountedWire) Close(code Code, reason string) error { +func (w *mountedWire) Close(code wire.Code, reason string) error { w.mu.Lock() if w.closed { w.mu.Unlock() diff --git a/core/go/addressed_test.go b/core/go/addressed_test.go deleted file mode 100644 index dcdd3a9..0000000 --- a/core/go/addressed_test.go +++ /dev/null @@ -1,474 +0,0 @@ -package duplex_test - -import ( - "encoding/json" - "errors" - "reflect" - "sync" - "testing" - - "github.com/Bitspark/nightseam/duplex/go" -) - -func TestPathEncodingIsCanonicalAndComposable(t *testing.T) { - paths := [][]string{{}, {""}, {"a", "b"}, {"a.b"}, {"a", "b:c"}, {"รฉ", "e\u0301", "๐Ÿ˜€", "\ufeff", "\x00"}} - seen := map[string]bool{} - for _, path := range paths { - encoded, err := duplex.EncodePath(path) - if err != nil { - t.Fatal(err) - } - if seen[encoded] { - t.Fatalf("paths alias at %q", encoded) - } - seen[encoded] = true - decoded, err := duplex.DecodePath(encoded) - if err != nil || !reflect.DeepEqual(decoded, path) { - t.Fatalf("%q: %#v, %v", encoded, decoded, err) - } - for _, suffix := range paths { - b, _ := duplex.EncodePath(suffix) - combined, _ := duplex.EncodePath(append(append([]string{}, path...), suffix...)) - if combined != encoded+b { - t.Fatal("prefixing did not compose by concatenation") - } - } - } - if got, _ := duplex.EncodePath([]string{"a", "๐Ÿ˜€", ""}); got != "1:a4:๐Ÿ˜€0:" { - t.Fatal(got) - } - for _, malformed := range []string{"01:a", "00:", "1", ":", "-1:a", "2:a", "1:รฉ", "99999999999999999999999999999:x", "1:\xff"} { - if _, err := duplex.DecodePath(malformed); err == nil { - t.Fatalf("accepted %q", malformed) - } - } - if _, err := duplex.EncodePath([]string{"\xff"}); err == nil { - t.Fatal("accepted non-scalar UTF-8") - } -} - -// queuedRoot is a deterministic endpoint fixture. Only drain executes queued -// deliveries, so composition cannot pass the asynchronous check by timing luck. -type queuedRoot struct { - mu sync.Mutex - queue []queuedDelivery - current *rootAttachment - closed bool - closes int -} -type rootAttachment struct{ receiver duplex.Receiver } -type queuedDelivery struct { - path []string - message duplex.Message -} -type nonComparableRoot struct { - *queuedRoot - marker []int -} - -func newRoot() *queuedRoot { return &queuedRoot{} } -func (r *queuedRoot) Send(path []string, message duplex.Message) error { - if _, err := duplex.EncodePath(path); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - if r.closed { - return duplex.ErrClosed - } - r.queue = append(r.queue, queuedDelivery{append([]string{}, path...), message}) - return nil -} -func (r *queuedRoot) Receive(receiver duplex.Receiver) (func(), error) { - r.mu.Lock() - defer r.mu.Unlock() - if r.closed { - return nil, duplex.ErrClosed - } - if r.current != nil { - return nil, duplex.ErrReceiverExists - } - attachment := &rootAttachment{receiver} - r.current = attachment - return func() { - r.mu.Lock() - if r.current == attachment { - r.current = nil - } - r.mu.Unlock() - }, nil -} -func (r *queuedRoot) attached() bool { - r.mu.Lock() - defer r.mu.Unlock() - return r.current != nil -} -func (r *queuedRoot) captured() duplex.Receiver { - r.mu.Lock() - defer r.mu.Unlock() - return r.current.receiver -} -func (r *queuedRoot) Close(code duplex.Code, reason string) error { - r.mu.Lock() - if r.closed { - r.mu.Unlock() - return nil - } - r.closed = true - r.closes++ - attachment := r.current - r.current = nil - r.mu.Unlock() - if attachment != nil && attachment.receiver.Closed != nil { - attachment.receiver.Closed(code, reason) - } - return nil -} -func (r *queuedRoot) drain() { - for { - r.mu.Lock() - if len(r.queue) == 0 { - r.mu.Unlock() - return - } - d := r.queue[0] - r.queue = r.queue[1:] - attachment := r.current - r.mu.Unlock() - if attachment != nil && attachment.receiver.Message != nil { - attachment.receiver.Message(d.path, d.message) - } - } -} - -type sendOnly func([]string, duplex.Message) error - -func (s sendOnly) Send(path []string, message duplex.Message) error { return s(path, message) } - -func TestWireSelectionsGrantOnlySendAccess(t *testing.T) { - root := newRoot() - prefix := []string{"a.b"} - selected := duplex.At(sendOnly(root.Send), prefix) - prefix[0] = "changed" - for _, view := range []duplex.Wire{selected, duplex.At(root, nil), duplex.At(selected, []string{"๐Ÿ˜€"})} { - if _, ok := view.(interface { - Receive(duplex.Receiver) (func(), error) - }); ok { - t.Fatal("selection grants receive authority") - } - if _, ok := view.(interface { - Close(duplex.Code, string) error - }); ok { - t.Fatal("selection grants lifecycle authority") - } - } - path := []string{"call"} - if err := duplex.At(selected, []string{"๐Ÿ˜€"}).Send(path, duplex.Message{}); err != nil { - t.Fatal(err) - } - path[0] = "changed" - if !reflect.DeepEqual(root.queue[0].path, []string{"a.b", "๐Ÿ˜€", "call"}) { - t.Fatal(root.queue) - } -} - -func TestMountPreservesPathsFramesAndReturnCapability(t *testing.T) { - left, right, reply := newRoot(), newRoot(), newRoot() - children := map[string]duplex.Endpoint{"left": left, "": right} - mounted := duplex.Mount(children) - children["left"] = reply - address := &duplex.ReturnAddress{Wire: nonComparableRoot{reply, []int{1}}} - var paths [][]string - var received []duplex.Message - _, err := mounted.Receive(duplex.Receiver{Message: func(path []string, message duplex.Message) { - paths = append(paths, path) - received = append(received, message) - }}) - if err != nil { - t.Fatal(err) - } - frames := []duplex.ProfileFrame{ - {Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage(`{"n":9007199254740993}`), Meta: map[string]string{"tag": "value"}}, - {Version: 1, Kind: duplex.ProfileResponse, ID: "c:1", Error: &duplex.ProfileError{Code: "refused", Message: "No", Data: json.RawMessage(`{"why":"test"}`)}}, - {Version: 1, Kind: duplex.ProfileEvent, Data: json.RawMessage(`null`)}, - {Version: 1, Kind: duplex.ProfileCancel, ID: "c:1"}, - } - view := duplex.At(duplex.At(mounted, []string{"left"}), []string{"๐Ÿ˜€"}) - for _, frame := range frames { - if err := view.Send([]string{"call"}, duplex.Message{Frame: frame, Return: address}); err != nil { - t.Fatal(err) - } - } - if len(received) != 0 || len(left.queue) != 4 || len(reply.queue) != 0 { - t.Fatal("composition changed dispatch ownership") - } - for _, delivery := range left.queue { - if !reflect.DeepEqual(delivery.path, []string{"๐Ÿ˜€", "call"}) { - t.Fatal(delivery.path) - } - } - left.drain() - for i, frame := range frames { - if !reflect.DeepEqual(paths[i], []string{"left", "๐Ÿ˜€", "call"}) || !reflect.DeepEqual(received[i].Frame, frame) || received[i].Return != address { - t.Fatal(paths[i], received[i]) - } - } - if err := mounted.Send([]string{""}, duplex.Message{}); err != nil { - t.Fatal(err) - } - right.drain() - if !reflect.DeepEqual(paths[4], []string{""}) { - t.Fatal(paths[4]) - } - for _, path := range [][]string{nil, {"missing"}} { - if err := mounted.Send(path, duplex.Message{}); !errors.Is(err, duplex.ErrNoRoute) { - t.Fatal(err) - } - } - if err := mounted.Send([]string{"left", "\xff"}, duplex.Message{}); !errors.Is(err, duplex.ErrPath) { - t.Fatal(err) - } -} - -func TestMountRefusesDuplicateAttachmentAndRebindsWithoutStealingCapturedDeliveries(t *testing.T) { - root, reply := newRoot(), newRoot() - mounted := duplex.Mount(map[string]duplex.Endpoint{"service": root}) - address := &duplex.ReturnAddress{Wire: duplex.At(reply, nil)} - var old, fresh []duplex.ProfileKind - var oldPaths [][]string - closed := 0 - detach, err := mounted.Receive(duplex.Receiver{ - Message: func(path []string, message duplex.Message) { - oldPaths = append(oldPaths, path) - old = append(old, message.Frame.Kind) - if message.Return != address { - t.Fatal("return capability changed") - } - }, - Closed: func(duplex.Code, string) { closed++ }, - }) - if err != nil { - t.Fatal(err) - } - captured := root.captured() - if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatal(err) - } - captured.Message([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileRequest}, Return: address}) - detach() - detach() - if root.attached() || root.closed { - t.Fatal("detach retained ownership or closed borrowed root") - } - freshDetach, err := mounted.Receive(duplex.Receiver{Message: func(_ []string, message duplex.Message) { fresh = append(fresh, message.Frame.Kind) }}) - if err != nil { - t.Fatal(err) - } - detach() // The old token must never remove the new attachment. - captured.Closed(duplex.CodeNormal, "stale close") - captured.Message([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileCancel}, Return: address}) - if err := address.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileResponse}}); err != nil { - t.Fatal(err) - } - if err := mounted.Send([]string{"service", "new"}, duplex.Message{Frame: duplex.ProfileFrame{Kind: duplex.ProfileEvent}}); err != nil { - t.Fatal(err) - } - root.drain() - if !reflect.DeepEqual(old, []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileCancel}) || !reflect.DeepEqual(fresh, []duplex.ProfileKind{duplex.ProfileEvent}) || closed != 0 || len(reply.queue) != 1 { - t.Fatal(old, fresh, closed, reply.queue) - } - if !reflect.DeepEqual(oldPaths, [][]string{{"service", "wait"}, {"service", "wait"}}) { - t.Fatal(oldPaths) - } - freshDetach() - _ = mounted.Close(duplex.CodeNormal, "done") - if closed != 0 || root.closed { - t.Fatal("detached owner or borrowed child was closed") - } -} - -func TestMountAttachmentFailureRollsBackOnlyItsBorrowedAttachments(t *testing.T) { - for _, duplicate := range []bool{false, true} { - t.Run(map[bool]string{false: "occupied-child", true: "aliased-child"}[duplicate], func(t *testing.T) { - first, occupied := newRoot(), newRoot() - if duplicate { - occupied = first - } else { - if _, err := occupied.Receive(duplex.Receiver{}); err != nil { - t.Fatal(err) - } - } - mounted := duplex.Mount(map[string]duplex.Endpoint{"a": first, "z": occupied}) - if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatal(err) - } - if first.attached() || first.closed || occupied.closed { - t.Fatal("failed acquisition leaked or closed a child") - } - if !duplicate && !occupied.attached() { - t.Fatal("rollback removed another owner") - } - if _, err := first.Receive(duplex.Receiver{}); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestMountedChildEndKeepsHealthySiblingAndEndsOwnerOnce(t *testing.T) { - left, right := newRoot(), newRoot() - mounted := duplex.Mount(map[string]duplex.Endpoint{"left": left, "right": right}) - closed, deliveries := 0, 0 - _, err := mounted.Receive(duplex.Receiver{ - Message: func(path []string, _ duplex.Message) { - if !reflect.DeepEqual(path, []string{"right", "call"}) { - t.Fatal(path) - } - deliveries++ - }, - Closed: func(code duplex.Code, reason string) { - closed++ - if code != duplex.CodeNormal || reason != "last" { - t.Fatal(code, reason) - } - }, - }) - if err != nil { - t.Fatal(err) - } - stale := left.captured() - _ = left.Close(duplex.CodeNormal, "first") - stale.Closed(duplex.CodeNormal, "duplicate") - if closed != 0 { - t.Fatal("one child ended the mount attachment") - } - if err := mounted.Send([]string{"right", "call"}, duplex.Message{}); err != nil { - t.Fatal(err) - } - right.drain() - _ = right.Close(duplex.CodeNormal, "last") - if closed != 1 || deliveries != 1 { - t.Fatal(closed, deliveries) - } - if err := mounted.Send(nil, duplex.Message{}); !errors.Is(err, duplex.ErrNoRoute) { - t.Fatal("child ending permanently closed mount", err) - } - if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrClosed) { - t.Fatal(err) - } - _ = mounted.Close(duplex.CodeNormal, "mount") - if closed != 1 { - t.Fatal(closed) - } -} - -func TestMountCloseDetachesOwnAttachmentAndPreservesChildren(t *testing.T) { - root := newRoot() - mounted := duplex.Mount(map[string]duplex.Endpoint{"": root}) - closed := 0 - _, err := mounted.Receive(duplex.Receiver{Closed: func(code duplex.Code, reason string) { - closed++ - if code != duplex.CodeNormal || reason != "mount ended" { - t.Error(code, reason) - } - _ = mounted.Close(code, reason) - if _, err := root.Receive(duplex.Receiver{}); err != nil { - t.Error("child was not released before closure callback", err) - } - }}) - if err != nil { - t.Fatal(err) - } - _ = mounted.Close(duplex.CodeNormal, "mount ended") - _ = mounted.Close(duplex.CodeNormal, "again") - if closed != 1 || root.closes != 0 || !root.attached() { - t.Fatal(closed, root.closes, root.attached()) - } - if err := mounted.Send([]string{""}, duplex.Message{}); !errors.Is(err, duplex.ErrClosed) { - t.Fatal(err) - } - if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrClosed) { - t.Fatal(err) - } - if err := root.Send(nil, duplex.Message{}); err != nil { - t.Fatal(err) - } -} - -type registeringRoot struct { - *queuedRoot - registered chan struct{} - resume chan struct{} -} - -func (r *registeringRoot) Receive(receiver duplex.Receiver) (func(), error) { - detach, err := r.queuedRoot.Receive(receiver) - close(r.registered) - <-r.resume - return detach, err -} -func TestMountCloseDuringReceiveDisposesLateChildAttachment(t *testing.T) { - root := ®isteringRoot{newRoot(), make(chan struct{}), make(chan struct{})} - mounted := duplex.Mount(map[string]duplex.Endpoint{"x": root}) - finished := make(chan error, 1) - closed := 0 - go func() { - _, err := mounted.Receive(duplex.Receiver{Closed: func(duplex.Code, string) { closed++ }}) - finished <- err - }() - <-root.registered - _ = mounted.Close(duplex.CodeNormal, "done") - close(root.resume) - if err := <-finished; !errors.Is(err, duplex.ErrClosed) { - t.Fatal(err) - } - if root.attached() || root.closes != 0 || closed != 1 { - t.Fatal(root.attached(), root.closes, closed) - } -} - -type endingRoot struct{ *queuedRoot } - -func (r *endingRoot) Receive(receiver duplex.Receiver) (func(), error) { - detach, err := r.queuedRoot.Receive(receiver) - if err == nil { - _ = r.Close(duplex.CodeNormal, "ended during acquisition") - } - return detach, err -} -func TestMountChildEndingDuringAcquisitionRollsBackHealthySibling(t *testing.T) { - healthy := newRoot() - ending := &endingRoot{newRoot()} - mounted := duplex.Mount(map[string]duplex.Endpoint{"a": healthy, "z": ending}) - if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrClosed) { - t.Fatal(err) - } - if healthy.attached() || ending.attached() || healthy.closed { - t.Fatal("partial acquisition retained a child") - } - if _, err := healthy.Receive(duplex.Receiver{}); err != nil { - t.Fatal(err) - } -} - -func TestEmptyMountStillOwnsOneDetachableAttachment(t *testing.T) { - mounted := duplex.Mount(nil) - detach, err := mounted.Receive(duplex.Receiver{}) - if err != nil { - t.Fatal(err) - } - if _, err := mounted.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatal(err) - } - detach() - closed := 0 - _, err = mounted.Receive(duplex.Receiver{Closed: func(duplex.Code, string) { closed++ }}) - if err != nil { - t.Fatal(err) - } - detach() - _ = mounted.Close(duplex.CodeNormal, "done") - if closed != 1 { - t.Fatal(closed) - } -} diff --git a/core/go/errors.go b/core/go/errors.go new file mode 100644 index 0000000..64bbddb --- /dev/null +++ b/core/go/errors.go @@ -0,0 +1,96 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// ErrBackpressure is why a carrier ended when a bounded queue stayed full: a +// consumer that does not drain is disconnected rather than allowed to hold the +// carrier up. A carrier that ended this way is closed, so errors.Is(err, +// transports.ErrClosed) also holds for the error it reports. +var ErrBackpressure = errors.New("bitruntime: consumer is stalled") + +// PublicError is safe to send to the remote caller. Other handler errors are +// replaced by a generic internal error; their messages are not disclosed. +type PublicError struct { + Code string `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Error is the code and the message, as a log line shows them. +func (e *PublicError) Error() string { return e.Code + ": " + e.Message } + +// Ended classifies the terminal cause of a carrier as a closed carrier while +// keeping the cause itself: errors.Is(Ended(cause), transports.ErrClosed) +// holds, and so does errors.Is(Ended(cause), cause). +func Ended(cause error) error { + if cause == nil || errors.Is(cause, transports.ErrClosed) { + if cause == nil { + return transports.ErrClosed + } + return cause + } + return &endedError{cause: cause} +} + +type endedError struct{ cause error } + +func (e *endedError) Error() string { return "bitruntime: closed: " + e.cause.Error() } +func (e *endedError) Unwrap() []error { + return []error{transports.ErrClosed, e.cause} +} + +// Respond answers a request through its return capability, normalizing err to +// the public error the protocol carries: a PublicError as it is, a +// cancellation as cancelled, a closed carrier as disconnected, and anything +// else as internal. An oversized or unencodable result is answered with a +// bounded internal error instead, so the caller is never left waiting. +// +// It returns the outcome it reported: nil for a delivered success, the +// normalized refusal, or the return capability's own refusal. +func Respond(request wire.Message, result json.RawMessage, err error) error { + if request.Return == nil || request.Return.Wire == nil { + return transports.ErrClosed + } + f := wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: request.Frame.ID, Result: result, Traceparent: request.Frame.Traceparent, Tracestate: request.Frame.Tracestate} + if err != nil { + var public *PublicError + switch { + case errors.As(err, &public) && public != nil && public.Code != "" && public.Message != "": + f.Error = &wire.ProfileError{Code: public.Code, Message: public.Message, Data: public.Data} + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + f.Error = &wire.ProfileError{Code: "cancelled", Message: "Request cancelled"} + case errors.Is(err, transports.ErrClosed): + f.Error = &wire.ProfileError{Code: "disconnected", Message: "Connection ended; outcome may be unknown"} + default: + f.Error = &wire.ProfileError{Code: "internal", Message: "Internal error"} + } + f.Result = nil + // Preserve the local cancellation cause, but otherwise observe exactly + // the normalized public error selected for this response. + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + err = &PublicError{Code: f.Error.Code, Message: f.Error.Message, Data: f.Error.Data} + } + } + if sendErr := request.Return.Wire.Send(nil, wire.Message{Frame: f}); sendErr != nil { + // A malformed or oversized public result must settle as a bounded + // refusal, just as the protocol engine's own response does. + f.Result = nil + f.Error = &wire.ProfileError{Code: "internal", Message: "Response could not be encoded"} + if fallbackErr := request.Return.Wire.Send(nil, wire.Message{Frame: f}); fallbackErr == nil { + return &PublicError{Code: f.Error.Code, Message: f.Error.Message} + } + // A caller that already withdrew cannot receive either response. Its + // selected refusal remains that refusal; a failed success is no success. + if err == nil { + return WithoutUnpublishedProof(sendErr) + } + } + return err +} diff --git a/core/go/forward.go b/core/go/forward.go new file mode 100644 index 0000000..03fcdb6 --- /dev/null +++ b/core/go/forward.go @@ -0,0 +1,67 @@ +package core + +import ( + "errors" + "sync" + + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// Forward joins two existing origins without allocating a peer or channel: +// what either endpoint delivers is sent through the other unchanged, with its +// return capability and context, in the order the source delivers it. The +// returned detach removes only the forwarding attachments; both endpoints stay +// owned by their callers, and each root remains responsible for ending its +// failed carrier. +// +// A message the destination refuses fails only that message: a refused +// request is answered through its return capability, and forwarding goes on. +// Forwarding ends when either endpoint ends or detach is called. +func Forward(inbound, outbound wire.Endpoint) (func(), error) { + if inbound == nil || outbound == nil { + return nil, errors.New("bitruntime: forwarding requires two origins") + } + var mu sync.Mutex + var detaches []func() + ended := false + stop := func() { + mu.Lock() + if ended { + mu.Unlock() + return + } + ended = true + owned := detaches + detaches = nil + mu.Unlock() + for _, detach := range owned { + detach() + } + } + receiver := func(destination wire.AddressedWire) wire.Receiver { + return wire.Receiver{Closed: func(wire.Code, string) { stop() }, Message: func(path []string, message wire.Message) { + if err := destination.Send(path, message); err != nil && message.Frame.Kind == wire.ProfileRequest { + Respond(message, nil, WithoutUnpublishedProof(err)) + } + }} + } + for _, direction := range []struct{ source, destination wire.Endpoint }{{inbound, outbound}, {outbound, inbound}} { + detach, err := direction.source.Receive(receiver(direction.destination)) + if err != nil { + stop() + return nil, err + } + mu.Lock() + active := !ended + if active { + detaches = append(detaches, detach) + } + mu.Unlock() + if !active { + detach() + return nil, transports.ErrClosed + } + } + return stop, nil +} diff --git a/core/go/invocation.go b/core/go/invocation.go index 706b6d8..4fddf54 100644 --- a/core/go/invocation.go +++ b/core/go/invocation.go @@ -1,4 +1,4 @@ -package runtime +package core import ( "errors" @@ -6,14 +6,14 @@ import ( "sync" "sync/atomic" - "github.com/Bitspark/nightseam/duplex/go" + wire "github.com/Bitspark/bitwire/wire/go" ) -// An admitted request's return capability is the invocation, presented as a -// Wire. The empty path carries its outcome, as it always has; these operations +// An admitted request's return capability is the invocation, presented as +// addressed access. The empty path carries its outcome, as it always has; these operations // carry its lifecycle. They are ordinary events of the profile โ€” a layer's own // vocabulary, as `channel.` is the tunnel's โ€” and a participant needs nothing -// of Nightseam's to speak them but the Wire it was already handed. +// of bitruntime's to speak them but the return capability it was handed. // // The capture or body a verb is about is one opaque segment after the // operation, because a path is what addresses a thing. The verbs never reach a @@ -78,13 +78,13 @@ func nextInvocationIdentifier() string { return strconv.FormatUint(invocationIdentifier.Add(1), 10) } -func invocationEvent() duplex.ProfileFrame { - return duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")} +func invocationEvent() wire.ProfileFrame { + return wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: []byte("null")} } // Invocation is the lifecycle an admitting runtime keeps for one admitted // request, and the answer its return capability gives to the vocabulary above. -// A runtime that is not Nightseam's composes it โ€” or answers the same paths +// A runtime that is not bitruntime composes it โ€” or answers the same paths // itself โ€” and the same participants work against either. type Invocation struct { mu sync.Mutex @@ -96,7 +96,7 @@ type Invocation struct { unready int running int controls int - control *duplex.Message + control *wire.Message settled bool dispatchDone bool retired bool @@ -104,7 +104,7 @@ type Invocation struct { } type invocationCapture struct { - sink duplex.Wire + sink wire.AddressedWire ready bool notified bool } @@ -130,7 +130,7 @@ func NewInvocation(limits InvocationLimits, onRetired func()) *Invocation { // Deliver answers one operation of the invocation vocabulary. A return // capability routes every nonempty path here; the empty path stays its own. -func (v *Invocation) Deliver(path []string, message duplex.Message) error { +func (v *Invocation) Deliver(path []string, message wire.Message) error { if v == nil { return ErrInvocationUnsupported } @@ -139,7 +139,7 @@ func (v *Invocation) Deliver(path []string, message duplex.Message) error { } switch path[0] { case InvocationControl: - if len(path) != 1 || message.Frame.Kind != duplex.ProfileCancel { + if len(path) != 1 || message.Frame.Kind != wire.ProfileCancel { return ErrInvocationUnsupported } v.latch(message) @@ -148,7 +148,7 @@ func (v *Invocation) Deliver(path []string, message duplex.Message) error { default: return ErrInvocationUnsupported } - if len(path) != 2 || message.Frame.Kind != duplex.ProfileEvent { + if len(path) != 2 || message.Frame.Kind != wire.ProfileEvent { return ErrInvocationUnsupported } identifier := path[1] @@ -207,7 +207,7 @@ func (v *Invocation) Retired() bool { return v.retired } -func (v *Invocation) capture(identifier string, sink duplex.Wire) error { +func (v *Invocation) capture(identifier string, sink wire.AddressedWire) error { v.mu.Lock() defer v.mu.Unlock() if v.retired { @@ -234,11 +234,11 @@ func (v *Invocation) ready(identifier string) { } capture.ready = true v.unready-- - var sinks []duplex.Wire - var control duplex.Message + var sinks []wire.AddressedWire + var control wire.Message if v.control != nil && !capture.notified { capture.notified = true - sinks, control = []duplex.Wire{capture.sink}, *v.control + sinks, control = []wire.AddressedWire{capture.sink}, *v.control v.controls++ } retire := v.retireLocked() @@ -300,14 +300,14 @@ func (v *Invocation) done(identifier string) { // latch records the first cancellation and pushes it to every capture that // is already ready. A capture installed while it is latched receives it when // its own request delivery becomes ready. Further controls coalesce. -func (v *Invocation) latch(message duplex.Message) { +func (v *Invocation) latch(message wire.Message) { v.mu.Lock() if v.retired || v.control != nil { v.mu.Unlock() return } v.control = &message - var sinks []duplex.Wire + var sinks []wire.AddressedWire for _, capture := range v.captures { if capture.ready && !capture.notified { capture.notified = true @@ -322,7 +322,7 @@ func (v *Invocation) latch(message duplex.Message) { // push runs participant code outside the lock and retains one control // reservation until every selected continuation has returned, so that // retirement cannot reclaim a capture a control is still reaching. -func (v *Invocation) push(sinks []duplex.Wire, message duplex.Message) { +func (v *Invocation) push(sinks []wire.AddressedWire, message wire.Message) { defer func() { v.mu.Lock() v.controls-- @@ -357,17 +357,17 @@ func invocationNotify(callback func()) { // invocationSink is the Wire a capture is pushed its control through. It // accepts the invocation's cancellation at its own origin and nothing else. -type invocationSink struct{ control func(duplex.Message) } +type invocationSink struct{ control func(wire.Message) } -func (s *invocationSink) Send(path []string, message duplex.Message) error { - if len(path) != 0 || message.Frame.Kind != duplex.ProfileCancel { +func (s *invocationSink) Send(path []string, message wire.Message) error { + if len(path) != 0 || message.Frame.Kind != wire.ProfileCancel { return errors.New("an invocation control sink carries cancellation only") } s.control(message) return nil } -func invocationWire(message duplex.Message) (duplex.Wire, error) { +func invocationWire(message wire.Message) (wire.AddressedWire, error) { if message.Return == nil || message.Return.Wire == nil { return nil, ErrInvocationUnsupported } @@ -378,7 +378,7 @@ func invocationWire(message duplex.Message) (duplex.Wire, error) { // for one traversal of one admitted invocation. Repeated traversal of the same // dispatcher takes a fresh handle, so no two traversals share a slot. type InvocationCaptureHandle struct { - wire duplex.Wire + wire wire.AddressedWire identifier string once sync.Once released sync.Once @@ -391,17 +391,17 @@ type InvocationCaptureHandle struct { // A return capability that does not speak the vocabulary refuses, and the // refusal is the caller's to answer: routing an invocation-aware request with // weaker cancellation guarantees is exactly what this reports instead. -func CaptureInvocation(message duplex.Message, control func(duplex.Message)) (*InvocationCaptureHandle, error) { - wire, err := invocationWire(message) +func CaptureInvocation(message wire.Message, control func(wire.Message)) (*InvocationCaptureHandle, error) { + access, err := invocationWire(message) if err != nil { return nil, err } if control == nil { return nil, errors.New("an invocation capture requires a control sink") } - handle := &InvocationCaptureHandle{wire: wire, identifier: nextInvocationIdentifier()} - sent := duplex.Message{Frame: invocationEvent(), Return: &duplex.ReturnAddress{Wire: &invocationSink{control: control}}} - if err := wire.Send([]string{InvocationCapture, handle.identifier}, sent); err != nil { + handle := &InvocationCaptureHandle{wire: access, identifier: nextInvocationIdentifier()} + sent := wire.Message{Frame: invocationEvent(), Return: &wire.ReturnAddress{Wire: &invocationSink{control: control}}} + if err := access.Send([]string{InvocationCapture, handle.identifier}, sent); err != nil { return nil, err } return handle, nil @@ -414,7 +414,7 @@ func (c *InvocationCaptureHandle) Ready() { return } c.once.Do(func() { - _ = c.wire.Send([]string{InvocationReady, c.identifier}, duplex.Message{Frame: invocationEvent()}) + _ = c.wire.Send([]string{InvocationReady, c.identifier}, wire.Message{Frame: invocationEvent()}) }) } @@ -425,13 +425,13 @@ func (c *InvocationCaptureHandle) Release() { return } c.released.Do(func() { - _ = c.wire.Send([]string{InvocationRelease, c.identifier}, duplex.Message{Frame: invocationEvent()}) + _ = c.wire.Send([]string{InvocationRelease, c.identifier}, wire.Message{Frame: invocationEvent()}) }) } // InvocationBodyHandle is one execution lease of one admitted invocation. type InvocationBodyHandle struct { - wire duplex.Wire + wire wire.AddressedWire identifier string once sync.Once } @@ -440,13 +440,13 @@ type InvocationBodyHandle struct { // The invocation does not retire while the lease is held, so an early answer // to the caller โ€” a deadline, a withdrawal โ€” never retires an invocation whose // body is still running. -func BeginInvocationBody(message duplex.Message) (*InvocationBodyHandle, error) { - wire, err := invocationWire(message) +func BeginInvocationBody(message wire.Message) (*InvocationBodyHandle, error) { + access, err := invocationWire(message) if err != nil { return nil, err } - handle := &InvocationBodyHandle{wire: wire, identifier: nextInvocationIdentifier()} - if err := wire.Send([]string{InvocationBegin, handle.identifier}, duplex.Message{Frame: invocationEvent()}); err != nil { + handle := &InvocationBodyHandle{wire: access, identifier: nextInvocationIdentifier()} + if err := access.Send([]string{InvocationBegin, handle.identifier}, wire.Message{Frame: invocationEvent()}); err != nil { return nil, err } return handle, nil @@ -459,7 +459,7 @@ func (b *InvocationBodyHandle) Done() { return } b.once.Do(func() { - _ = b.wire.Send([]string{InvocationDone, b.identifier}, duplex.Message{Frame: invocationEvent()}) + _ = b.wire.Send([]string{InvocationDone, b.identifier}, wire.Message{Frame: invocationEvent()}) }) } @@ -467,10 +467,10 @@ func (b *InvocationBodyHandle) Done() { // latches it and pushes it to the traversals that captured it. A router that // receives a control frame relays it here rather than resolving a route of its // own: the capture, not the current registration, decides where it goes. -func RelayInvocationControl(message duplex.Message) error { - wire, err := invocationWire(message) +func RelayInvocationControl(message wire.Message) error { + access, err := invocationWire(message) if err != nil { return err } - return wire.Send([]string{InvocationControl}, message) + return access.Send([]string{InvocationControl}, message) } diff --git a/core/go/invocation_experiment_test.go b/core/go/invocation_experiment_test.go deleted file mode 100644 index d8fe7b2..0000000 --- a/core/go/invocation_experiment_test.go +++ /dev/null @@ -1,567 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -// ledgerEndpoint is the second independent integration. It shares no ledger -// with the first and composes none of Nightseam's lifecycle: it answers the -// invocation vocabulary itself, out of its own state, using the operation -// paths and nothing else. If the dispatcher works against this, the boundary -// is public in fact and not only in name. -type ledgerEndpoint struct { - mu sync.Mutex - receiver *duplex.Receiver - captureCap int - bodyCap int - calls map[string]*ledgerCall - next atomic.Uint64 - retirements atomic.Int64 - refusals atomic.Int64 -} - -type ledgerCall struct { - mu sync.Mutex - owner *ledgerEndpoint - address *duplex.ReturnAddress - outcome chan duplex.Message - sinks map[string]duplex.Wire - delivered map[string]bool - told map[string]bool - bodies map[string]bool - takenCaps int - takenBody int - pending int - control *duplex.Message - settled bool - retired bool -} - -func newLedgerEndpoint(captures, bodies int) *ledgerEndpoint { - return &ledgerEndpoint{captureCap: captures, bodyCap: bodies, calls: map[string]*ledgerCall{}} -} - -func (e *ledgerEndpoint) Send([]string, duplex.Message) error { return nil } -func (e *ledgerEndpoint) Close(code duplex.Code, reason string) error { - e.mu.Lock() - receiver := e.receiver - e.receiver = nil - e.mu.Unlock() - if receiver != nil && receiver.Closed != nil { - receiver.Closed(code, reason) - } - return nil -} -func (e *ledgerEndpoint) Receive(receiver duplex.Receiver) (func(), error) { - e.mu.Lock() - defer e.mu.Unlock() - if e.receiver != nil { - return nil, duplex.ErrReceiverExists - } - held := receiver - e.receiver = &held - return func() { - e.mu.Lock() - if e.receiver == &held { - e.receiver = nil - } - e.mu.Unlock() - }, nil -} - -// ledgerReturn answers the outcome at its own origin and the invocation -// vocabulary everywhere else, refusing any path it does not implement. -type ledgerReturn struct{ call *ledgerCall } - -func (r *ledgerReturn) Send(path []string, message duplex.Message) error { - call := r.call - if len(path) == 0 { - if message.Frame.Kind != duplex.ProfileResponse { - return errors.New("invalid outcome") - } - select { - case call.outcome <- message: - default: - } - call.settle() - return nil - } - switch path[0] { - case ws.InvocationControl: - if len(path) != 1 || message.Frame.Kind != duplex.ProfileCancel { - return errors.New("unknown invocation operation") - } - call.latch(message) - return nil - case ws.InvocationCapture: - if len(path) != 2 || message.Return == nil || message.Return.Wire == nil { - return errors.New("unknown invocation operation") - } - return call.capture(path[1], message.Return.Wire) - case ws.InvocationReady: - if len(path) != 2 { - return errors.New("unknown invocation operation") - } - call.markReady(path[1]) - return nil - case ws.InvocationRelease: - if len(path) != 2 { - return errors.New("unknown invocation operation") - } - call.release(path[1]) - return nil - case ws.InvocationBegin: - if len(path) != 2 { - return errors.New("unknown invocation operation") - } - return call.begin(path[1]) - case ws.InvocationDone: - if len(path) != 2 { - return errors.New("unknown invocation operation") - } - call.done(path[1]) - return nil - } - return errors.New("unknown invocation operation") -} - -func (c *ledgerCall) capture(identifier string, sink duplex.Wire) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.retired { - return errors.New("retired") - } - if c.takenCaps >= c.owner.captureCap { - c.owner.refusals.Add(1) - return errors.New("capture bound reached") - } - c.takenCaps++ - c.pending++ - c.sinks[identifier] = sink - return nil -} - -func (c *ledgerCall) markReady(identifier string) { - c.mu.Lock() - sink := c.sinks[identifier] - if sink == nil || c.delivered[identifier] { - c.mu.Unlock() - return - } - c.delivered[identifier] = true - c.pending-- - var deliver duplex.Wire - var control duplex.Message - if c.control != nil && !c.told[identifier] { - c.told[identifier] = true - deliver, control = sink, *c.control - } - c.mu.Unlock() - if deliver != nil { - _ = deliver.Send(nil, control) - } - c.retire() -} - -func (c *ledgerCall) release(identifier string) { - c.mu.Lock() - if _, exists := c.sinks[identifier]; !exists { - c.mu.Unlock() - return - } - if !c.delivered[identifier] { - c.pending-- - } - delete(c.sinks, identifier) - delete(c.delivered, identifier) - c.mu.Unlock() - c.retire() -} - -func (c *ledgerCall) begin(identifier string) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.retired { - return errors.New("retired") - } - if c.takenBody >= c.owner.bodyCap { - c.owner.refusals.Add(1) - return errors.New("body bound reached") - } - c.takenBody++ - c.bodies[identifier] = true - return nil -} - -func (c *ledgerCall) done(identifier string) { - c.mu.Lock() - if !c.bodies[identifier] { - c.mu.Unlock() - return - } - delete(c.bodies, identifier) - c.mu.Unlock() - c.retire() -} - -func (c *ledgerCall) latch(message duplex.Message) { - c.mu.Lock() - if c.retired || c.control != nil { - c.mu.Unlock() - return - } - c.control = &message - var sinks []duplex.Wire - for identifier, sink := range c.sinks { - if c.delivered[identifier] && !c.told[identifier] { - c.told[identifier] = true - sinks = append(sinks, sink) - } - } - c.mu.Unlock() - for _, sink := range sinks { - _ = sink.Send(nil, message) - } -} - -func (c *ledgerCall) settle() { - c.mu.Lock() - c.settled = true - c.mu.Unlock() - c.retire() -} - -func (c *ledgerCall) retire() { - c.mu.Lock() - if c.retired || !c.settled || c.pending != 0 || len(c.bodies) != 0 { - c.mu.Unlock() - return - } - c.retired = true - c.sinks, c.delivered, c.told, c.control = nil, nil, nil, nil - c.mu.Unlock() - c.owner.retirements.Add(1) -} - -func (e *ledgerEndpoint) admit(path []string, params json.RawMessage) (*ledgerCall, chan duplex.Message) { - identifier := fmt.Sprintf("l:%d", e.next.Add(1)) - call := &ledgerCall{owner: e, outcome: make(chan duplex.Message, 1), sinks: map[string]duplex.Wire{}, - delivered: map[string]bool{}, told: map[string]bool{}, bodies: map[string]bool{}, pending: 1} - call.address = &duplex.ReturnAddress{Wire: &ledgerReturn{call: call}} - e.mu.Lock() - e.calls[identifier] = call - receiver := e.receiver - e.mu.Unlock() - if receiver != nil && receiver.Message != nil { - receiver.Message(path, duplex.Message{ - Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier, Params: params}, - Return: call.address, - }) - } - // The request's own delivery has returned. - call.mu.Lock() - call.pending-- - call.mu.Unlock() - call.retire() - return call, call.outcome -} - -func (c *ledgerCall) cancel(identifier string) { - _ = c.address.Wire.Send([]string{ws.InvocationControl}, duplex.Message{ - Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: identifier}, - Return: c.address, - }) -} - -func (c *ledgerCall) isRetired() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.retired -} - -// TestASecondIntegrationParticipatesWithNoSharedLedger runs Nightseam's -// dispatcher and its generated-binder registration over an endpoint that -// implements the lifecycle itself, through an opaque wrapper, with no shared -// state and no concrete type recognized on either side. -func TestASecondIntegrationParticipatesWithNoSharedLedger(t *testing.T) { - endpoint := newLedgerEndpoint(8, 8) - dispatch, err := ws.NewDispatcher(opaqueEndpoint{endpoint}) - if err != nil { - t.Fatal(err) - } - view, err := ws.NewDispatcher(dispatch.Select([]string{"space"})) - if err != nil { - t.Fatal(err) - } - started, release := make(chan struct{}, 1), make(chan struct{}) - observed := make(chan error, 1) - if _, err := ws.HandleWire(view, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - started <- struct{}{} - <-release - observed <- ctx.Err() - return "answer", nil - }); err != nil { - t.Fatal(err) - } - call, outcome := endpoint.admit([]string{"space", "read"}, nil) - <-started - if call.isRetired() { - t.Fatal("retired while the body was running") - } - call.cancel("l:1") - close(release) - if err := <-observed; err == nil { - t.Fatal("cancellation did not reach the captured traversal of the second integration") - } - select { - case <-outcome: - case <-time.After(5 * time.Second): - t.Fatal("no outcome") - } - for range 500 { - if call.isRetired() { - break - } - time.Sleep(2 * time.Millisecond) - } - if !call.isRetired() { - t.Fatal("the second integration never retired its invocation") - } - if endpoint.retirements.Load() != 1 { - t.Fatalf("retirements: %d", endpoint.retirements.Load()) - } -} - -// conduitEndpoint is half of a pure route: what is sent on one half is -// delivered to the other half's attachment, verbatim, with the message's -// return capability untouched. It correlates nothing and admits nothing, so a -// composition over it is pure forwarding rather than a carrier hop. -type conduitEndpoint struct { - mu sync.Mutex - receiver *duplex.Receiver - other *conduitEndpoint -} - -func newConduit() (*conduitEndpoint, *conduitEndpoint) { - near, far := &conduitEndpoint{}, &conduitEndpoint{} - near.other, far.other = far, near - return near, far -} - -func (c *conduitEndpoint) Send(path []string, message duplex.Message) error { - c.other.mu.Lock() - receiver := c.other.receiver - c.other.mu.Unlock() - if receiver == nil || receiver.Message == nil { - return ws.ErrClosed - } - receiver.Message(path, message) - return nil -} -func (c *conduitEndpoint) Receive(receiver duplex.Receiver) (func(), error) { - c.mu.Lock() - defer c.mu.Unlock() - if c.receiver != nil { - return nil, duplex.ErrReceiverExists - } - held := receiver - c.receiver = &held - return func() { - c.mu.Lock() - if c.receiver == &held { - c.receiver = nil - } - c.mu.Unlock() - }, nil -} -func (c *conduitEndpoint) Close(duplex.Code, string) error { return nil } - -// TestForwardingPreservesLifecycleParticipation admits on one integration and -// forwards through an opaque wrapper and a pure route into a dispatcher on the -// far side. The lifecycle travels with the preserved return capability rather -// than being reconstructed at the boundary, and detach and rebind on the far -// side leave the captured traversal owning the control. -func TestForwardingPreservesLifecycleParticipation(t *testing.T) { - origin := newLedgerEndpoint(8, 8) - near, far := newConduit() - stop, err := ws.ForwardWire(opaqueEndpoint{origin}, opaqueEndpoint{near}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(stop) - dispatch, err := ws.NewDispatcher(far) - if err != nil { - t.Fatal(err) - } - controls, requests := make(chan duplex.Message, 4), make(chan duplex.Message, 4) - detach, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - if m.Frame.Kind == duplex.ProfileCancel { - controls <- m - return - } - requests <- m - }}) - if err != nil { - t.Fatal(err) - } - call, _ := origin.admit([]string{"read"}, nil) - var admitted duplex.Message - select { - case admitted = <-requests: - case <-time.After(5 * time.Second): - t.Fatal("the forwarded request never arrived") - } - if admitted.Return != call.address { - t.Fatal("forwarding did not preserve the original return capability") - } - detach() - rebound := make(chan duplex.Message, 4) - if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { rebound <- m }}); err != nil { - t.Fatal(err) - } - call.cancel("l:1") - select { - case m := <-controls: - if m.Frame.Kind != duplex.ProfileCancel { - t.Fatalf("control was %q", m.Frame.Kind) - } - case <-time.After(5 * time.Second): - t.Fatal("the control did not follow the captured traversal across the forwarder") - } - select { - case <-rebound: - t.Fatal("the control reached the rebound registration") - default: - } -} - -// TestAQueuedControlCannotReachAReusedIdentity is the first identity-reuse -// race: a control already queued against one invocation keeps that invocation, -// so a later invocation reusing the same textual identifier is untouched. -func TestAQueuedControlCannotReachAReusedIdentity(t *testing.T) { - endpoint := newLedgerEndpoint(8, 8) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - seen := make(chan string, 8) - if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - seen <- string(m.Frame.Kind) + ":" + m.Frame.ID - }}); err != nil { - t.Fatal(err) - } - first, _ := endpoint.admit([]string{"read"}, nil) - if got := <-seen; got != "request:l:1" { - t.Fatalf("first request: %s", got) - } - // The first invocation settles and retires before its old control is - // released. Its control ticket is the invocation itself, not a key. - first.settle() - stale := first.address - second, _ := endpoint.admit([]string{"read"}, nil) - if got := <-seen; got != "request:l:2" { - t.Fatalf("second request: %s", got) - } - // The stale control names the first invocation's identifier and travels on - // the first invocation's own return capability. It reaches nothing. - _ = stale.Wire.Send([]string{ws.InvocationControl}, duplex.Message{ - Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: "l:1"}, - Return: stale, - }) - select { - case got := <-seen: - t.Fatalf("a stale control was delivered: %s", got) - case <-time.After(200 * time.Millisecond): - } - second.cancel("l:2") - select { - case got := <-seen: - if got != "cancel:l:2" { - t.Fatalf("live control: %s", got) - } - case <-time.After(5 * time.Second): - t.Fatal("the live invocation's control never arrived") - } -} - -// TestAResponseRacingAQueuedControlRetiresOnce drives a response and a control -// at one invocation concurrently, many times, under the race detector. -func TestAResponseRacingAQueuedControlRetiresOnce(t *testing.T) { - endpoint := newLedgerEndpoint(8, 8) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - if _, err := ws.HandleWire(dispatch, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - return "answer", nil - }); err != nil { - t.Fatal(err) - } - for i := range 64 { - call, outcome := endpoint.admit([]string{"read"}, nil) - var wait sync.WaitGroup - wait.Add(1) - go func() { defer wait.Done(); call.cancel(fmt.Sprintf("l:%d", i+1)) }() - select { - case <-outcome: - case <-time.After(5 * time.Second): - t.Fatal("no outcome") - } - wait.Wait() - } - for range 500 { - if endpoint.retirements.Load() == 64 { - return - } - time.Sleep(2 * time.Millisecond) - } - t.Fatalf("retirements after 64 raced completions: %d", endpoint.retirements.Load()) -} - -// TestCaptureBoundRefusesRatherThanGrowing checks the refusal a bounded -// integration gives, and that the dispatcher answers it instead of routing. -func TestCaptureBoundRefusesRatherThanGrowing(t *testing.T) { - endpoint := newLedgerEndpoint(1, 8) - outer, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - inner, err := ws.NewDispatcher(outer.Select([]string{"a"})) - if err != nil { - t.Fatal(err) - } - delivered := make(chan struct{}, 1) - if _, err := inner.Register([]string{"read"}, duplex.Receiver{Message: func([]string, duplex.Message) { delivered <- struct{}{} }}); err != nil { - t.Fatal(err) - } - _, outcome := endpoint.admit([]string{"a", "read"}, nil) - select { - case answer := <-outcome: - // The refusal is this integration's own: a dispatcher reports busy for - // a bound it can recognize as one, and invalid_message for a refusal - // whose reason a facility did not spell in the agreed vocabulary. - if answer.Frame.Error == nil || answer.Frame.Error.Code != "invalid_message" { - t.Fatalf("a traversal past the capture bound answered %+v", answer.Frame.Error) - } - case <-time.After(5 * time.Second): - t.Fatal("no refusal") - } - select { - case <-delivered: - t.Fatal("the inner receiver was reached past the bound") - default: - } - if endpoint.refusals.Load() == 0 { - t.Fatal("the bound was never exercised") - } -} diff --git a/core/go/invocation_test.go b/core/go/invocation_test.go deleted file mode 100644 index 1e4e2f7..0000000 --- a/core/go/invocation_test.go +++ /dev/null @@ -1,519 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -// invocationEndpoint is an endpoint written against the public contract alone. -// It admits requests, answers the invocation vocabulary out of its own ledger, -// and imports nothing of Nightseam's but the vocabulary's paths. It is one of -// the two independent integrations #439 requires. -type invocationEndpoint struct { - mu sync.Mutex - receiver *duplex.Receiver - closed bool - limits ws.InvocationLimits - admitted map[string]*ws.Invocation - returns map[string]*duplex.ReturnAddress - outcomes map[string]chan duplex.Message - retirements atomic.Int64 - next atomic.Uint64 -} - -func newInvocationEndpoint(limits ws.InvocationLimits) *invocationEndpoint { - return &invocationEndpoint{limits: limits, admitted: map[string]*ws.Invocation{}, - returns: map[string]*duplex.ReturnAddress{}, outcomes: map[string]chan duplex.Message{}} -} - -// invocationReturn is this endpoint's return capability. The empty path is the -// outcome; every other path is the invocation's own vocabulary. -type invocationReturn struct { - owner *invocationEndpoint - identifier string - invocation *ws.Invocation -} - -func (r *invocationReturn) Send(path []string, message duplex.Message) error { - if len(path) != 0 { - return r.invocation.Deliver(path, message) - } - if message.Frame.Kind != duplex.ProfileResponse { - return errors.New("invalid outcome") - } - r.owner.mu.Lock() - outcome := r.owner.outcomes[r.identifier] - r.owner.mu.Unlock() - if outcome != nil { - select { - case outcome <- message: - default: - } - } - r.invocation.Settle() - return nil -} - -// Send loops back into this endpoint's own attachment, asynchronously, so a -// composition above it can be traversed more than once in one invocation -// without running destination code on the sender's stack. -func (e *invocationEndpoint) Send(path []string, message duplex.Message) error { - go e.deliver(path, message) - return nil -} - -func (e *invocationEndpoint) Receive(receiver duplex.Receiver) (func(), error) { - e.mu.Lock() - defer e.mu.Unlock() - if e.closed { - return nil, ws.ErrClosed - } - if e.receiver != nil { - return nil, duplex.ErrReceiverExists - } - held := receiver - e.receiver = &held - return func() { - e.mu.Lock() - if e.receiver == &held { - e.receiver = nil - } - e.mu.Unlock() - }, nil -} - -func (e *invocationEndpoint) Close(code duplex.Code, reason string) error { - e.mu.Lock() - if e.closed { - e.mu.Unlock() - return nil - } - e.closed = true - receiver := e.receiver - e.receiver = nil - e.mu.Unlock() - if receiver != nil && receiver.Closed != nil { - receiver.Closed(code, reason) - } - return nil -} - -// admit delivers one request through the attached receiver with a fresh -// invocation, and returns the channel its outcome arrives on. -func (e *invocationEndpoint) admit(path []string, params json.RawMessage) (string, chan duplex.Message) { - identifier := fmt.Sprintf("x:%d", e.next.Add(1)) - outcome := make(chan duplex.Message, 1) - invocation := ws.NewInvocation(e.limits, func() { e.retirements.Add(1) }) - address := &duplex.ReturnAddress{Wire: &invocationReturn{owner: e, identifier: identifier, invocation: invocation}} - e.mu.Lock() - e.admitted[identifier] = invocation - e.returns[identifier] = address - e.outcomes[identifier] = outcome - receiver := e.receiver - e.mu.Unlock() - message := duplex.Message{ - Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier, Params: params}, - Return: address, - } - if receiver != nil && receiver.Message != nil { - receiver.Message(path, message) - } - invocation.DispatchDone() - return identifier, outcome -} - -// deliver hands a message to the attachment exactly as it arrived, without -// admitting an invocation of this endpoint's own. -func (e *invocationEndpoint) deliver(path []string, message duplex.Message) { - e.mu.Lock() - receiver := e.receiver - e.mu.Unlock() - if receiver != nil && receiver.Message != nil { - receiver.Message(path, message) - } -} - -func (e *invocationEndpoint) cancel(identifier string) { - e.mu.Lock() - invocation, address := e.admitted[identifier], e.returns[identifier] - e.mu.Unlock() - if invocation == nil { - return - } - _ = invocation.Deliver([]string{ws.InvocationControl}, duplex.Message{ - Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: identifier}, - Return: address, - }) -} - -func (e *invocationEndpoint) invocation(identifier string) *ws.Invocation { - e.mu.Lock() - defer e.mu.Unlock() - return e.admitted[identifier] -} - -// opaqueEndpoint wraps another endpoint with nothing but the contract. It -// passes the complete message, its return capability and its relative path -// through, and recognizes no concrete type on either side. -type opaqueEndpoint struct{ inner duplex.Endpoint } - -func (o opaqueEndpoint) Send(path []string, message duplex.Message) error { - return o.inner.Send(path, message) -} -func (o opaqueEndpoint) Receive(receiver duplex.Receiver) (func(), error) { - return o.inner.Receive(receiver) -} -func (o opaqueEndpoint) Close(code duplex.Code, reason string) error { - return o.inner.Close(code, reason) -} - -func TestIndependentEndpointParticipatesThroughThePublicVocabulary(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(opaqueEndpoint{endpoint}) - if err != nil { - t.Fatal(err) - } - started, release := make(chan struct{}, 1), make(chan struct{}) - cancelled := make(chan error, 1) - if _, err := ws.HandleWire(dispatch, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - started <- struct{}{} - <-release - cancelled <- ctx.Err() - return "answer", nil - }); err != nil { - t.Fatal(err) - } - identifier, outcome := endpoint.admit([]string{"read"}, nil) - <-started - invocation := endpoint.invocation(identifier) - if invocation.Retired() { - t.Fatal("an invocation retired while its body was still running") - } - endpoint.cancel(identifier) - close(release) - if err := <-cancelled; err == nil { - t.Fatal("cancellation did not reach the captured traversal") - } - select { - case <-outcome: - case <-time.After(5 * time.Second): - t.Fatal("no outcome") - } - waitRetired(t, invocation) - if endpoint.retirements.Load() != 1 { - t.Fatalf("retirements: %d", endpoint.retirements.Load()) - } -} - -func TestCapturedTraversalSurvivesDetachAndRebind(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - first, second := make(chan duplex.Message, 4), make(chan duplex.Message, 4) - detach, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { first <- m }}) - if err != nil { - t.Fatal(err) - } - identifier, _ := endpoint.admit([]string{"read"}, nil) - if got := (<-first).Frame.Kind; got != duplex.ProfileRequest { - t.Fatalf("first receiver saw %q", got) - } - detach() - if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { second <- m }}); err != nil { - t.Fatal(err) - } - endpoint.cancel(identifier) - select { - case m := <-first: - if m.Frame.Kind != duplex.ProfileCancel || m.Frame.ID != identifier { - t.Fatalf("captured receiver got %q %q", m.Frame.Kind, m.Frame.ID) - } - case <-time.After(5 * time.Second): - t.Fatal("cancellation did not reach the receiver that was captured") - } - select { - case <-second: - t.Fatal("cancellation reached the rebound receiver") - default: - } -} - -func TestEachTraversalOfOneDispatcherCapturesSeparately(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - // One dispatcher visited twice in one traversal: its outer route forwards - // back into its own inner route. Each visit is a capture of its own. - seen := make(chan string, 8) - if _, err := dispatch.Register([]string{"outer"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - if m.Frame.Kind == duplex.ProfileRequest { - go func() { _ = dispatch.Send([]string{"inner"}, m) }() - } - seen <- "outer:" + string(m.Frame.Kind) - }}); err != nil { - t.Fatal(err) - } - if _, err := dispatch.Register([]string{"inner"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - seen <- "inner:" + string(m.Frame.Kind) - }}); err != nil { - t.Fatal(err) - } - identifier, _ := endpoint.admit([]string{"outer"}, nil) - collect(t, seen, 2, map[string]bool{"outer:request": true, "inner:request": true}) - endpoint.cancel(identifier) - collect(t, seen, 2, map[string]bool{"outer:cancel": true, "inner:cancel": true}) -} - -func TestLatchedCancellationReachesACaptureInstalledAfterIt(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - inner, err := ws.NewDispatcher(dispatch.Select([]string{"a"})) - if err != nil { - t.Fatal(err) - } - controls := make(chan duplex.Message, 4) - var identifier atomic.Value - identifier.Store("") - // The outer receiver cancels the invocation before the inner capture is - // installed. The latch is what carries the control to the later capture. - if _, err := inner.Register([]string{"read"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - if m.Frame.Kind == duplex.ProfileRequest { - endpoint.cancel(m.Frame.ID) - return - } - controls <- m - }}); err != nil { - t.Fatal(err) - } - endpoint.admit([]string{"a", "read"}, nil) - _ = identifier - select { - case m := <-controls: - if m.Frame.Kind != duplex.ProfileCancel { - t.Fatalf("latched control was %q", m.Frame.Kind) - } - case <-time.After(5 * time.Second): - t.Fatal("a capture installed while cancellation was latched never received it") - } -} - -func TestInvocationBoundsCapturesAndBodies(t *testing.T) { - endpoint := newInvocationEndpoint(ws.InvocationLimits{Captures: 2, Bodies: 1}) - identifier, _ := endpoint.admit([]string{"read"}, nil) - invocation := endpoint.invocation(identifier) - message := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier}, - Return: &duplex.ReturnAddress{Wire: &invocationReturn{owner: endpoint, identifier: identifier, invocation: invocation}}} - for i := range 2 { - if _, err := ws.CaptureInvocation(message, func(duplex.Message) {}); err != nil { - t.Fatalf("capture %d refused: %v", i, err) - } - } - if _, err := ws.CaptureInvocation(message, func(duplex.Message) {}); !errors.Is(err, ws.ErrInvocationLimit) { - t.Fatalf("capture beyond the bound: %v", err) - } - body, err := ws.BeginInvocationBody(message) - if err != nil { - t.Fatal(err) - } - if _, err := ws.BeginInvocationBody(message); !errors.Is(err, ws.ErrInvocationLimit) { - t.Fatalf("body beyond the bound: %v", err) - } - // A released capture gives back no slot: the bound is a total, so neither - // depth nor shallow fan-out can grow what one invocation retains. - body.Done() - if _, err := ws.BeginInvocationBody(message); !errors.Is(err, ws.ErrInvocationLimit) { - t.Fatalf("a finished body returned its slot: %v", err) - } -} - -func TestRetirementWaitsForTheBodyAndTheControlDrain(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - identifier, _ := endpoint.admit([]string{"read"}, nil) - invocation := endpoint.invocation(identifier) - message := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: identifier}, - Return: &duplex.ReturnAddress{Wire: &invocationReturn{owner: endpoint, identifier: identifier, invocation: invocation}}} - capture, err := ws.CaptureInvocation(message, func(duplex.Message) {}) - if err != nil { - t.Fatal(err) - } - body, err := ws.BeginInvocationBody(message) - if err != nil { - t.Fatal(err) - } - invocation.Settle() - if invocation.Retired() { - t.Fatal("retired with an undelivered capture and a running body") - } - capture.Ready() - if invocation.Retired() { - t.Fatal("retired while the body was still running") - } - body.Done() - if !invocation.Retired() { - t.Fatal("did not retire once settled with nothing outstanding") - } - if _, err := ws.CaptureInvocation(message, func(duplex.Message) {}); !errors.Is(err, ws.ErrInvocationEnded) { - t.Fatalf("a retired invocation admitted a capture: %v", err) - } - if _, err := ws.BeginInvocationBody(message); !errors.Is(err, ws.ErrInvocationEnded) { - t.Fatalf("a retired invocation admitted a body: %v", err) - } -} - -func TestSequentialCompletionsBeyondCapacityRetainNothing(t *testing.T) { - endpoint := newInvocationEndpoint(ws.InvocationLimits{Captures: 2, Bodies: 2}) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - if _, err := ws.HandleWire(dispatch, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return "answer", nil }); err != nil { - t.Fatal(err) - } - var invocations []*ws.Invocation - for range 32 { - identifier, outcome := endpoint.admit([]string{"read"}, nil) - select { - case <-outcome: - case <-time.After(5 * time.Second): - t.Fatal("no outcome") - } - invocations = append(invocations, endpoint.invocation(identifier)) - } - for i, invocation := range invocations { - waitRetired(t, invocation) - if i == 0 { - continue - } - } - if got := endpoint.retirements.Load(); got != 32 { - t.Fatalf("retirements after 32 sequential completions: %d", got) - } -} - -// A bound the runtime's own facility keeps is a busy refusal: something to -// try again at, not a request that was malformed. -func TestATraversalPastTheCaptureBoundIsRefusedAsBusy(t *testing.T) { - endpoint := newInvocationEndpoint(ws.InvocationLimits{Captures: 1, Bodies: 8}) - outer, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - inner, err := ws.NewDispatcher(outer.Select([]string{"a"})) - if err != nil { - t.Fatal(err) - } - delivered := make(chan struct{}, 1) - if _, err := inner.Register([]string{"read"}, duplex.Receiver{Message: func([]string, duplex.Message) { delivered <- struct{}{} }}); err != nil { - t.Fatal(err) - } - _, outcome := endpoint.admit([]string{"a", "read"}, nil) - select { - case answer := <-outcome: - if answer.Frame.Error == nil || answer.Frame.Error.Code != "busy" { - t.Fatalf("a traversal past the bound answered %+v", answer.Frame.Error) - } - case <-time.After(5 * time.Second): - t.Fatal("no refusal") - } - select { - case <-delivered: - t.Fatal("the inner receiver was reached past the bound") - default: - } -} - -func TestADispatcherRefusesAnInvocationWithoutALifecycle(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - delivered := make(chan struct{}, 1) - if _, err := dispatch.Register([]string{"read"}, duplex.Receiver{Message: func([]string, duplex.Message) { delivered <- struct{}{} }}); err != nil { - t.Fatal(err) - } - answered := make(chan duplex.Message, 1) - bare := &bareReturn{answer: func(m duplex.Message) { answered <- m }} - // A request arrives through the contract alone, with a return capability - // that carries no lifecycle. It is refused explicitly, on its own original - // return capability, rather than routed with weaker guarantees. - endpoint.deliver([]string{"read"}, duplex.Message{ - Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "b:1", Params: []byte("null")}, - Return: &duplex.ReturnAddress{Wire: bare}, - }) - select { - case refusal := <-answered: - if refusal.Frame.Error == nil || refusal.Frame.Error.Code != "invalid_message" { - t.Fatalf("refusal was %+v", refusal.Frame.Error) - } - case <-time.After(5 * time.Second): - t.Fatal("an unmanaged invocation was neither routed nor refused") - } - select { - case <-delivered: - t.Fatal("an unmanaged invocation was routed with weaker guarantees") - default: - } - if bare.uses.Load() != 1 { - t.Fatalf("the refusal did not use the original return capability once: %d", bare.uses.Load()) - } -} - -type bareReturn struct { - answer func(duplex.Message) - uses atomic.Int64 -} - -func (b *bareReturn) Send(path []string, message duplex.Message) error { - if len(path) != 0 { - return errors.New("this return capability carries no lifecycle") - } - b.uses.Add(1) - b.answer(message) - return nil -} - -func waitRetired(t *testing.T, invocation *ws.Invocation) { - t.Helper() - for range 500 { - if invocation.Retired() { - return - } - time.Sleep(2 * time.Millisecond) - } - t.Fatal("invocation never retired") -} - -func collect(t *testing.T, seen chan string, count int, want map[string]bool) { - t.Helper() - got := map[string]bool{} - for range count { - select { - case value := <-seen: - got[value] = true - case <-time.After(5 * time.Second): - t.Fatalf("saw %v, wanted %v", got, want) - } - } - for value := range want { - if !got[value] { - t.Fatalf("saw %v, wanted %v", got, want) - } - } -} diff --git a/core/go/meta.go b/core/go/meta.go index 6ec0277..8a3ed14 100644 --- a/core/go/meta.go +++ b/core/go/meta.go @@ -1,17 +1,10 @@ -package runtime +package core import ( "context" - "encoding/json" - "maps" - "strings" -) -// metaReserved prefixes the meta keys the profile and its components keep for -// themselves โ€” a deadline, a cause โ€” so that a consumer's key and one defined -// later never collide. This version defines none, so every key under it is -// refused, on the way out as on the way in. -const metaReserved = "nightseam." + "github.com/Bitspark/bitruntime/internal/delivery/go" +) // Meta is what a frame carries about a call rather than of it: a flat map of // strings โ€” a tenant, an idempotency key, a credential that is per request โ€” @@ -19,77 +12,21 @@ const metaReserved = "nightseam." type Meta = map[string]string // A carriage travels one way at a time. The meta a frame arrived with and the -// meta the next frame sent from this context will carry are separate values -// under separate keys, so that a handler's outgoing call carries the caller's -// credential only where the handler said to: WithMeta(ctx, MetaFrom(ctx)) is -// how a handler forwards what it received, and nothing forwards it silently. -type outgoingMetaKey struct{} -type incomingMetaKey struct{} +// meta the next frame sent from this context will carry are separate values, +// so that a handler's outgoing call carries the caller's credential only where +// the handler said to: WithMeta(ctx, MetaFrom(ctx)) is how a handler forwards +// what it received, and nothing forwards it silently. // WithMeta says what the requests and events sent from ctx carry. The map is // copied, so a later write to the caller's does not reach a frame already // sent; a nil or empty map carries nothing. Keys under the reserved prefix are -// the profile's and are dropped rather than sent, since the peer at the far +// the protocol's and are dropped rather than sent, since the peer at the far // end refuses a frame carrying one. func WithMeta(ctx context.Context, meta Meta) context.Context { - carried := make(Meta, len(meta)) - for key, value := range meta { - if !strings.HasPrefix(key, metaReserved) { - carried[key] = value - } - } - if len(carried) == 0 { - return context.WithValue(ctx, outgoingMetaKey{}, Meta(nil)) - } - return context.WithValue(ctx, outgoingMetaKey{}, carried) + return delivery.WithOutgoingMeta(ctx, meta) } // MetaFrom is the meta of the frame whose handler ctx runs under, and nil // where the frame carried none or ctx is no handler's. The map is a copy: a // handler may read it, and what it writes reaches no frame. -func MetaFrom(ctx context.Context) Meta { - meta, _ := ctx.Value(incomingMetaKey{}).(Meta) - if len(meta) == 0 { - return nil - } - return maps.Clone(meta) -} - -// withIncomingMeta places what a frame carried on the context its handler runs -// under. An absent member and an empty carriage are alike to a handler, which -// reads nil for both. -func withIncomingMeta(ctx context.Context, meta Meta) context.Context { - if len(meta) == 0 { - return ctx - } - return context.WithValue(ctx, incomingMetaKey{}, meta) -} - -// outgoingMeta is what a frame sent from ctx carries, and nil where nothing -// said. It is read once per frame, so that a context changed between two calls -// is obeyed by each. -func outgoingMeta(ctx context.Context) Meta { - meta, _ := ctx.Value(outgoingMetaKey{}).(Meta) - if len(meta) == 0 { - return nil - } - return meta -} - -// validMeta holds meta to what a carriage is, reading the member as it was -// spelled rather than as the frame holds it: an object, since a member spelled -// null is not an absent one; every value a string, which map[string]string -// cannot tell from a null it would read as the empty one; and no key of the -// reserved prefix. -func validMeta(raw json.RawMessage) bool { - var values map[string]json.RawMessage - if err := json.Unmarshal(raw, &values); err != nil || values == nil { - return false - } - for key, value := range values { - if strings.HasPrefix(key, metaReserved) || len(value) == 0 || value[0] != '"' { - return false - } - } - return true -} +func MetaFrom(ctx context.Context) Meta { return delivery.IncomingMeta(ctx) } diff --git a/core/go/pair.go b/core/go/pair.go index 30c26f6..47770ec 100644 --- a/core/go/pair.go +++ b/core/go/pair.go @@ -1,32 +1,85 @@ -package runtime +package core import ( "context" "encoding/json" "errors" - "fmt" "maps" "sync" "time" - "github.com/Bitspark/nightseam/duplex/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" ) -// NewWirePair constructs a bounded local carrier with two relative origins. -// Sending on either endpoint delivers to receivers on the other. It allocates -// no Peer and preserves structured frames and verified local request context. -// The limits, propagator and observer in options apply to both directions. -func NewWirePair(options Options) (left, right duplex.Endpoint, err error) { +// PairOptions bounds a local pair. Zero values select the defaults, which are +// the protocol engine's: 64 concurrent handlers and 128 pending requests and +// queued messages per direction, frames of at most 1 MiB, a 30-second request +// deadline and a 10-second stall deadline for event consumers. +type PairOptions struct { + // MaxConcurrentHandlers bounds the requests a direction runs at once; the + // one past it is refused busy. + MaxConcurrentHandlers int + // MaxPendingRequests bounds the requests a direction holds admitted and + // unanswered; the one past it is refused busy. + MaxPendingRequests int + // QueueCapacity bounds the requests and events a direction holds queued. + // A full queue ends the pair with ErrBackpressure. + QueueCapacity int + // MaxFrameBytes bounds the envelope each message would travel as. + MaxFrameBytes int64 + // RequestTimeout answers a request whose handler has not answered by then. + RequestTimeout time.Duration + // WriteTimeout bounds how long an event consumer may take before the pair + // ends as stalled. + WriteTimeout time.Duration + // Propagator moves a trace between a frame and a handler's context. + Propagator Propagator +} + +func (o PairOptions) normalized() (PairOptions, error) { + if o.MaxConcurrentHandlers < 0 || o.MaxPendingRequests < 0 || o.QueueCapacity < 0 || o.MaxFrameBytes < 0 || o.RequestTimeout < 0 || o.WriteTimeout < 0 { + return o, errors.New("bitruntime: pair limits must not be negative") + } + if o.MaxConcurrentHandlers == 0 { + o.MaxConcurrentHandlers = 64 + } + if o.MaxPendingRequests == 0 { + o.MaxPendingRequests = 128 + } + if o.QueueCapacity == 0 { + o.QueueCapacity = 128 + } + if o.MaxFrameBytes == 0 { + o.MaxFrameBytes = 1 << 20 + } + if o.RequestTimeout == 0 { + o.RequestTimeout = 30 * time.Second + } + if o.WriteTimeout == 0 { + o.WriteTimeout = 10 * time.Second + } + if o.Propagator == nil { + o.Propagator = DefaultPropagator + } + return o, nil +} + +// NewPair constructs a bounded local carrier with two relative origins. +// Sending on either endpoint delivers to the receiver on the other. It +// allocates no peer, serializes nothing, and preserves structured frames, the +// original return capability's identity and received context. Each admitted +// request gets a fresh return capability that carries its invocation lifecycle. +func NewPair(options PairOptions) (left, right wire.Endpoint, err error) { o, err := options.normalized() if err != nil { return nil, nil, err } - if len(o.Handlers) != 0 || len(o.Events) != 0 || o.Prepare != nil { - return nil, nil, errors.New("local wires install receivers through Receive") - } - p := &localWirePair{options: o, done: make(chan struct{})} + p := &localPair{options: o, done: make(chan struct{})} for i := range p.ends { - p.ends[i] = &localWire{pair: p, wake: make(chan struct{}, 1), calls: map[returnKey]*localWireCall{}} + p.ends[i] = &localEnd{pair: p, wake: make(chan struct{}, 1), calls: map[returnKey]*localCall{}} } p.ends[0].other, p.ends[1].other = p.ends[1], p.ends[0] for _, end := range p.ends { @@ -35,42 +88,49 @@ func NewWirePair(options Options) (left, right duplex.Endpoint, err error) { return p.ends[0], p.ends[1], nil } -type localWirePair struct { +// returnKey correlates a request by its return capability's identity and its +// identifier, as the contract requires. +type returnKey struct { + address *wire.ReturnAddress + id string +} + +type localPair struct { mu sync.Mutex - options Options - ends [2]*localWire + options PairOptions + ends [2]*localEnd done chan struct{} closed bool } type localRegistration struct { - receiver duplex.Receiver + receiver wire.Receiver active bool } type localDelivery struct { path []string - message duplex.Message - call *localWireCall + message wire.Message + call *localCall refusal error } -type localWire struct { - pair *localWirePair - other *localWire +type localEnd struct { + pair *localPair + other *localEnd wake chan struct{} queue []localDelivery dataQueued int active int eventTimer *time.Timer - calls map[returnKey]*localWireCall + calls map[returnKey]*localCall receiver *localRegistration } -type localWireCall struct { +type localCall struct { key returnKey path []string - message duplex.Message - returning *duplex.ReturnAddress + message wire.Message + returning *wire.ReturnAddress registration *localRegistration - dispatch *wireDispatchContext + dispatch *delivery.Context invocation *Invocation cancel context.CancelFunc timer *time.Timer @@ -81,36 +141,37 @@ type localWireCall struct { cancelled bool } -func (w *localWire) Send(path []string, message duplex.Message) error { - name, err := duplex.EncodePath(path) +func (w *localEnd) Send(path []string, message wire.Message) error { + name, err := profile.EncodePath(path) if err != nil { - return err + return Unpublished(err) } - if err := validateWireFrame(name, message.Frame, w.pair.options.MaxFrameBytes); err != nil { - return err + if err := profile.Validate(name, message.Frame, w.pair.options.MaxFrameBytes); err != nil { + return Unpublished(err) } - if message.Frame.Kind != duplex.ProfileRequest && message.Frame.Kind != duplex.ProfileEvent && message.Frame.Kind != duplex.ProfileCancel { - return errors.New("a response is sent to its request's return address") + if message.Frame.Kind != wire.ProfileRequest && message.Frame.Kind != wire.ProfileEvent && message.Frame.Kind != wire.ProfileCancel { + return Unpublished(errors.New("bitruntime: a response is sent to its request's return address")) } - if message.Frame.Kind != duplex.ProfileEvent && (message.Return == nil || message.Return.Wire == nil) { - return errors.New("a wire request or cancellation requires a return address") + if message.Frame.Kind != wire.ProfileEvent && (message.Return == nil || message.Return.Wire == nil) { + return Unpublished(errors.New("bitruntime: a request or cancellation requires a return address")) } + // Copy, then keep: what the receiver sees is what was validated. message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) message.Frame.Meta = maps.Clone(message.Frame.Meta) return w.other.admit(append([]string(nil), path...), message) } -func (w *localWire) admit(path []string, message duplex.Message) error { +func (w *localEnd) admit(path []string, message wire.Message) error { p := w.pair key := returnKey{message.Return, message.Frame.ID} delivery := localDelivery{path: path, message: message} p.mu.Lock() if p.closed { p.mu.Unlock() - return ErrClosed + return Unpublished(transports.ErrClosed) } - if message.Frame.Kind == duplex.ProfileCancel { + if message.Frame.Kind == wire.ProfileCancel { call := w.calls[key] if call == nil || call.completed || call.cancelQueued || call.cancelled { p.mu.Unlock() @@ -121,21 +182,19 @@ func (w *localWire) admit(path []string, message duplex.Message) error { delivery.message.Return = call.returning } else { if w.dataQueued >= p.options.QueueCapacity { - depth := w.dataQueued p.mu.Unlock() - p.observe(Backpressure{At: time.Now(), Queued: depth, Stalled: true, Deadline: p.options.WriteTimeout}) - p.end(duplex.CodeDuplex, "local wire queue limit reached") - return ErrBackpressure + p.end(transports.CodeProtocol, "local wire queue limit reached") + return Unpublished(Ended(ErrBackpressure)) } w.dataQueued++ - if message.Frame.Kind == duplex.ProfileRequest { + if message.Frame.Kind == wire.ProfileRequest { if w.calls[key] != nil { delivery.refusal = &PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} } else if len(w.calls) >= p.options.MaxPendingRequests { delivery.refusal = &PublicError{Code: "busy", Message: "Outstanding call limit reached"} } else { - call := &localWireCall{key: key, path: path, message: message, invocation: NewInvocation(DefaultInvocationLimits(), nil)} - call.returning = &duplex.ReturnAddress{Wire: &localReturn{wire: w, call: call}} + call := &localCall{key: key, path: path, message: message, invocation: NewInvocation(DefaultInvocationLimits(), nil)} + call.returning = &wire.ReturnAddress{Wire: &localReturn{end: w, call: call}} w.calls[key] = call delivery.call, delivery.message.Return = call, call.returning } @@ -147,13 +206,17 @@ func (w *localWire) admit(path []string, message duplex.Message) error { return nil } -func (w *localWire) signal() { +func (w *localEnd) signal() { select { case w.wake <- struct{}{}: default: } } -func (w *localWire) retireLocked(call *localWireCall) { + +// retireLocked frees a call's pending slot once it is answered and no +// already queued cancellation still owns its reservation. It never removes a +// newer admission that reused the same return identity and identifier. +func (w *localEnd) retireLocked(call *localCall) { if !call.completed || call.cancelQueued || w.calls[call.key] != call { return } @@ -161,8 +224,8 @@ func (w *localWire) retireLocked(call *localWireCall) { call.invocation.Settle() call.invocation.DispatchDone() } -func (w *localWire) complete(call *localWireCall) { - w.pair.mu.Lock() + +func (w *localEnd) completeLocked(call *localCall) { if !call.completed { call.completed = true if call.active { @@ -177,10 +240,9 @@ func (w *localWire) complete(call *localWireCall) { } } w.retireLocked(call) - w.pair.mu.Unlock() } -func (w *localWire) next() (localDelivery, bool) { +func (w *localEnd) next() (localDelivery, bool) { w.pair.mu.Lock() defer w.pair.mu.Unlock() if w.pair.closed || len(w.queue) == 0 { @@ -189,14 +251,15 @@ func (w *localWire) next() (localDelivery, bool) { delivery := w.queue[0] w.queue[0] = localDelivery{} w.queue = w.queue[1:] - if delivery.message.Frame.Kind != duplex.ProfileCancel { + if delivery.message.Frame.Kind != wire.ProfileCancel { w.dataQueued-- } return delivery, true } -func (w *localWire) run() { + +func (w *localEnd) run() { for { - delivery, ok := w.next() + next, ok := w.next() if !ok { select { case <-w.pair.done: @@ -205,12 +268,12 @@ func (w *localWire) run() { continue } } - if delivery.refusal != nil { - sendWireResponse(delivery.message, nil, delivery.refusal) + if next.refusal != nil { + Respond(next.message, nil, next.refusal) continue } - if delivery.message.Frame.Kind == duplex.ProfileCancel { - w.deliverCancel(delivery.call, delivery.message) + if next.message.Frame.Kind == wire.ProfileCancel { + w.deliverCancel(next.call, next.message) continue } w.pair.mu.Lock() @@ -222,40 +285,37 @@ func (w *localWire) run() { w.pair.mu.Unlock() return } - if delivery.call != nil { + if next.call != nil { if registration == nil || w.active >= w.pair.options.MaxConcurrentHandlers { w.pair.mu.Unlock() code, message := "method_not_found", "Unknown method" if registration != nil { code, message = "busy", "Too many concurrent requests" } - sendWireResponse(delivery.message, nil, &PublicError{Code: code, Message: message}) + Respond(next.message, nil, &PublicError{Code: code, Message: message}) continue } - call := delivery.call + call := next.call call.registration, call.active = registration, true w.active++ base := context.Background() - if source, ok := call.key.address.Wire.(interface{ wireDispatch() *wireDispatchContext }); ok { - call.dispatch = source.wireDispatch() + if source, ok := call.key.address.Wire.(delivery.Source); ok { + call.dispatch = source.BitruntimeDelivery() if call.dispatch != nil { - base = call.dispatch.ctx + base = call.dispatch.Ctx } } if call.dispatch == nil { - base = w.pair.options.Propagator.Extract(base, Trace{Parent: delivery.message.Frame.Traceparent, State: delivery.message.Frame.Tracestate}) + base = w.pair.options.Propagator.Extract(base, Trace{Parent: next.message.Frame.Traceparent, State: next.message.Frame.Tracestate}) } ctx, cancel := context.WithCancel(base) call.cancel = cancel if call.dispatch != nil { copied := *call.dispatch - copied.ctx = ctx + copied.Ctx = ctx call.dispatch = &copied } else { - name, _ := duplex.EncodePath(delivery.path) - call.dispatch = &wireDispatchContext{ctx: ctx, maxFrameBytes: w.pair.options.MaxFrameBytes, panic: func(value any) { - w.pair.observe(HandlerPanic{At: time.Now(), Method: name, Value: fmt.Sprint(value), Family: w.pair.options.Families[name]}) - }} + call.dispatch = &delivery.Context{Ctx: ctx, MaxFrameBytes: w.pair.options.MaxFrameBytes} } call.timer = time.AfterFunc(w.pair.options.RequestTimeout, func() { w.timeout(call) }) } @@ -263,49 +323,48 @@ func (w *localWire) run() { if registration == nil { continue } - if delivery.message.Frame.Kind == duplex.ProfileEvent { - if _, associated := eventContextOf(delivery.message); !associated { - ctx := w.pair.options.Propagator.Extract(context.Background(), Trace{Parent: delivery.message.Frame.Traceparent, State: delivery.message.Frame.Tracestate}) - delivery.message = withWireEventContext(delivery.message, ctx) + if next.message.Frame.Kind == wire.ProfileEvent { + if _, associated := delivery.EventContext(next.message); !associated { + ctx := w.pair.options.Propagator.Extract(context.Background(), Trace{Parent: next.message.Frame.Traceparent, State: next.message.Frame.Tracestate}) + next.message = delivery.WithEventContext(next.message, ctx) } w.pair.mu.Lock() w.eventTimer = time.AfterFunc(w.pair.options.WriteTimeout, func() { w.pair.mu.Lock() - closed, depth := w.pair.closed, w.dataQueued + closed := w.pair.closed w.pair.mu.Unlock() if !closed { - w.pair.observe(Backpressure{At: time.Now(), Queued: depth, Stalled: true, Deadline: w.pair.options.WriteTimeout}) - w.pair.end(duplex.CodeDuplex, "local wire event consumer stalled") + w.pair.end(transports.CodeProtocol, "local wire event consumer stalled") } }) w.pair.mu.Unlock() } - w.deliver(registration, delivery.path, delivery.message) - if delivery.message.Frame.Kind == duplex.ProfileEvent { + w.deliver(registration, next.path, next.message) + if next.message.Frame.Kind == wire.ProfileEvent { w.pair.mu.Lock() - w.eventTimer.Stop() - w.eventTimer = nil + if w.eventTimer != nil { + w.eventTimer.Stop() + w.eventTimer = nil + } w.pair.mu.Unlock() } } } -func (w *localWire) deliver(registration *localRegistration, path []string, message duplex.Message) { +func (w *localEnd) deliver(registration *localRegistration, path []string, message wire.Message) { defer func() { if value := recover(); value != nil { - name, _ := duplex.EncodePath(path) - w.pair.observe(HandlerPanic{At: time.Now(), Method: name, Value: fmt.Sprint(value), Family: w.pair.options.Families[name]}) - if message.Frame.Kind == duplex.ProfileRequest { - sendWireResponse(message, nil, errors.New("wire receiver panic")) + if message.Frame.Kind == wire.ProfileRequest { + Respond(message, nil, errors.New("wire receiver panic")) } else { - w.pair.end(duplex.CodeDuplex, "wire event receiver failed") + w.pair.end(transports.CodeProtocol, "wire event receiver failed") } } }() registration.receiver.Message(path, message) } -func (w *localWire) deliverCancel(call *localWireCall, message duplex.Message) { +func (w *localEnd) deliverCancel(call *localCall, message wire.Message) { w.pair.mu.Lock() call.cancelQueued, call.cancelled = false, true registration, completed := call.registration, call.completed @@ -319,7 +378,7 @@ func (w *localWire) deliverCancel(call *localWireCall, message duplex.Message) { } } -func (w *localWire) timeout(call *localWireCall) { +func (w *localEnd) timeout(call *localCall) { w.pair.mu.Lock() if w.pair.closed || call.completed { w.pair.mu.Unlock() @@ -330,7 +389,7 @@ func (w *localWire) timeout(call *localWireCall) { } if !call.cancelQueued && !call.cancelled { call.cancelQueued = true - w.queue = append(w.queue, localDelivery{path: call.path, message: duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: call.message.Frame.ID}, Return: call.returning}, call: call}) + w.queue = append(w.queue, localDelivery{path: call.path, message: wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: call.message.Frame.ID}, Return: call.returning}, call: call}) } respond := !call.responded call.responded = true @@ -340,19 +399,19 @@ func (w *localWire) timeout(call *localWireCall) { // actual response. An application ignoring cancellation cannot spawn an // unbounded number of replacement handlers by repeatedly timing out. if respond { - sendWireResponse(call.message, nil, context.DeadlineExceeded) + Respond(call.message, nil, context.DeadlineExceeded) } } -func (w *localWire) Receive(receiver duplex.Receiver) (func(), error) { +func (w *localEnd) Receive(receiver wire.Receiver) (func(), error) { registration := &localRegistration{receiver: receiver, active: true} w.pair.mu.Lock() defer w.pair.mu.Unlock() if w.pair.closed { - return nil, ErrClosed + return nil, transports.ErrClosed } if w.receiver != nil { - return nil, duplex.ErrReceiverExists + return nil, ErrReceiverExists } w.receiver = registration return func() { @@ -364,16 +423,18 @@ func (w *localWire) Receive(receiver duplex.Receiver) (func(), error) { w.pair.mu.Unlock() }, nil } -func (w *localWire) Close(code duplex.Code, reason string) error { + +// Close ends the pair for both sides. An observe-only code closes it as an +// abort would: nothing is transmitted in-process either way. +func (w *localEnd) Close(code wire.Code, reason string) error { w.pair.end(code, reason) return nil } -func (p *localWirePair) observe(event ObserverEvent) { - if p.options.Observer != nil { - func() { defer func() { _ = recover() }(); p.options.Observer.Observe(event) }() - } -} -func (p *localWirePair) end(code duplex.Code, reason string) { + +// end closes the pair once. Every request admitted and not yet answered is +// answered with disconnected, and every refusal still queued is answered with +// the refusal it was admitted with, so no caller is left to its own deadline. +func (p *localPair) end(code wire.Code, reason string) { p.mu.Lock() if p.closed { p.mu.Unlock() @@ -381,8 +442,9 @@ func (p *localWirePair) end(code duplex.Code, reason string) { } p.closed = true close(p.done) - var receivers []duplex.Receiver - var requests []duplex.Message + var receivers []wire.Receiver + var requests []wire.Message + var refusals []localDelivery for _, end := range p.ends { if end.eventTimer != nil { end.eventTimer.Stop() @@ -391,6 +453,11 @@ func (p *localWirePair) end(code duplex.Code, reason string) { end.receiver.active = false receivers = append(receivers, end.receiver.receiver) } + for _, queued := range end.queue { + if queued.refusal != nil { + refusals = append(refusals, queued) + } + } for _, call := range end.calls { if call.timer != nil { call.timer.Stop() @@ -407,60 +474,70 @@ func (p *localWirePair) end(code duplex.Code, reason string) { call.invocation.DispatchDone() } end.receiver = nil - end.calls = map[returnKey]*localWireCall{} + end.calls = map[returnKey]*localCall{} end.queue, end.dataQueued = nil, 0 } p.mu.Unlock() - p.observe(ConnectionClosed{At: time.Now(), Code: int(code), Reason: reason, Local: true}) go func() { for _, receiver := range receivers { if receiver.Closed != nil { func() { defer func() { _ = recover() }(); receiver.Closed(code, reason) }() } } + for _, refused := range refusals { + Respond(refused.message, nil, refused.refusal) + } for _, request := range requests { - sendWireResponse(request, nil, ErrClosed) + Respond(request, nil, transports.ErrClosed) } }() } type localReturn struct { - wire *localWire - call *localWireCall + end *localEnd + call *localCall } -func (r *localReturn) wireDispatch() *wireDispatchContext { return r.call.dispatch } +// BitruntimeDelivery is the context the pair established for this request. +func (r *localReturn) BitruntimeDelivery() *delivery.Context { return r.call.dispatch } // Invocation exposes this return capability's lifecycle to the pair that owns // it. Participants reach the same state through the vocabulary on Send. func (r *localReturn) Invocation() *Invocation { return r.call.invocation } -func (r *localReturn) Send(path []string, message duplex.Message) (err error) { +func (r *localReturn) Send(path []string, message wire.Message) (err error) { if len(path) != 0 { return r.call.invocation.Deliver(path, message) } - if message.Frame.Kind != duplex.ProfileResponse || message.Frame.ID != r.call.message.Frame.ID { - return errors.New("invalid wire response") + if message.Frame.Kind != wire.ProfileResponse || message.Frame.ID != r.call.message.Frame.ID { + return errors.New("bitruntime: invalid wire response") } - if err := validateWireFrame("", message.Frame, r.wire.pair.options.MaxFrameBytes); err != nil { + if err := profile.Validate("", message.Frame, r.end.pair.options.MaxFrameBytes); err != nil { + // Refused before completion, so the response helper can still send + // its bounded internal-error fallback. return err } - // Validation refuses an attempt before completion, allowing the shared - // response helper to substitute its bounded internal-error fallback. - defer r.wire.complete(r.call) - defer func() { - if value := recover(); value != nil { - err = fmt.Errorf("wire return failed: %v", value) - } - }() - r.wire.pair.mu.Lock() + p := r.end.pair + p.mu.Lock() if r.call.responded || r.call.completed { - r.wire.pair.mu.Unlock() - return ErrClosed + // A deadline already answered the caller. This is the handler's actual + // response, which is what releases its budget. + r.end.completeLocked(r.call) + p.mu.Unlock() + return transports.ErrClosed } r.call.responded = true - r.wire.pair.mu.Unlock() + // Retire before the caller can hold its answer: a caller that issues its + // next call as soon as this one returns must find the slot free. A queued + // cancellation keeps the reservation until it drains. + r.end.completeLocked(r.call) + p.mu.Unlock() r.call.invocation.Settle() + defer func() { + if value := recover(); value != nil { + err = errors.New("bitruntime: wire return failed") + } + }() message.Frame.Result = append(json.RawMessage(nil), message.Frame.Result...) if message.Frame.Error != nil { copied := *message.Frame.Error diff --git a/core/go/pair_test.go b/core/go/pair_test.go deleted file mode 100644 index 70e9411..0000000 --- a/core/go/pair_test.go +++ /dev/null @@ -1,330 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" -) - -type localTestReturn struct{ send func(duplex.Message) error } - -type localTestObserver func(ObserverEvent) - -func (observe localTestObserver) Observe(event ObserverEvent) { observe(event) } - -func (r *localTestReturn) Send(_ []string, m duplex.Message) error { return r.send(m) } - -func testBinding(t *testing.T, endpoint duplex.Endpoint) *Dispatcher { - t.Helper() - binding, err := NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = binding.Close(duplex.CodeNormal, "done") }) - return binding -} - -func localPair(t *testing.T, options Options) (duplex.Endpoint, duplex.Endpoint) { - t.Helper() - a, b, err := NewWirePair(options) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = a.Close(duplex.CodeNormal, "done") }) - return a, b -} - -func TestLocalWirePairRoundTripReverseAndIsolation(t *testing.T) { - a, b := localPair(t, Options{}) - aBinding := testBinding(t, a) - _, err := HandleWire(aBinding, []string{"reverse"}, func(_ context.Context, raw json.RawMessage) (any, error) { return string(raw), nil }) - if err != nil { - t.Fatal(err) - } - bBinding := testBinding(t, b) - _, err = HandleWire(bBinding, []string{"call"}, func(ctx context.Context, raw json.RawMessage) (any, error) { - var result string - err := CallWire(ctx, b, []string{"reverse"}, raw, &result) - return result, err - }) - if err != nil { - t.Fatal(err) - } - var result string - if err := CallWire(context.Background(), a, []string{"call"}, 7, &result); err != nil || result != "7" { - t.Fatalf("reverse result %q: %v", result, err) - } - x, y := localPair(t, Options{}) - yBinding := testBinding(t, y) - _, _ = HandleWire(yBinding, []string{"call"}, func(context.Context, json.RawMessage) (any, error) { return "independent", nil }) - _ = a.Close(duplex.CodeNormal, "first pair only") - if err := CallWire(context.Background(), x, []string{"call"}, nil, &result); err != nil || result != "independent" { - t.Fatalf("other pair %q: %v", result, err) - } -} - -func TestLocalWirePairRetainsPendingUntilResponse(t *testing.T) { - a, b := localPair(t, Options{MaxPendingRequests: 1}) - started, release := make(chan struct{}), make(chan struct{}) - bBinding := testBinding(t, b) - _, _ = HandleWire(bBinding, []string{"hold"}, func(context.Context, json.RawMessage) (any, error) { close(started); <-release; return "done", nil }) - first := make(chan error, 1) - go func() { var result string; first <- CallWire(context.Background(), a, []string{"hold"}, nil, &result) }() - <-started - var result any - err := CallWire(context.Background(), a, []string{"hold"}, nil, &result) - var public *PublicError - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatalf("pending budget: %v", err) - } - close(release) - if err := <-first; err != nil { - t.Fatal(err) - } - _, _ = HandleWire(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) - if err := CallWire(context.Background(), a, []string{"next"}, nil, &result); err != nil { - t.Fatal(err) - } -} - -func TestLocalWirePairOrderedEventsAndReservedCancel(t *testing.T) { - a, b := localPair(t, Options{QueueCapacity: 1, MaxPendingRequests: 1}) - bBinding := testBinding(t, b) - started, cancelled := make(chan struct{}), make(chan struct{}) - _, _ = HandleWire(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - close(started) - <-ctx.Done() - close(cancelled) - return nil, ctx.Err() - }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - answer := make(chan error, 1) - go func() { answer <- CallWire(ctx, a, []string{"hold"}, nil, nil) }() - <-started - entered, release, drained := make(chan struct{}), make(chan struct{}), make(chan struct{}) - var mu sync.Mutex - var seen []int - _, _ = bBinding.Register([]string{"event"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - var n int - _ = json.Unmarshal(m.Frame.Data, &n) - mu.Lock() - seen = append(seen, n) - mu.Unlock() - if n == 1 { - close(entered) - <-release - } else { - close(drained) - } - }}) - if err := EmitWire(context.Background(), a, []string{"event"}, 1); err != nil { - t.Fatal(err) - } - <-entered - if err := EmitWire(context.Background(), a, []string{"event"}, 2); err != nil { - t.Fatal(err) - } - cancel() - if !errors.Is(<-answer, context.Canceled) { - t.Fatal("caller was not cancelled") - } - close(release) - select { - case <-cancelled: - case <-time.After(time.Second): - t.Fatal("reserved cancel did not arrive") - } - <-drained - mu.Lock() - defer mu.Unlock() - if len(seen) != 2 || seen[0] != 1 || seen[1] != 2 { - t.Fatalf("event order: %v", seen) - } -} - -func TestLocalWirePairOverflowClosesOnlyItsCarrier(t *testing.T) { - a, b := localPair(t, Options{QueueCapacity: 1}) - bBinding := testBinding(t, b) - entered, release, ended := make(chan struct{}), make(chan struct{}), make(chan struct{}) - defer close(release) - _, _ = bBinding.Register([]string{"event"}, duplex.Receiver{Message: func([]string, duplex.Message) { close(entered); <-release }, Closed: func(duplex.Code, string) { close(ended) }}) - if err := EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { - t.Fatal(err) - } - <-entered - if err := EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { - t.Fatal(err) - } - if err := EmitWire(context.Background(), a, []string{"event"}, nil); !errors.Is(err, ErrBackpressure) { - t.Fatalf("overflow: %v", err) - } - select { - case <-ended: - case <-time.After(time.Second): - t.Fatal("blocked consumer hid closure") - } -} - -func TestLocalWirePairReturnMappingAndFailedResponseRetirement(t *testing.T) { - a, b := localPair(t, Options{MaxPendingRequests: 1}) - bBinding := testBinding(t, b) - received := make(chan duplex.Message, 2) - _, _ = bBinding.Register([]string{"raw"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { received <- m }}) - failed := errors.New("return failed") - original := &duplex.ReturnAddress{Wire: &localTestReturn{send: func(duplex.Message) error { return Unpublished(failed) }}} - if err := a.Send([]string{"raw"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage("null")}, Return: original}); err != nil { - t.Fatal(err) - } - request := <-received - if request.Return == original { - t.Fatal("root did not map the return capability") - } - if err := a.Send([]string{"raw"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: "c:1"}, Return: original}); err != nil { - t.Fatal(err) - } - if cancelled := <-received; cancelled.Return != request.Return { - t.Fatal("cancellation used a different return capability") - } - err := request.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: "c:1", Result: json.RawMessage("null")}}) - if !errors.Is(err, failed) { - t.Fatalf("return failure lost: %v", err) - } - var unpublished *UnpublishedError - if errors.As(err, &unpublished) { - t.Fatal("return retained publication proof after dispatch") - } - _, _ = HandleWire(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) - var result string - if err := CallWire(context.Background(), a, []string{"next"}, nil, &result); err != nil || result != "reused" { - t.Fatalf("next: %q, %v", result, err) - } -} - -func TestLocalWirePairPrivateDispatchContext(t *testing.T) { - a, b := localPair(t, Options{}) - type verifiedKey struct{} - verified := &struct{ identity string }{"verified locally"} - dispatch := &wireDispatchContext{ctx: context.WithValue(context.Background(), verifiedKey{}, verified)} - bBinding := testBinding(t, b) - _, _ = HandleWire(bBinding, []string{"inspect"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - if ctx.Value(verifiedKey{}) != verified { - return nil, errors.New("lost verified dispatch context") - } - return "observed", nil - }) - var result string - if err := callWire(context.Background(), a, []string{"inspect"}, nil, &result, dispatch); err != nil || result != "observed" { - t.Fatalf("context: %q, %v", result, err) - } -} - -func TestLocalDispatcherExactAndPrefixRoutes(t *testing.T) { - a, b := localPair(t, Options{}) - bBinding := testBinding(t, b) - for _, path := range [][]string{nil, {"a"}} { - label := "root" - if len(path) > 0 { - label = "a" - } - _, err := bBinding.RegisterPrefix(path, duplex.Receiver{Message: func(received []string, m duplex.Message) { - if len(received) == 0 { - t.Error("callback path lost its origin") - } - sendWireResponse(m, json.RawMessage(`"`+label+`"`), nil) - }}) - if err != nil { - t.Fatal(err) - } - } - detach, _ := HandleWire(bBinding, []string{"a", "b"}, func(context.Context, json.RawMessage) (any, error) { return "exact", nil }) - var result string - if err := CallWire(context.Background(), a, []string{"a", "b"}, nil, &result); err != nil || result != "exact" { - t.Fatalf("exact: %q, %v", result, err) - } - detach() - if err := CallWire(context.Background(), a, []string{"a", "b"}, nil, &result); err != nil || result != "a" { - t.Fatalf("prefix: %q, %v", result, err) - } - if err := CallWire(context.Background(), a, []string{"other"}, nil, &result); err != nil || result != "root" { - t.Fatalf("root: %q, %v", result, err) - } -} - -func TestLocalWirePairDeadlineRetainsNoncooperativeHandlerBudget(t *testing.T) { - a, b := localPair(t, Options{RequestTimeout: 15 * time.Millisecond, MaxConcurrentHandlers: 1}) - started, cancelled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) - defer close(release) - bBinding := testBinding(t, b) - _, _ = HandleWire(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - close(started) - <-ctx.Done() - close(cancelled) - <-release - return nil, nil - }) - first := make(chan error, 1) - go func() { first <- CallWire(context.Background(), a, []string{"hold"}, nil, nil) }() - <-started - var public *PublicError - if err := <-first; !errors.As(err, &public) || public.Code != "cancelled" { - t.Fatalf("deadline: %v", err) - } - select { - case <-cancelled: - case <-time.After(time.Second): - t.Fatal("deadline did not cancel handler") - } - err := CallWire(context.Background(), a, []string{"hold"}, nil, nil) - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatalf("handler budget: %v", err) - } -} - -func TestLocalWirePairStalledEventDeadlineIsObserved(t *testing.T) { - pressure := make(chan Backpressure, 1) - a, b := localPair(t, Options{WriteTimeout: 15 * time.Millisecond, Observer: localTestObserver(func(event ObserverEvent) { - if event, ok := event.(Backpressure); ok && event.Stalled { - pressure <- event - } - })}) - bBinding := testBinding(t, b) - release, closed := make(chan struct{}), make(chan struct{}) - defer close(release) - _, _ = bBinding.Register([]string{"event"}, duplex.Receiver{Message: func([]string, duplex.Message) { <-release }, Closed: func(duplex.Code, string) { close(closed) }}) - if err := EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { - t.Fatal(err) - } - select { - case event := <-pressure: - if event.Deadline != 15*time.Millisecond { - t.Fatalf("deadline: %v", event.Deadline) - } - case <-time.After(time.Second): - t.Fatal("stalled event was not observed") - } - <-closed - if err := EmitWire(context.Background(), a, []string{"event"}, nil); !errors.Is(err, ErrClosed) { - t.Fatalf("closed pair: %v", err) - } -} - -func TestLocalWirePairOversizedResponseUsesBoundedFallback(t *testing.T) { - a, b := localPair(t, Options{MaxFrameBytes: 512}) - bBinding := testBinding(t, b) - _, _ = HandleWire(bBinding, []string{"large"}, func(context.Context, json.RawMessage) (any, error) { return strings.Repeat("x", 2048), nil }) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - var result string - err := CallWire(ctx, a, []string{"large"}, nil, &result) - var public *PublicError - if !errors.As(err, &public) || public.Code != "internal" { - t.Fatalf("oversized response: %v", err) - } -} diff --git a/core/go/publication.go b/core/go/publication.go index 15b4a3d..0101e51 100644 --- a/core/go/publication.go +++ b/core/go/publication.go @@ -1,8 +1,8 @@ -package runtime +package core import "errors" -// UnpublishedError reports a local refusal before a frame entered the peer's +// UnpublishedError reports a local refusal before a frame entered a carrier's // outbound queue or a local implementation dispatched. Its underlying cause // retains the ordinary public error or cancellation identity. A queued write failure or a remote response never // supplies this proof, even if it has the same error code or message. diff --git a/core/go/publication_test.go b/core/go/publication_test.go deleted file mode 100644 index c899486..0000000 --- a/core/go/publication_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync/atomic" - "testing" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -func wantUnpublished(t *testing.T, err error, want bool) { - t.Helper() - var unpublished *ws.UnpublishedError - if got := errors.As(err, &unpublished); got != want || err == nil { - t.Fatalf("publication proof: %v, want unpublished=%v", err, want) - } -} - -func TestUnpublishedProofIsLocalToTheSendAttempt(t *testing.T) { - entered, finish := make(chan struct{}, 1), make(chan struct{}) - client, server := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "wait": func(c context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - entered <- struct{}{} - select { - case <-finish: - return nil, nil - case <-c.Done(): - return nil, c.Err() - } - }, - "busy": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { - return nil, &ws.PublicError{Code: "busy", Message: "retained before refusing"} - }, - "nested": func(c context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - withdrawn, cancel := context.WithCancel(c) - cancel() - return nil, peer.Call(withdrawn, "never.sent", nil, nil) - }, - }}, ws.Options{MaxPendingRequests: 1, MaxFrameBytes: 512}) - _ = server - ctx, cancel := context.WithCancel(context.Background()) - cancel() - err := client.Call(ctx, "wait", nil, nil) - wantUnpublished(t, err, true) - if !errors.Is(err, context.Canceled) { - t.Fatalf("wrapping lost cancellation identity: %v", err) - } - wantUnpublished(t, client.Call(context.Background(), "wait", make(chan int), nil), true) - wantUnpublished(t, client.Call(context.Background(), "wait", strings.Repeat("x", 1024), nil), true) - wantUnpublished(t, client.Emit(context.Background(), "event", make(chan int)), true) - wantUnpublished(t, client.Emit(context.Background(), "event", strings.Repeat("x", 1024)), true) - - done := make(chan error, 1) - go func() { done <- client.Call(context.Background(), "wait", nil, nil) }() - receive(t, entered) - err = client.Call(context.Background(), "busy", nil, nil) - wantUnpublished(t, err, true) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatalf("wrapping lost public refusal: %v", err) - } - close(finish) - if err := receive(t, done); err != nil { - t.Fatal(err) - } - - // The same public code received from the other side carries no proof. - err = client.Call(context.Background(), "busy", nil, nil) - wantUnpublished(t, err, false) - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatal(err) - } - // A marker from a nested, definitely-unsent call must not cross the wire - // as proof about the request whose implementation has already run. - err = client.Call(context.Background(), "nested", nil, nil) - wantUnpublished(t, err, false) - if !errors.As(err, &public) || public.Code != "cancelled" { - t.Fatal(err) - } -} - -type publicationWriteFailure struct { - duplex.Conn - wrote chan struct{} - err error -} - -func (c *publicationWriteFailure) Send(context.Context, duplex.Frame) error { - close(c.wrote) - return c.err -} - -func TestQueuedWriteFailureHasNoUnpublishedProof(t *testing.T) { - client, _ := newPair(t, ws.Options{}, ws.Options{}) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - proof := client.Call(ctx, "unsent", nil, nil) - wantUnpublished(t, proof, true) - for _, tc := range []struct { - name string - cause, identity error - }{ - {"plain", errors.New("transport failed after accepting a queued frame"), nil}, - {"nested", fmt.Errorf("adapter send: %w", proof), context.Canceled}, - } { - t.Run(tc.name, func(t *testing.T) { - near, far := duplex.Pipe(1 << 20) - defer far.Abort() - cause := tc.cause - connection := &publicationWriteFailure{Conn: near, wrote: make(chan struct{}), err: cause} - peer, err := ws.NewPeer(context.Background(), connection, ws.ClientRole, ws.Options{}) - if err != nil { - t.Fatal(err) - } - defer peer.Close() - err = peer.Call(context.Background(), "supply", nil, nil) - receive(t, connection.wrote) - wantUnpublished(t, err, false) - if !errors.Is(err, cause) { - t.Fatalf("transport cause lost: %v", err) - } - if tc.identity != nil && !errors.Is(err, tc.identity) { - t.Fatalf("nested cause lost: %v", err) - } - }) - } -} - -type publicationReadFailure struct { - duplex.Conn - wrote chan struct{} - err error -} - -func (c *publicationReadFailure) Send(context.Context, duplex.Frame) error { - close(c.wrote) - return nil -} -func (c *publicationReadFailure) Receive(context.Context) (duplex.Frame, error) { - <-c.wrote - return duplex.Frame{}, c.err -} - -func TestReadFailureHasNoNestedUnpublishedProof(t *testing.T) { - client, _ := newPair(t, ws.Options{}, ws.Options{}) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - proof := client.Call(ctx, "unsent", nil, nil) - near, far := duplex.Pipe(1 << 20) - defer far.Abort() - connection := &publicationReadFailure{Conn: near, wrote: make(chan struct{}), err: fmt.Errorf("adapter receive: %w", proof)} - peer, err := ws.NewPeer(context.Background(), connection, ws.ClientRole, ws.Options{}) - if err != nil { - t.Fatal(err) - } - defer peer.Close() - err = peer.Call(context.Background(), "supply", nil, nil) - wantUnpublished(t, err, false) - if !errors.Is(err, context.Canceled) { - t.Fatalf("nested cause lost: %v", err) - } -} - -func TestRefusedReverseReplyCannotProveDeliveredCallUnpublished(t *testing.T) { - var delivered atomic.Bool - client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "a": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - delivered.Store(true) - return nil, peer.Call(ctx, "b", nil, nil) - }, - }}, ws.Options{MaxFrameBytes: 180, Handlers: map[string]ws.Handler{ - "b": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return strings.Repeat("x", 2000), nil }, - }}) - err := client.Call(context.Background(), "a", nil, nil) - if !delivered.Load() { - t.Fatal("outer request was not delivered") - } - if client.Err() == nil { - t.Fatal("oversized reply fallback did not reach the failure broadcast") - } - wantUnpublished(t, err, false) -} diff --git a/core/go/trace.go b/core/go/trace.go index 776ed48..a6b2493 100644 --- a/core/go/trace.go +++ b/core/go/trace.go @@ -1,9 +1,11 @@ -package runtime +package core import ( "context" "crypto/rand" "encoding/hex" + + "github.com/Bitspark/bitruntime/internal/profile/go" ) // Trace is the W3C Trace Context a frame carries: the two members verbatim, @@ -22,7 +24,7 @@ type Propagator interface { Inject(ctx context.Context) Trace } -// DefaultPropagator is what a peer with no Options.Propagator propagates by: it +// DefaultPropagator is what a carrier with no Propagator option uses: it // keeps an incoming trace verbatim and mints an outgoing one's ids itself, so // that frames correlate across hops with no tracing library installed. var DefaultPropagator Propagator = w3cPropagator{} @@ -55,7 +57,7 @@ func (w3cPropagator) Extract(ctx context.Context, trace Trace) context.Context { // span id of this frame's own โ€” or a new trace where the context carries none. func (w3cPropagator) Inject(ctx context.Context) Trace { parent, ok := TraceOf(ctx) - if !ok || !validTraceparent(parent.Parent) { + if !ok || !profile.ValidTraceparent(parent.Parent) { return Trace{Parent: "00-" + randomID(16) + "-" + randomID(8) + "-01"} } return Trace{Parent: parent.Parent[:36] + randomID(8) + parent.Parent[52:], State: parent.State} diff --git a/core/go/trace_test.go b/core/go/trace_test.go deleted file mode 100644 index 464c302..0000000 --- a/core/go/trace_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "testing" - - ws "github.com/Bitspark/nightseam/runtime/go" - "github.com/coder/websocket" -) - -// The trace a test sends and expects to see continued: one traceparent of the -// W3C form and a tracestate the runtime never reads, only forwards. -var carried = ws.Trace{Parent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", State: "congo=t61rcWkgMzE"} - -const carriedMembers = `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"congo=t61rcWkgMzE"` - -func frameMember(t *testing.T, members map[string]json.RawMessage, name string) string { - t.Helper() - raw, present := members[name] - if !present { - return "" - } - var value string - if err := json.Unmarshal(raw, &value); err != nil { - t.Fatalf("member %s = %s: %v", name, raw, err) - } - return value -} - -// frameTrace is what one frame carries, read off the wire rather than from the -// peer that wrote it. -func frameTrace(t *testing.T, members map[string]json.RawMessage) ws.Trace { - t.Helper() - return ws.Trace{Parent: frameMember(t, members, "traceparent"), State: frameMember(t, members, "tracestate")} -} - -// childOf holds a trace to what a child of parent is: the same trace id and -// flags, a span id of its own, and the tracestate it inherited verbatim. -func childOf(t *testing.T, parent, child ws.Trace) { - t.Helper() - if len(child.Parent) != 55 { - t.Fatalf("child traceparent %q is not of the W3C form", child.Parent) - } - if child.Parent[:36] != parent.Parent[:36] || child.Parent[52:] != parent.Parent[52:] { - t.Fatalf("child %q is not of the trace %q", child.Parent, parent.Parent) - } - if child.Parent[36:52] == parent.Parent[36:52] { - t.Fatalf("child %q reuses its parent's span id", child.Parent) - } - for _, c := range child.Parent[36:52] { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') { - t.Fatalf("child span id in %q is not lower-case hexadecimal", child.Parent) - } - } - if child.State != parent.State { - t.Fatalf("child tracestate = %q, want %q verbatim", child.State, parent.State) - } -} - -// TestOutgoingFramesContinueTheContextTrace: a request sent from a context -// carrying a trace carries a child of it, and the cancellation that follows -// carries that request's members rather than a sibling span of them. -func TestOutgoingFramesContinueTheContextTrace(t *testing.T) { - peer, conn, ctx := rawPeer(t, ws.Options{}) - traced, cancel := context.WithCancel(ws.DefaultPropagator.Extract(context.Background(), carried)) - t.Cleanup(cancel) - returned := make(chan error, 1) - go func() { returned <- peer.Call(traced, "far", nil, nil) }() - - request := readFrame(ctx, t, conn) - sent := frameTrace(t, request) - childOf(t, carried, sent) - cancel() - cancellation := readFrame(ctx, t, conn) - if string(cancellation["kind"]) != `"cancel"` || string(cancellation["id"]) != string(request["id"]) { - t.Fatalf("frame after the cancelled call = %v", cancellation) - } - if got := frameTrace(t, cancellation); got != sent { - t.Fatalf("cancel carried %+v, not its request's %+v", got, sent) - } - receive(t, returned) -} - -// TestARequestFromABareContextCarriesANewTrace: nothing correlates two calls -// made from a context that carries no trace, and each is nonetheless traced โ€” -// a peer with no propagator configured still says where its frames came from. -func TestARequestFromABareContextCarriesANewTrace(t *testing.T) { - peer, conn, ctx := rawPeer(t, ws.Options{}) - traces := make([]ws.Trace, 0, 2) - for range 2 { - go func() { _ = peer.Call(context.Background(), "far", nil, nil) }() - trace := frameTrace(t, readFrame(ctx, t, conn)) - if len(trace.Parent) != 55 || trace.Parent[:3] != "00-" || trace.Parent[52:] != "-01" { - t.Fatalf("a new trace = %q, not a sampled traceparent of version 00", trace.Parent) - } - if trace.State != "" { - t.Fatalf("a new trace carries the tracestate %q of nothing", trace.State) - } - traces = append(traces, trace) - } - if traces[0].Parent[3:35] == traces[1].Parent[3:35] { - t.Fatalf("two calls from bare contexts share the trace id in %q", traces[0].Parent) - } -} - -// TestAHandlerRunsUnderItsRequestsTrace: the trace of an incoming request is in -// the context its handler runs under and readable there; what the handler sends -// is a child of it; the response carries the request's members byte for byte. -// An incoming event's trace reaches its handler the same way. -func TestAHandlerRunsUnderItsRequestsTrace(t *testing.T) { - handlerTraces := make(chan ws.Trace, 2) - peer, conn, ctx := rawPeer(t, ws.Options{ - Events: map[string]ws.EventHandler{ - "progress": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) { - trace, _ := ws.TraceOf(ctx) - handlerTraces <- trace - }, - }, - Handlers: map[string]ws.Handler{ - "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - trace, found := ws.TraceOf(ctx) - if !found { - return nil, ws.ErrClosed - } - handlerTraces <- trace - if err := peer.Emit(ctx, "progress", 1); err != nil { - return nil, err - } - var answer string - if err := peer.Call(ctx, "reverse", nil, &answer); err != nil { - return nil, err - } - return answer, nil - }, - }, - }) - write := func(data string) { - t.Helper() - if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { - t.Fatal(err) - } - } - write(`{"version":1,"kind":"request","id":"c:1","method":"outer","params":{},` + carriedMembers + `}`) - if got := receive(t, handlerTraces); got != carried { - t.Fatalf("the handler's context carried %+v, not the request's %+v", got, carried) - } - event := readFrame(ctx, t, conn) - if string(event["event"]) != `"progress"` { - t.Fatalf("frame after the traced request = %v", event) - } - emitted := frameTrace(t, event) - childOf(t, carried, emitted) - reverse := readFrame(ctx, t, conn) - if string(reverse["method"]) != `"reverse"` { - t.Fatalf("frame after the emitted event = %v", reverse) - } - called := frameTrace(t, reverse) - childOf(t, carried, called) - if called.Parent == emitted.Parent { - t.Fatalf("two frames of one handler share the span id in %q", called.Parent) - } - write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back"}`) - response := readFrame(ctx, t, conn) - if string(response["id"]) != `"c:1"` || string(response["result"]) != `"back"` { - t.Fatalf("response to the traced request = %v", response) - } - if got := frameTrace(t, response); got != carried { - t.Fatalf("the response carried %+v, not its request's %+v", got, carried) - } - write(`{"version":1,"kind":"event","event":"progress","data":1,` + carriedMembers + `}`) - if got := receive(t, handlerTraces); got != carried { - t.Fatalf("the event handler's context carried %+v, not the event's %+v", got, carried) - } - if peer.Err() != nil { - t.Fatalf("a traced exchange closed the connection: %v", peer.Err()) - } -} - -// recordingPropagator is a propagator of another making: it records what it is -// asked to extract and dictates what every outgoing frame carries. -type recordingPropagator struct { - extracted chan ws.Trace - injects ws.Trace -} - -func (p *recordingPropagator) Extract(ctx context.Context, trace ws.Trace) context.Context { - p.extracted <- trace - return ctx -} - -func (p *recordingPropagator) Inject(context.Context) ws.Trace { return p.injects } - -// TestACustomPropagatorSeesTheMembersVerbatim: the peer neither reads nor -// rewrites what a configured propagator is given or returns โ€” the incoming -// members reach Extract as they arrived, and what Inject returns is what the -// outgoing frame carries. Only a response keeps its request's own members. -func TestACustomPropagatorSeesTheMembersVerbatim(t *testing.T) { - propagator := &recordingPropagator{ - extracted: make(chan ws.Trace, 2), - injects: ws.Trace{Parent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00", State: "rojo=00f067aa0ba902b7"}, - } - peer, conn, ctx := rawPeer(t, ws.Options{ - Propagator: propagator, - Handlers: map[string]ws.Handler{ - "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - var answer string - return answer, peer.Call(ctx, "reverse", nil, &answer) - }, - }, - }) - write := func(data string) { - t.Helper() - if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { - t.Fatal(err) - } - } - write(`{"version":1,"kind":"request","id":"c:1","method":"outer","params":{},` + carriedMembers + `}`) - if got := receive(t, propagator.extracted); got != carried { - t.Fatalf("Extract saw %+v, not the members %+v the frame carried", got, carried) - } - reverse := readFrame(ctx, t, conn) - if got := frameTrace(t, reverse); got != propagator.injects { - t.Fatalf("the outgoing request carried %+v, not the %+v Inject returned", got, propagator.injects) - } - write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back"}`) - if got := frameTrace(t, readFrame(ctx, t, conn)); got != carried { - t.Fatalf("the response carried %+v, not its request's %+v", got, carried) - } - if peer.Err() != nil { - t.Fatalf("a custom propagator closed the connection: %v", peer.Err()) - } -} diff --git a/dispatch/go/bitwire_test.go b/dispatch/go/bitwire_test.go deleted file mode 100644 index 66de068..0000000 --- a/dispatch/go/bitwire_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "slices" - "testing" - "time" - - bitwire "github.com/Bitspark/bitwire/wire/go" - "github.com/Bitspark/nightseam/duplex/go" - runtime "github.com/Bitspark/nightseam/runtime/go" - "github.com/Bitspark/nightseam/tunnel/go" -) - -// These assignments cross the actual public package boundary. Independently -// named, structurally similar Message/Receiver/Code types do not satisfy it. -var _ bitwire.Wire = (duplex.Wire)(nil) -var _ duplex.Wire = (bitwire.Wire)(nil) -var _ bitwire.Endpoint = (duplex.Endpoint)(nil) -var _ duplex.Endpoint = (bitwire.Endpoint)(nil) -var _ bitwire.Endpoint = (*tunnel.Channel)(nil) - -func TestPublishedBitwireTypesCarryNightseamCalls(t *testing.T) { - left, right, err := runtime.NewWirePair(runtime.Options{}) - if err != nil { - t.Fatal(err) - } - var client, server bitwire.Endpoint = left, right - defer client.Close(duplex.CodeNormal, "done") - detach, err := server.Receive(bitwire.Receiver{ - Message: func(path []string, request bitwire.Message) { - if !slices.Equal(path, []string{"model", "read"}) { - t.Errorf("shared endpoint path = %v", path) - } - if request.Frame.Kind != bitwire.ProfileRequest || request.Return == nil { - t.Error("the shared receiver did not receive a request and return capability") - return - } - err := request.Return.Wire.Send(nil, bitwire.Message{Frame: bitwire.ProfileFrame{ - Version: 1, Kind: bitwire.ProfileResponse, ID: request.Frame.ID, Result: request.Frame.Params, - }}) - if err != nil { - t.Error(err) - } - }, - }) - if err != nil { - t.Fatal(err) - } - defer detach() - selected := duplex.At(duplex.Mount(map[string]bitwire.Endpoint{"service": client}), []string{"service", "model"}) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - var result json.RawMessage - if err := runtime.CallWire(ctx, selected, []string{"read"}, json.RawMessage(`{"shared":true}`), &result); err != nil { - t.Fatal(err) - } - if string(result) != `{"shared":true}` { - t.Fatalf("shared contract response: %s", result) - } -} diff --git a/dispatch/go/dispatch.go b/dispatch/go/dispatch.go new file mode 100644 index 0000000..58a8b5e --- /dev/null +++ b/dispatch/go/dispatch.go @@ -0,0 +1,235 @@ +// Package dispatch routes addressed deliveries to handlers and provides the +// request, response and event helpers adapters use. A Dispatcher owns one +// endpoint attachment and an explicit exact/longest-prefix routing policy; Call +// and Emit send through any addressed access; Handle and Register run a body +// per request or event with the context its carrier established. +package dispatch + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + "github.com/Bitspark/bitruntime/internal/request/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// CallOptions configures one call, independently of its carrier. A zero +// Timeout waits 30 seconds; a nil Propagator uses core.DefaultPropagator. +type CallOptions struct { + Propagator core.Propagator + Timeout time.Duration +} + +// Call sends one request at a relative path and waits for its response, +// decoding a success into result when it is not nil. Its return capability is +// fresh for this call and carries the invocation lifecycle, so it is +// independent of every other call's identifier. A caller that withdraws โ€” +// ctx ends first โ€” sends a best-effort cancellation. It never retries. +// +// A refusal before anything was sent is an *core.UnpublishedError; a response +// error is a *core.PublicError. +func Call(ctx context.Context, access wire.AddressedWire, path []string, params, result any, options ...CallOptions) error { + var o request.Options + if len(options) > 0 { + o = request.Options{Propagator: options[0].Propagator, Timeout: options[0].Timeout} + } + return request.Call(ctx, access, path, params, result, nil, o) +} + +// EmitOptions configures one event emission; a nil Propagator uses +// core.DefaultPropagator. +type EmitOptions struct { + Propagator core.Propagator +} + +// Emit admits one event at a relative path. Success says only that the +// destination accepted it; processing and transport remain asynchronous. +func Emit(ctx context.Context, access wire.AddressedWire, path []string, data any, options ...EmitOptions) error { + if ctx == nil || access == nil { + return core.Unpublished(errors.New("bitruntime: an event requires a context and access")) + } + if err := ctx.Err(); err != nil { + return core.Unpublished(err) + } + if !profile.ValidPath(path) { + return core.Unpublished(errors.New("bitruntime: an event requires a valid operation path")) + } + encoded, err := profile.MarshalJSON(data) + if err != nil { + return core.Unpublished(err) + } + propagator := core.DefaultPropagator + if len(options) > 0 && options[0].Propagator != nil { + propagator = options[0].Propagator + } + trace := propagator.Inject(ctx) + return core.Unpublished(access.Send(path, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: encoded, Traceparent: trace.Parent, Tracestate: trace.State, Meta: delivery.OutgoingMeta(ctx)}})) +} + +// Handler is a request body, independent of the carrier. It takes the params +// as they arrived and returns the result, or an error: a *core.PublicError +// crosses with its code, a cancellation as cancelled, and any other error as +// internal. Its context carries the trace and meta the request brought, and +// ends when the caller withdraws or the deadline passes. +type Handler func(context.Context, json.RawMessage) (any, error) + +// EventHandler receives an event body beside the context it was carried with. +// An error ends the registry's endpoint as a protocol error. +type EventHandler func(context.Context, json.RawMessage) error + +// Handlers groups a request body and an event body that share one path. +type Handlers struct { + Request Handler + Event EventHandler +} + +// Registry is the registration capability Handle and Register need: send +// access plus explicit route registration. Closing it releases its +// registrations, not a borrowed carrier. +type Registry interface { + wire.AddressedWire + Register([]string, wire.Receiver) (func(), error) + Close(wire.Code, string) error +} + +// Handle registers one request body at a relative path. The receiver returns +// before running application code; the body runs asynchronously, and the +// request's cancellation reaches it through its return capability. +func Handle(registry Registry, path []string, handler Handler) (func(), error) { + if registry == nil || handler == nil { + return nil, errors.New("bitruntime: a handler requires a registry and body") + } + return Register(registry, path, Handlers{Request: handler}) +} + +// Register installs one receiver for a request body, an event body, or both, +// at a relative path. The one detach removes the group; an event-only path +// refuses requests with method_not_found. +func Register(registry Registry, path []string, handlers Handlers) (func(), error) { + if registry == nil || (handlers.Request == nil && handlers.Event == nil) { + return nil, errors.New("bitruntime: registration requires a registry and at least one handler") + } + if !profile.ValidPath(path) { + return nil, core.ErrInvalidPath + } + type returnKey struct { + address *wire.ReturnAddress + id string + } + var mu sync.Mutex + incoming := map[returnKey]context.CancelFunc{} + return registry.Register(path, wire.Receiver{ + Closed: func(wire.Code, string) { + mu.Lock() + defer mu.Unlock() + for _, cancel := range incoming { + cancel() + } + }, + Message: func(_ []string, message wire.Message) { + if message.Frame.Kind == wire.ProfileEvent { + if handlers.Event != nil { + ctx, associated := delivery.EventContext(message) + if !associated { + ctx = core.DefaultPropagator.Extract(context.Background(), core.Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) + } + if err := invokeEvent(delivery.WithIncomingMeta(ctx, message.Frame.Meta), handlers.Event, message.Frame.Data); err != nil { + _ = registry.Close(transports.CodeProtocolError, "wire event rejected") + } + } + return + } + key := returnKey{message.Return, message.Frame.ID} + if message.Frame.Kind == wire.ProfileCancel { + mu.Lock() + cancel := incoming[key] + mu.Unlock() + if cancel != nil { + cancel() + } + return + } + if message.Frame.Kind != wire.ProfileRequest { + return + } + if handlers.Request == nil { + core.Respond(message, nil, &core.PublicError{Code: "method_not_found", Message: "Unknown method"}) + return + } + dispatch := delivery.Of(message) + base := context.Background() + if dispatch != nil { + base = dispatch.Ctx + } + ctx := core.DefaultPropagator.Extract(base, core.Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) + ctx, cancel := context.WithCancel(delivery.WithIncomingMeta(ctx, message.Frame.Meta)) + mu.Lock() + if incoming[key] != nil { + mu.Unlock() + cancel() + core.Respond(message, nil, &core.PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"}) + return + } + incoming[key] = cancel + mu.Unlock() + // The body runs after this receiver returns, so returning is not + // completion. The lease says so to whoever admitted the request: an + // early answer to the caller cannot retire an invocation whose body + // is still running. A bound reached is a refusal; any other refusal + // means this return capability carries no lifecycle, and ordinary + // addressed delivery goes on without one. + body, leaseErr := core.BeginInvocationBody(message) + if errors.Is(leaseErr, core.ErrInvocationLimit) { + mu.Lock() + delete(incoming, key) + mu.Unlock() + cancel() + core.Respond(message, nil, &core.PublicError{Code: "busy", Message: "Invocation participation limit reached"}) + return + } + go func() { + defer func() { body.Done(); cancel(); mu.Lock(); delete(incoming, key); mu.Unlock() }() + result, err := invokeHandler(ctx, handlers.Request, message.Frame.Params, dispatch) + if err == nil { + err = ctx.Err() + } + data, marshalErr := profile.MarshalJSON(result) + if err == nil { + err = marshalErr + } + if dispatch != nil && dispatch.Completion != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { + dispatch.Completion.Set(err) + } + core.Respond(message, data, core.WithoutUnpublishedProof(err)) + }() + }, + }) +} + +func invokeEvent(ctx context.Context, handler EventHandler, data json.RawMessage) (err error) { + defer func() { + if recover() != nil { + err = errors.New("bitruntime: event handler panic") + } + }() + return handler(ctx, data) +} + +func invokeHandler(ctx context.Context, handler Handler, params json.RawMessage, dispatch *delivery.Context) (result any, err error) { + defer func() { + if value := recover(); value != nil { + if dispatch != nil && dispatch.Panic != nil { + dispatch.Panic(value) + } + err = errors.New("bitruntime: handler panic") + } + }() + return handler(ctx, params) +} diff --git a/dispatch/go/dispatcher.go b/dispatch/go/dispatcher.go index 213fcc4..202a6e2 100644 --- a/dispatch/go/dispatcher.go +++ b/dispatch/go/dispatcher.go @@ -1,24 +1,19 @@ -package runtime +package dispatch import ( "errors" "slices" "sync" - "github.com/Bitspark/nightseam/duplex/go" + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" ) -// HandlerRegistry is the explicit registration capability used by generated -// bindings. Closing it releases its registrations, not its borrowed carrier. -type HandlerRegistry interface { - duplex.Wire - Register([]string, duplex.Receiver) (func(), error) - Close(duplex.Code, string) error -} - type dispatchRegistration struct { path []string - receiver duplex.Receiver + receiver wire.Receiver } type dispatchRoute struct { name string @@ -31,9 +26,10 @@ type dispatchRoute struct { // request whose return capability carries none rather than routing it with // weaker detach and cancellation guarantees. An opaque wrapper is therefore as // good as a native endpoint: the lifecycle travels with the unchanged return -// capability, and nothing here recognizes a concrete type. +// capability, and nothing here recognizes a concrete type. A cancellation goes +// to the traversal that captured its request, never to the route now in force. type Dispatcher struct { - root duplex.Endpoint + root wire.Endpoint ownEndpoint bool mu sync.Mutex closed bool @@ -45,15 +41,17 @@ type Dispatcher struct { // caller owns. Borrowed endpoints remain the default. type DispatcherOptions struct{ OwnEndpoint bool } -func NewDispatcher(root duplex.Endpoint, options ...DispatcherOptions) (*Dispatcher, error) { +// NewDispatcher attaches to root and routes what it delivers. The endpoint is +// borrowed unless options transfer its closure. +func NewDispatcher(root wire.Endpoint, options ...DispatcherOptions) (*Dispatcher, error) { if root == nil { - return nil, errors.New("dispatcher requires an endpoint") + return nil, errors.New("bitruntime: a dispatcher requires an endpoint") } d := &Dispatcher{root: root, routes: map[dispatchRoute]*dispatchRegistration{}} if len(options) > 0 { d.ownEndpoint = options[0].OwnEndpoint } - detach, err := root.Receive(duplex.Receiver{Message: d.deliver, Closed: func(code duplex.Code, reason string) { _ = d.Close(code, reason) }}) + detach, err := root.Receive(wire.Receiver{Message: d.deliver, Closed: func(code wire.Code, reason string) { _ = d.Close(code, reason) }}) if err != nil { return nil, err } @@ -65,40 +63,47 @@ func NewDispatcher(root duplex.Endpoint, options ...DispatcherOptions) (*Dispatc d.mu.Unlock() if closed { detach() - return nil, ErrClosed + return nil, transports.ErrClosed } return d, nil } -func (d *Dispatcher) Send(path []string, message duplex.Message) error { +// Send sends through the borrowed root. +func (d *Dispatcher) Send(path []string, message wire.Message) error { d.mu.Lock() closed := d.closed d.mu.Unlock() if closed { - return ErrClosed + return transports.ErrClosed } return d.root.Send(path, message) } -func (d *Dispatcher) Register(path []string, receiver duplex.Receiver) (func(), error) { + +// Register routes exactly path to receiver. A path has one registration. +func (d *Dispatcher) Register(path []string, receiver wire.Receiver) (func(), error) { return d.register(path, receiver, false) } -func (d *Dispatcher) RegisterPrefix(path []string, receiver duplex.Receiver) (func(), error) { + +// RegisterPrefix routes path and every path beneath it, the longest registered +// prefix winning, unless an exact registration matches. +func (d *Dispatcher) RegisterPrefix(path []string, receiver wire.Receiver) (func(), error) { return d.register(path, receiver, true) } -func (d *Dispatcher) register(path []string, receiver duplex.Receiver, prefix bool) (func(), error) { - name, err := duplex.EncodePath(path) + +func (d *Dispatcher) register(path []string, receiver wire.Receiver, prefix bool) (func(), error) { + name, err := profile.EncodePath(path) if err != nil { - return nil, err + return nil, core.ErrInvalidPath } key := dispatchRoute{name, prefix} registration := &dispatchRegistration{path: slices.Clone(path), receiver: receiver} d.mu.Lock() defer d.mu.Unlock() if d.closed { - return nil, ErrClosed + return nil, transports.ErrClosed } if d.routes[key] != nil { - return nil, duplex.ErrReceiverExists + return nil, core.ErrReceiverExists } d.routes[key] = registration return func() { @@ -109,6 +114,7 @@ func (d *Dispatcher) register(path []string, receiver duplex.Receiver, prefix bo d.mu.Unlock() }, nil } + func (d *Dispatcher) match(path []string, name string) *dispatchRegistration { if exact := d.routes[dispatchRoute{name, false}]; exact != nil { return exact @@ -121,16 +127,17 @@ func (d *Dispatcher) match(path []string, name string) *dispatchRegistration { } return selected } -func (d *Dispatcher) deliver(path []string, message duplex.Message) { - name, err := duplex.EncodePath(path) + +func (d *Dispatcher) deliver(path []string, message wire.Message) { + name, err := profile.EncodePath(path) if err != nil { return } // A control belongs to the traversal that captured it, never to the // registration in force now. Handing it to the invocation is what keeps a // detach or a rebind from retargeting an admitted request. - if message.Frame.Kind == duplex.ProfileCancel { - _ = RelayInvocationControl(message) + if message.Frame.Kind == wire.ProfileCancel { + _ = core.RelayInvocationControl(message) return } d.mu.Lock() @@ -140,35 +147,37 @@ func (d *Dispatcher) deliver(path []string, message duplex.Message) { } d.mu.Unlock() if registration == nil || registration.receiver.Message == nil { - if message.Frame.Kind == duplex.ProfileRequest { - _ = sendWireResponse(message, nil, &PublicError{Code: "method_not_found", Message: "Unknown method"}) + if message.Frame.Kind == wire.ProfileRequest { + _ = core.Respond(message, nil, &core.PublicError{Code: "method_not_found", Message: "Unknown method"}) } return } delivered := slices.Clone(path) - if message.Frame.Kind != duplex.ProfileRequest { + if message.Frame.Kind != wire.ProfileRequest { registration.receiver.Message(delivered, message) return } - capture, err := CaptureInvocation(message, func(control duplex.Message) { + capture, err := core.CaptureInvocation(message, func(control wire.Message) { registration.receiver.Message(slices.Clone(delivered), control) }) if err != nil { // A bound reached is a refusal to try again at; a capability that // carries no lifecycle is a request this dispatcher cannot route with // the guarantees it advertises. - refusal := &PublicError{Code: "invalid_message", Message: "Invocation requires the lifecycle its return capability carries"} - if errors.Is(err, ErrInvocationLimit) { - refusal = &PublicError{Code: "busy", Message: "Invocation participation limit reached"} + refusal := &core.PublicError{Code: "invalid_message", Message: "Invocation requires the lifecycle its return capability carries"} + if errors.Is(err, core.ErrInvocationLimit) { + refusal = &core.PublicError{Code: "busy", Message: "Invocation participation limit reached"} } - _ = sendWireResponse(message, nil, refusal) + _ = core.Respond(message, nil, refusal) return } defer capture.Ready() registration.receiver.Message(delivered, message) } -func (d *Dispatcher) Close(code duplex.Code, reason string) error { +// Close releases the routes and the root attachment, and closes the root only +// when this dispatcher owns it. +func (d *Dispatcher) Close(code wire.Code, reason string) error { d.mu.Lock() if d.closed { d.mu.Unlock() @@ -198,6 +207,7 @@ func (d *Dispatcher) Select(path []string) *SelectedEndpoint { return &SelectedEndpoint{owner: d, prefix: slices.Clone(path)} } +// SelectedEndpoint is a receiving view of a shared dispatcher at a prefix. type SelectedEndpoint struct { owner *Dispatcher prefix []string @@ -206,44 +216,50 @@ type SelectedEndpoint struct { attachment *selectedAttachment } type selectedAttachment struct { - receiver duplex.Receiver + receiver wire.Receiver detach func() } +// Select narrows the view by a further prefix. func (s *SelectedEndpoint) Select(path []string) *SelectedEndpoint { return s.owner.Select(append(slices.Clone(s.prefix), path...)) } -func (s *SelectedEndpoint) Send(path []string, message duplex.Message) error { + +// Send sends beneath the view's prefix through the shared root. +func (s *SelectedEndpoint) Send(path []string, message wire.Message) error { s.mu.Lock() closed := s.closed s.mu.Unlock() if closed { - return ErrClosed + return transports.ErrClosed } return s.owner.Send(append(slices.Clone(s.prefix), path...), message) } -func (s *SelectedEndpoint) Receive(receiver duplex.Receiver) (func(), error) { + +// Receive attaches the view's one receiver, which sees paths relative to the +// view's prefix. +func (s *SelectedEndpoint) Receive(receiver wire.Receiver) (func(), error) { attachment := &selectedAttachment{receiver: receiver} s.mu.Lock() if s.closed { s.mu.Unlock() - return nil, ErrClosed + return nil, transports.ErrClosed } if s.attachment != nil { s.mu.Unlock() - return nil, duplex.ErrReceiverExists + return nil, core.ErrReceiverExists } s.attachment = attachment s.mu.Unlock() - detach, err := s.owner.RegisterPrefix(s.prefix, duplex.Receiver{ - Message: func(path []string, message duplex.Message) { + detach, err := s.owner.RegisterPrefix(s.prefix, wire.Receiver{ + Message: func(path []string, message wire.Message) { if receiver.Message != nil { receiver.Message(slices.Clone(path[len(s.prefix):]), message) - } else if message.Frame.Kind == duplex.ProfileRequest { - _ = sendWireResponse(message, nil, &PublicError{Code: "method_not_found", Message: "Unknown method"}) + } else if message.Frame.Kind == wire.ProfileRequest { + _ = core.Respond(message, nil, &core.PublicError{Code: "method_not_found", Message: "Unknown method"}) } }, - Closed: func(code duplex.Code, reason string) { s.remove(attachment, true, code, reason) }, + Closed: func(code wire.Code, reason string) { s.remove(attachment, true, code, reason) }, }) s.mu.Lock() active := s.attachment == attachment @@ -259,11 +275,12 @@ func (s *SelectedEndpoint) Receive(receiver duplex.Receiver) (func(), error) { } if !active { detach() - return nil, ErrClosed + return nil, transports.ErrClosed } return func() { s.remove(attachment, false, 0, "") }, nil } -func (s *SelectedEndpoint) remove(attachment *selectedAttachment, tell bool, code duplex.Code, reason string) { + +func (s *SelectedEndpoint) remove(attachment *selectedAttachment, tell bool, code wire.Code, reason string) { s.mu.Lock() if s.attachment != attachment { s.mu.Unlock() @@ -279,7 +296,9 @@ func (s *SelectedEndpoint) remove(attachment *selectedAttachment, tell bool, cod attachment.receiver.Closed(code, reason) } } -func (s *SelectedEndpoint) Close(code duplex.Code, reason string) error { + +// Close ends the view's route; it never closes the shared root. +func (s *SelectedEndpoint) Close(code wire.Code, reason string) error { s.mu.Lock() s.closed = true attachment := s.attachment diff --git a/dispatch/go/dispatcher_ownership_test.go b/dispatch/go/dispatcher_ownership_test.go deleted file mode 100644 index 3823339..0000000 --- a/dispatch/go/dispatcher_ownership_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package runtime_test - -import ( - "errors" - "slices" - "testing" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -// An endpoint has one owning attachment: a second is refused without replacing -// the first, detach is idempotent, and a later attachment is permitted. -func TestAnEndpointHasOneOwningAttachment(t *testing.T) { - left, right, err := ws.NewWirePair(ws.Options{}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) - first := make(chan []string, 4) - detach, err := right.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { first <- path }}) - if err != nil { - t.Fatal(err) - } - if _, err := right.Receive(duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatalf("a second attachment was accepted: %v", err) - } - if err := ws.EmitWire(t.Context(), left, []string{"one"}, nil); err != nil { - t.Fatal(err) - } - if got := <-first; !slices.Equal(got, []string{"one"}) { - t.Fatalf("first attachment saw %v", got) - } - detach() - detach() - second := make(chan []string, 4) - if _, err := right.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { second <- path }}); err != nil { - t.Fatalf("a later attachment was refused: %v", err) - } - if err := ws.EmitWire(t.Context(), left, []string{"two"}, nil); err != nil { - t.Fatal(err) - } - if got := <-second; !slices.Equal(got, []string{"two"}) { - t.Fatalf("second attachment saw %v", got) - } - select { - case got := <-first: - t.Fatalf("the detached attachment still received %v", got) - default: - } -} - -// A dispatcher refuses a duplicate path and frees it when its registration -// detaches. Exact and prefix are separate spaces: one of each may hold a path. -func TestADispatcherRefusesADuplicatePath(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - detach, err := dispatch.Register([]string{"a", "b"}, duplex.Receiver{Message: func([]string, duplex.Message) {}}) - if err != nil { - t.Fatal(err) - } - if _, err := dispatch.Register([]string{"a", "b"}, duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatalf("a duplicate exact path was accepted: %v", err) - } - if _, err := dispatch.RegisterPrefix([]string{"a", "b"}, duplex.Receiver{}); err != nil { - t.Fatalf("a prefix at an exact path was refused: %v", err) - } - if _, err := dispatch.RegisterPrefix([]string{"a", "b"}, duplex.Receiver{}); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatalf("a duplicate prefix path was accepted: %v", err) - } - detach() - if _, err := dispatch.Register([]string{"a", "b"}, duplex.Receiver{}); err != nil { - t.Fatalf("a detached path was not freed: %v", err) - } -} - -// Overlapping prefixes: the longest match wins, and an exact registration wins -// over every prefix that would also have matched. -func TestOverlappingRoutesSelectTheLongestThenTheExact(t *testing.T) { - endpoint := newInvocationEndpoint(ws.DefaultInvocationLimits()) - dispatch, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - reached := make(chan string, 8) - name := func(label string) duplex.Receiver { - return duplex.Receiver{Message: func([]string, duplex.Message) { reached <- label }} - } - for _, route := range []struct { - path []string - label string - }{{nil, "root"}, {[]string{"a"}, "a"}, {[]string{"a", "b"}, "a/b"}} { - if _, err := dispatch.RegisterPrefix(route.path, name(route.label)); err != nil { - t.Fatal(err) - } - } - if _, err := dispatch.Register([]string{"a", "b", "c"}, name("exact a/b/c")); err != nil { - t.Fatal(err) - } - for _, want := range []struct { - path []string - label string - }{ - {[]string{"z"}, "root"}, - {[]string{"a", "z"}, "a"}, - {[]string{"a", "b", "z"}, "a/b"}, - {[]string{"a", "b", "c"}, "exact a/b/c"}, - } { - endpoint.deliver(want.path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")}}) - if got := <-reached; got != want.label { - t.Fatalf("%v reached %q, want %q", want.path, got, want.label) - } - } -} - -// A nested selection prepends its prefixes outgoing and strips them incoming, -// and every view is a view of the one root attachment. -func TestNestedSelectionPrependsAndStrips(t *testing.T) { - left, right, err := ws.NewWirePair(ws.Options{}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) - dispatch, err := ws.NewDispatcher(right) - if err != nil { - t.Fatal(err) - } - inner := dispatch.Select([]string{"a"}).Select([]string{"b"}) - delivered := make(chan []string, 4) - if _, err := inner.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { delivered <- path }}); err != nil { - t.Fatal(err) - } - if err := ws.EmitWire(t.Context(), left, []string{"a", "b", "read"}, nil); err != nil { - t.Fatal(err) - } - if got := <-delivered; !slices.Equal(got, []string{"read"}) { - t.Fatalf("the nested view was delivered %v", got) - } - // And outgoing: what the view sends arrives at the root's full path. - back := make(chan []string, 4) - if _, err := left.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { back <- path }}); err != nil { - t.Fatal(err) - } - if err := ws.EmitWire(t.Context(), inner, []string{"reply"}, nil); err != nil { - t.Fatal(err) - } - if got := <-back; !slices.Equal(got, []string{"a", "b", "reply"}) { - t.Fatalf("the nested view sent to %v", got) - } -} - -// Mounting chooses a child by one segment and restores it on delivery; -// closing the mount detaches its own attachments and leaves children usable. -func TestMountRoutesByOneSegmentAndBorrowsItsChildren(t *testing.T) { - left, right, err := ws.NewWirePair(ws.Options{}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) - mount := duplex.Mount(map[string]duplex.Endpoint{"child": right}) - delivered := make(chan []string, 4) - if _, err := mount.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { delivered <- path }}); err != nil { - t.Fatal(err) - } - if err := ws.EmitWire(t.Context(), left, []string{"read"}, nil); err != nil { - t.Fatal(err) - } - if got := <-delivered; !slices.Equal(got, []string{"child", "read"}) { - t.Fatalf("the mount delivered %v", got) - } - // A mount has no destination at the empty path, and an unknown child has - // no route at all. - if err := mount.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")}}); !errors.Is(err, duplex.ErrNoRoute) { - t.Fatalf("the mount had a destination at []: %v", err) - } - if err := mount.Send([]string{"absent"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: []byte("null")}}); !errors.Is(err, duplex.ErrNoRoute) { - t.Fatalf("an unknown child had a route: %v", err) - } - if err := mount.Close(duplex.CodeNormal, ""); err != nil { - t.Fatal(err) - } - after := make(chan []string, 4) - if _, err := right.Receive(duplex.Receiver{Message: func(path []string, _ duplex.Message) { after <- path }}); err != nil { - t.Fatalf("closing the mount closed its borrowed child: %v", err) - } - if err := ws.EmitWire(t.Context(), left, []string{"again"}, nil); err != nil { - t.Fatal(err) - } - if got := <-after; !slices.Equal(got, []string{"again"}) { - t.Fatalf("the borrowed child delivered %v", got) - } -} diff --git a/dispatch/go/dispatcher_test.go b/dispatch/go/dispatcher_test.go deleted file mode 100644 index 8f8a6a5..0000000 --- a/dispatch/go/dispatcher_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -func TestDispatcherSharesOneAttachmentAndPreservesBorrowedEndpoint(t *testing.T) { - left, right, err := ws.NewWirePair(ws.Options{}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) - dispatch, err := ws.NewDispatcher(right) - if err != nil { - t.Fatal(err) - } - if _, err := right.Receive(duplex.Receiver{}); err == nil { - t.Fatal("second owning attachment accepted") - } - for _, name := range []string{"a", "b"} { - view := dispatch.Select([]string{name}) - binding, err := ws.NewDispatcher(view) - if err != nil { - t.Fatal(err) - } - if _, err := ws.HandleWire(binding, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return name, nil }); err != nil { - t.Fatal(err) - } - } - for _, name := range []string{"a", "b"} { - var got string - if err := ws.CallWire(context.Background(), left, []string{name, "read"}, nil, &got); err != nil || got != name { - t.Fatalf("%s: %q, %v", name, got, err) - } - } - if err := dispatch.Close(duplex.CodeNormal, ""); err != nil { - t.Fatal(err) - } - rebound, err := ws.NewDispatcher(right) - if err != nil { - t.Fatalf("borrowed endpoint was closed: %v", err) - } - if _, err := ws.HandleWire(rebound, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return "rebound", nil }); err != nil { - t.Fatal(err) - } - var got string - if err := ws.CallWire(context.Background(), left, []string{"read"}, nil, &got); err != nil || got != "rebound" { - t.Fatalf("rebound: %q, %v", got, err) - } -} - -func TestDispatcherClosesAnExplicitlyOwnedEndpoint(t *testing.T) { - left, right, err := ws.NewWirePair(ws.Options{}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) - dispatch, err := ws.NewDispatcher(right, ws.DispatcherOptions{OwnEndpoint: true}) - if err != nil { - t.Fatal(err) - } - if err := dispatch.Close(duplex.CodeProtocolError, "wire event rejected"); err != nil { - t.Fatal(err) - } - if _, err := right.Receive(duplex.Receiver{}); err == nil { - t.Fatal("owned endpoint stayed open") - } - if err := dispatch.Close(duplex.CodeNormal, "again"); err != nil { - t.Fatal(err) - } -} - -type unmanagedDispatchEndpoint struct{ receiver duplex.Receiver } - -func (*unmanagedDispatchEndpoint) Send([]string, duplex.Message) error { return nil } -func (w *unmanagedDispatchEndpoint) Receive(receiver duplex.Receiver) (func(), error) { - w.receiver = receiver - return func() {}, nil -} -func (*unmanagedDispatchEndpoint) Close(duplex.Code, string) error { return nil } diff --git a/dispatch/go/wire_cancel_reservation_test.go b/dispatch/go/wire_cancel_reservation_test.go deleted file mode 100644 index 2fcbe64..0000000 --- a/dispatch/go/wire_cancel_reservation_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "sync" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" -) - -type wireDrainGate struct { - started chan struct{} - release chan struct{} - once sync.Once -} - -func (g *wireDrainGate) open() { g.once.Do(func() { close(g.release) }) } - -type wireReservationPropagator struct { - Propagator - gates chan *wireDrainGate -} - -func (p *wireReservationPropagator) Extract(ctx context.Context, trace Trace) context.Context { - select { - case gate := <-p.gates: - close(gate.started) - select { - case <-gate.release: - case <-ctx.Done(): - } - default: - } - return p.Propagator.Extract(ctx, trace) -} -func (p *wireReservationPropagator) pause(t *testing.T) *wireDrainGate { - gate := &wireDrainGate{started: make(chan struct{}), release: make(chan struct{})} - p.gates <- gate - t.Cleanup(gate.open) - return gate -} - -type wireReservationSink struct { - replies chan duplex.ProfileFrame - onReply func(duplex.ProfileFrame) -} - -func (s *wireReservationSink) Send(_ []string, message duplex.Message) error { - if s.onReply != nil { - s.onReply(message.Frame) - } - s.replies <- message.Frame - return nil -} - -func wireReservationAwait[T any](t *testing.T, values <-chan T) T { - t.Helper() - select { - case value := <-values: - return value - case <-time.After(3 * time.Second): - t.Fatal("wire reservation barrier did not arrive") - var zero T - return zero - } -} - -// Only the root dispatcher runs. Its actual peer admission writes into an -// independently drained carrier queue, so a full root queue is tested without -// a second, unrelated transport saturation masking the root's result. -func wireReservationPeer(t *testing.T) (*Peer, duplex.Wire, *wireReservationPropagator) { - t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - near, far := duplex.Pipe(1 << 20) - propagator := &wireReservationPropagator{Propagator: DefaultPropagator, gates: make(chan *wireDrainGate, 1)} - options, err := (Options{QueueCapacity: 1, MaxPendingRequests: 1, Propagator: propagator}).normalized() - if err != nil { - t.Fatal(err) - } - peer := &Peer{ctx: ctx, cancel: cancel, conn: near, options: options, prefix: "c:", done: make(chan struct{}), pending: map[string]chan pendingResult{}, incoming: map[string]context.CancelFunc{}, handlers: map[string]Handler{}, eventHandlers: map[string]EventHandler{}, outputs: make(chan queuedFrame, 16)} - t.Cleanup(func() { _ = peer.Close(); _ = far.Abort() }) - return peer, peer.Wire(), propagator -} - -func wireReservationMessage(kind duplex.ProfileKind, id string, address *duplex.ReturnAddress) duplex.Message { - frame := duplex.ProfileFrame{Version: 1, Kind: kind, ID: id} - if kind == duplex.ProfileRequest { - frame.Params = json.RawMessage(`{}`) - } else if kind == duplex.ProfileEvent { - frame.Data = json.RawMessage(`null`) - } - return duplex.Message{Frame: frame, Return: address} -} -func wireReservationSend(t *testing.T, wire duplex.Wire, kind duplex.ProfileKind, id string, address *duplex.ReturnAddress) { - t.Helper() - if err := wire.Send([]string{"operation"}, wireReservationMessage(kind, id, address)); err != nil { - t.Fatal(err) - } -} - -func TestRootWireCancellationHasReservedAdmissionAndKeepsFIFO(t *testing.T) { - peer, wire, propagator := wireReservationPeer(t) - sink := &wireReservationSink{replies: make(chan duplex.ProfileFrame, 8)} - address := &duplex.ReturnAddress{Wire: sink} - gate := propagator.pause(t) - wireReservationSend(t, wire, duplex.ProfileRequest, "c:1", address) - wireReservationAwait(t, gate.started) - wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) // The sole data slot is occupied. - wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) - for range 4 { - wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) - wireReservationSend(t, wire, duplex.ProfileCancel, "c:999", address) - } - if err := peer.Err(); err != nil { - t.Fatalf("cancellation ended its carrier: %v", err) - } - gate.open() - var kinds []string - for range 3 { - kinds = append(kinds, wireReservationAwait(t, peer.outputs).frame.Kind) - } - if !reflect.DeepEqual(kinds, []string{"request", "event", "cancel"}) { - t.Fatalf("physical admission order = %v", kinds) - } - if reply := wireReservationAwait(t, sink.replies); reply.Error == nil || reply.Error.Code != "cancelled" { - t.Fatalf("cancel reply = %+v", reply) - } - // A fence after duplicate, unknown and settled controls proves none escaped. - wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) - wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) - if got := wireReservationAwait(t, peer.outputs).frame.Kind; got != "event" { - t.Fatalf("stale control reached the carrier: %s", got) - } - if err := peer.Err(); err != nil { - t.Fatalf("carrier ended: %v", err) - } -} - -func TestRootWireCompletedCallRetainsItsQueuedCancellationBudget(t *testing.T) { - peer, wire, propagator := wireReservationPeer(t) - first := &wireReservationSink{replies: make(chan duplex.ProfileFrame, 4)} - address := &duplex.ReturnAddress{Wire: first} - second := &wireReservationSink{replies: make(chan duplex.ProfileFrame, 4)} - secondAddress := &duplex.ReturnAddress{Wire: second} - reentrant := make(chan error, 1) - first.onReply = func(duplex.ProfileFrame) { - reentrant <- wire.Send([]string{"operation"}, wireReservationMessage(duplex.ProfileRequest, "c:2", secondAddress)) - } - wireReservationSend(t, wire, duplex.ProfileRequest, "c:1", address) - request := wireReservationAwait(t, peer.outputs).frame - gate := propagator.pause(t) - wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) - wireReservationAwait(t, gate.started) - wireReservationSend(t, wire, duplex.ProfileCancel, "c:1", address) - // Complete before the root can drain the control. The response callback - // attempts to spend the same one-request budget while its stale control is - // still queued; it must receive busy, not replenish control capacity. - peer.mu.Lock() - reply := peer.pending[request.ID] - peer.mu.Unlock() - reply <- pendingResult{result: json.RawMessage(`7`)} - if err := wireReservationAwait(t, reentrant); err != nil { - t.Fatalf("refusal admission closed carrier: %v", err) - } - if response := wireReservationAwait(t, first.replies); string(response.Result) != "7" { - t.Fatalf("first response = %+v", response) - } - gate.open() - if got := wireReservationAwait(t, peer.outputs).frame.Kind; got != "event" { - t.Fatalf("queued event = %s", got) - } - if response := wireReservationAwait(t, second.replies); response.Error == nil || response.Error.Code != "busy" { - t.Fatalf("retained reservation allowed another request: %+v", response) - } - // Reuse the original local identity after the control drains. The stale - // cancellation must neither cancel it nor delete its new state. - first.onReply = nil - wireReservationSend(t, wire, duplex.ProfileRequest, "c:1", address) - third := wireReservationAwait(t, peer.outputs).frame - if third.Kind != "request" { - t.Fatalf("stale control was emitted: %+v", third) - } - peer.mu.Lock() - reply = peer.pending[third.ID] - peer.mu.Unlock() - reply <- pendingResult{result: json.RawMessage(`9`)} - if response := wireReservationAwait(t, first.replies); string(response.Result) != "9" { - t.Fatalf("reused identity response = %+v", response) - } - if err := peer.Err(); err != nil { - t.Fatalf("carrier ended: %v", err) - } -} - -func TestRootWireCancellationReservationDoesNotIncreaseDataCapacity(t *testing.T) { - peer, wire, propagator := wireReservationPeer(t) - gate := propagator.pause(t) - wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) - wireReservationAwait(t, gate.started) - wireReservationSend(t, wire, duplex.ProfileEvent, "", nil) - if err := wire.Send([]string{"operation"}, wireReservationMessage(duplex.ProfileEvent, "", nil)); !errors.Is(err, ErrBackpressure) { - t.Fatalf("extra data admission = %v", err) - } - if !errors.Is(peer.Err(), ErrBackpressure) { - t.Fatalf("full data carrier remained open: %v", peer.Err()) - } -} diff --git a/dispatch/go/wire_event_context_test.go b/dispatch/go/wire_event_context_test.go deleted file mode 100644 index 98c8a69..0000000 --- a/dispatch/go/wire_event_context_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -type eventVerifiedKey struct{} -type eventVerifiedPropagator struct{ verified any } - -func (p eventVerifiedPropagator) Extract(ctx context.Context, trace ws.Trace) context.Context { - return context.WithValue(ws.DefaultPropagator.Extract(ctx, trace), eventVerifiedKey{}, p.verified) -} -func (eventVerifiedPropagator) Inject(ctx context.Context) ws.Trace { - return ws.DefaultPropagator.Inject(ctx) -} - -func TestWireEventContextSurvivesPhysicalForwardLocalPairAndMount(t *testing.T) { - verified := &struct{ source string }{"trusted context"} - client, server := newPair(t, ws.Options{Propagator: eventVerifiedPropagator{verified}}, ws.Options{}) - access, binding, err := ws.NewWirePair(ws.Options{MaxPendingRequests: 1}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = access.Close(duplex.CodeNormal, "done") }) - stop, err := ws.ForwardWire(server.Wire(), testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"local": access})).Select([]string{"local"})) - if err != nil { - t.Fatal(err) - } - defer stop() - mountBinding := testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"model": binding})) - model := mountBinding.Select([]string{"model", "events"}) - observed := make(chan context.Context, 1) - effects := 0 - modelBinding := testBinding(t, model) - _, err = ws.RegisterWire(modelBinding, []string{"change"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { - observed <- ctx - if ctx.Value(eventVerifiedKey{}) != verified { - return errors.New("unverified event") - } - effects++ - return nil - }}) - if err != nil { - t.Fatal(err) - } - meta := map[string]string{"tenant": "explicit", "verified": "cannot manufacture context"} - if err := ws.EmitWire(ws.WithMeta(context.Background(), meta), client.Wire(), []string{"events", "change"}, nil); err != nil { - t.Fatal(err) - } - ctx := receive(t, observed) - if ctx.Value(eventVerifiedKey{}) != verified || !reflect.DeepEqual(ws.MetaFrom(ctx), meta) { - t.Fatalf("lost received context: verified=%v meta=%v", ctx.Value(eventVerifiedKey{}), ws.MetaFrom(ctx)) - } - _, _ = ws.HandleWire(mountBinding, []string{"model", "barrier"}, func(context.Context, json.RawMessage) (any, error) { return effects, nil }) - var count int - if err := ws.CallWire(context.Background(), access, []string{"barrier"}, nil, &count); err != nil || count != 1 { - t.Fatalf("effect count=%d, err=%v", count, err) - } - - // New outgoing events carry only explicitly supplied metadata. The private - // received context ends at the next physical boundary. - returned := make(chan context.Context, 2) - clientBinding := testBinding(t, client.Wire()) - _, _ = ws.RegisterWire(clientBinding, []string{"outgoing"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { returned <- ctx; return nil }}) - if err := ws.EmitWire(ctx, server.Wire(), []string{"outgoing"}, nil); err != nil { - t.Fatal(err) - } - fresh := receive(t, returned) - if fresh.Value(eventVerifiedKey{}) != nil || len(ws.MetaFrom(fresh)) != 0 { - t.Fatalf("ambient context crossed transport: %v %v", fresh.Value(eventVerifiedKey{}), ws.MetaFrom(fresh)) - } - if err := ws.EmitWire(ws.WithMeta(ctx, ws.MetaFrom(ctx)), server.Wire(), []string{"outgoing"}, nil); err != nil { - t.Fatal(err) - } - if got := ws.MetaFrom(receive(t, returned)); !reflect.DeepEqual(got, meta) { - t.Fatalf("explicit outgoing metadata=%v", got) - } - _ = server.Close() - select { - case <-ctx.Done(): - case <-time.After(time.Second): - t.Fatal("event context lost its physical connection lifetime") - } -} - -func TestWireEventMetadataCannotSupplyVerifiedContext(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - access, binding, err := ws.NewWirePair(ws.Options{}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = access.Close(duplex.CodeNormal, "done") }) - stop, err := ws.ForwardWire(server.Wire(), access) - if err != nil { - t.Fatal(err) - } - defer stop() - denied := make(chan bool, 1) - modelBinding := testBinding(t, binding) - _, _ = ws.RegisterWire(modelBinding, []string{"guard"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { - if ctx.Value(eventVerifiedKey{}) == nil { - denied <- true - return errors.New("event denied") - } - denied <- false - return nil - }}) - if err := ws.EmitWire(ws.WithMeta(context.Background(), map[string]string{"verified": "yes"}), client.Wire(), []string{"guard"}, nil); err != nil { - t.Fatal(err) - } - if !receive(t, denied) { - t.Fatal("metadata bypassed the event guard") - } -} - -func TestWireEventContextStopsAtAnotherPhysicalBoundary(t *testing.T) { - verified := &struct{}{} - client, incoming := newPair(t, ws.Options{Propagator: eventVerifiedPropagator{verified}}, ws.Options{}) - outgoing, server := newPair(t, ws.Options{}, ws.Options{}) - stop, err := ws.ForwardWire(incoming.Wire(), outgoing.Wire()) - if err != nil { - t.Fatal(err) - } - defer stop() - observed := make(chan context.Context, 1) - serverBinding := testBinding(t, server.Wire()) - _, _ = ws.RegisterWire(serverBinding, []string{"event"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { observed <- ctx; return nil }}) - if err := ws.EmitWire(ws.WithMeta(context.Background(), map[string]string{"explicit": "yes"}), client.Wire(), []string{"event"}, nil); err != nil { - t.Fatal(err) - } - ctx := receive(t, observed) - if ctx.Value(eventVerifiedKey{}) != nil || ws.MetaFrom(ctx)["explicit"] != "yes" { - t.Fatalf("physical boundary: private=%v meta=%v", ctx.Value(eventVerifiedKey{}), ws.MetaFrom(ctx)) - } -} - -func TestWireLocalEventsUseSuppliedPropagator(t *testing.T) { - verified := &struct{}{} - a, b, err := ws.NewWirePair(ws.Options{Propagator: eventVerifiedPropagator{verified}}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = a.Close(duplex.CodeNormal, "done") }) - observed := make(chan context.Context, 1) - bBinding := testBinding(t, b) - _, _ = ws.RegisterWire(bBinding, []string{"event"}, ws.WireHandlers{Event: func(ctx context.Context, _ json.RawMessage) error { observed <- ctx; return nil }}) - if err := ws.EmitWire(context.Background(), a, []string{"event"}, nil); err != nil { - t.Fatal(err) - } - if receive(t, observed).Value(eventVerifiedKey{}) != verified { - t.Fatal("local event bypassed configured propagator") - } -} diff --git a/dispatch/go/wire_namespace_test.go b/dispatch/go/wire_namespace_test.go deleted file mode 100644 index b5313e8..0000000 --- a/dispatch/go/wire_namespace_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "sync" - "testing" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -type namespaceObservation struct { - Picked string - Path []string -} - -func namespaceReceiver(picked string, events chan namespaceObservation) duplex.Receiver { - return duplex.Receiver{Message: func(path []string, message duplex.Message) { - observation := namespaceObservation{picked, append([]string{}, path...)} - if message.Frame.Kind == duplex.ProfileEvent { - events <- observation - return - } - if message.Frame.Kind != duplex.ProfileRequest { - return - } - data, _ := json.Marshal(observation) - _ = message.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: message.Frame.ID, Result: data}}) - }} -} - -func TestDispatcherUsesExactThenLongestSegmentPrefix(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - events := make(chan namespaceObservation, 20) - wire := testBinding(t, server.Wire()) - for _, route := range []struct { - path []string - name string - }{{nil, "root"}, {[]string{"a"}, "a"}, {[]string{"a", "b"}, "ab"}} { - if _, err := wire.RegisterPrefix(route.path, namespaceReceiver(route.name, events)); err != nil { - t.Fatal(err) - } - if _, err := wire.RegisterPrefix(route.path, namespaceReceiver("duplicate", events)); !errors.Is(err, duplex.ErrReceiverExists) { - t.Fatalf("duplicate namespace = %v", err) - } - } - detach, err := wire.Register([]string{"a"}, namespaceReceiver("exact", events)) - if err != nil { - t.Fatal(err) - } - for _, route := range []struct { - path []string - picked string - }{ - {[]string{"a"}, "exact"}, {[]string{"a", "b", "leaf"}, "ab"}, {[]string{"a", "bc"}, "a"}, {[]string{"a.b", "leaf"}, "root"}, {[]string{"", "๐Ÿ˜€"}, "root"}, - } { - var got namespaceObservation - if err := ws.CallWire(context.Background(), client.Wire(), route.path, nil, &got); err != nil { - t.Fatal(err) - } - want := namespaceObservation{route.picked, route.path} - if !reflect.DeepEqual(got, want) { - t.Fatalf("request = %+v; want %+v", got, want) - } - if err := ws.EmitWire(context.Background(), client.Wire(), route.path, nil); err != nil { - t.Fatal(err) - } - if got := receive(t, events); !reflect.DeepEqual(got, want) { - t.Fatalf("event = %+v; want %+v", got, want) - } - } - detach() - detach() - var got namespaceObservation - if err := ws.CallWire(context.Background(), client.Wire(), []string{"a"}, nil, &got); err != nil || got.Picked != "a" { - t.Fatalf("exact detach did not expose namespace: %+v %v", got, err) - } - if err := server.Handle("ordinary", func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return "raw", nil }); err != nil { - t.Fatal(err) - } - var raw string - if err := client.Call(context.Background(), "ordinary", nil, &raw); err != nil || raw != "raw" { - t.Fatalf("raw handler = %q %v", raw, err) - } - for _, name := range []string{"unknown.raw", "01:a", "1:a.invalid"} { - err := client.Call(context.Background(), name, nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "method_not_found" { - t.Fatalf("namespace captured noncanonical name %q: %v", name, err) - } - } -} - -func TestDispatcherCancellationKeepsOriginalRegistration(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - serverBinding := testBinding(t, server.Wire()) - started := make(chan *duplex.ReturnAddress, 1) - cancelled := make(chan *duplex.ReturnAddress, 1) - detach, err := serverBinding.RegisterPrefix([]string{"worker"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - if m.Frame.Kind == duplex.ProfileRequest { - started <- m.Return - } - if m.Frame.Kind == duplex.ProfileCancel { - cancelled <- m.Return - } - }}) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - result := make(chan error, 1) - go func() { result <- ws.CallWire(ctx, client.Wire(), []string{"worker", "dynamic"}, nil, nil) }() - original := receive(t, started) - detach() - replacement := make(chan duplex.ProfileKind, 4) - if _, err := serverBinding.RegisterPrefix([]string{"worker"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { replacement <- m.Frame.Kind }}); err != nil { - t.Fatal(err) - } - cancel() - if err := receive(t, result); !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - if got := receive(t, cancelled); got != original { - t.Fatal("cancellation changed the original return capability") - } - select { - case got := <-replacement: - t.Fatalf("replacement received old request's %s", got) - default: - } -} - -func TestStructuredWireBridgePreservesTraceVerbatim(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - serverBinding := testBinding(t, server.Wire()) - frames := make(chan duplex.ProfileFrame, 8) - _, err := serverBinding.Register([]string{"trace"}, duplex.Receiver{Message: func(_ []string, m duplex.Message) { - frames <- m.Frame - if m.Frame.Kind == duplex.ProfileRequest { - _ = m.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: m.Frame.ID, Result: json.RawMessage(`null`)}}) - } - }}) - if err != nil { - t.Fatal(err) - } - sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 2)} - address := &duplex.ReturnAddress{Wire: sink} - for _, trace := range []ws.Trace{{Parent: "00-11111111111111111111111111111111-2222222222222222-01", State: "vendor=value"}, {}} { - for _, kind := range []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileEvent} { - frame := duplex.ProfileFrame{Version: 1, Kind: kind, Traceparent: trace.Parent, Tracestate: trace.State} - if kind == duplex.ProfileRequest { - frame.ID = "c:1" - frame.Params = json.RawMessage(`null`) - } else { - frame.Data = json.RawMessage(`null`) - } - if err := client.Wire().Send([]string{"trace"}, duplex.Message{Frame: frame, Return: address}); err != nil { - t.Fatal(err) - } - got := receive(t, frames) - if got.Traceparent != trace.Parent || got.Tracestate != trace.State { - t.Fatalf("structured %s trace changed: %+v; want %+v", kind, got, trace) - } - if kind == duplex.ProfileRequest { - reply := receive(t, sink.replies) - if reply.Traceparent != trace.Parent || reply.Tracestate != trace.State { - t.Fatalf("response trace changed: %+v", reply) - } - } - } - } -} - -func TestRegisterWireGroupsRequestAndEventAtOnePath(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - serverBinding := testBinding(t, server.Wire()) - events := make(chan string, 2) - detach, err := ws.RegisterWire(serverBinding, []string{"shared"}, ws.WireHandlers{ - Request: func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }, - Event: func(_ context.Context, value json.RawMessage) error { - var text string - _ = json.Unmarshal(value, &text) - events <- text - return nil - }, - }) - if err != nil { - t.Fatal(err) - } - var got string - if err := ws.CallWire(context.Background(), client.Wire(), []string{"shared"}, "response", &got); err != nil || got != "response" { - t.Fatalf("grouped request = %q %v", got, err) - } - if err := ws.EmitWire(context.Background(), client.Wire(), []string{"shared"}, "event"); err != nil { - t.Fatal(err) - } - if got := receive(t, events); got != "event" { - t.Fatal(got) - } - detach() - detach() - _, err = ws.RegisterWire(serverBinding, []string{"shared"}, ws.WireHandlers{Event: func(context.Context, json.RawMessage) error { return nil }}) - if err != nil { - t.Fatal(err) - } - err = ws.CallWire(context.Background(), client.Wire(), []string{"shared"}, nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "method_not_found" { - t.Fatalf("event-only request did not refuse: %v", err) - } - if _, err := ws.RegisterWire(serverBinding, []string{"empty"}, ws.WireHandlers{}); err == nil { - t.Fatal("registered empty handlers") - } -} - -// This fixture created and owns the carrier, so its registry explicitly owns -// fatal-handler closure. Ordinary borrowed dispatchers only detach themselves. -type ownedEventRegistry struct { - *ws.Dispatcher - endpoint duplex.Endpoint -} - -func (r ownedEventRegistry) Close(code duplex.Code, reason string) error { - _ = r.Dispatcher.Close(code, reason) - return r.endpoint.Close(code, reason) -} - -func TestRegisterWireEventFailuresEndOnlyTheirCarrierWithSanitizedReason(t *testing.T) { - for _, panics := range []bool{false, true} { - t.Run(map[bool]string{false: "error", true: "panic"}[panics], func(t *testing.T) { - log := &recorder{} - client, server := newPair(t, ws.Options{Observer: log}, ws.Options{}) - serverBinding := ownedEventRegistry{testBinding(t, server.Wire()), server.Wire()} - _, err := ws.RegisterWire(serverBinding, []string{"rejected"}, ws.WireHandlers{Event: func(context.Context, json.RawMessage) error { - if panics { - panic("private failure") - } - return errors.New("private failure") - }}) - if err != nil { - t.Fatal(err) - } - if err := ws.EmitWire(context.Background(), client.Wire(), []string{"rejected"}, nil); err != nil { - t.Fatal(err) - } - receive(t, server.Done()) - wireChannelAwait(t, log, 1, func(event ws.ObserverEvent) bool { _, ok := event.(ws.ConnectionClosed); return ok }) - for _, event := range log.all() { - if closed, ok := event.(ws.ConnectionClosed); ok { - if closed.Code != 1002 || closed.Reason != "wire event rejected" { - t.Fatalf("event ending = %+v", closed) - } - return - } - } - t.Fatal("no carrier ending was observed") - }) - } -} - -type forwardRegistrationWire struct { - receiver duplex.Receiver - sent []duplex.Message - paths [][]string - detached int - closed int - receiveErr error - sendErr error -} - -func (w *forwardRegistrationWire) Send(path []string, message duplex.Message) error { - w.paths = append(w.paths, path) - w.sent = append(w.sent, message) - return w.sendErr -} -func (w *forwardRegistrationWire) Receive(receiver duplex.Receiver) (func(), error) { - if w.receiveErr != nil { - return nil, w.receiveErr - } - w.receiver = receiver - var once sync.Once - return func() { once.Do(func() { w.detached++ }) }, nil -} -func (w *forwardRegistrationWire) Close(duplex.Code, string) error { w.closed++; return nil } - -func TestForwardWirePreservesMessagesAndOwnsOnlyRegistrations(t *testing.T) { - left, right := &forwardRegistrationWire{}, &forwardRegistrationWire{} - detach, err := ws.ForwardWire(left, right) - if err != nil { - t.Fatal(err) - } - returning := &duplex.ReturnAddress{Wire: left} - path := []string{"unknown", "a.b", "", "๐Ÿ˜€"} - for _, kind := range []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileResponse, duplex.ProfileEvent, duplex.ProfileCancel} { - message := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: kind, ID: "c:1", Params: json.RawMessage(`{"n":1e3}`)}, Return: returning} - left.receiver.Message(path, message) - right.receiver.Message(path, message) - if !reflect.DeepEqual(left.sent[len(left.sent)-1], message) || !reflect.DeepEqual(right.sent[len(right.sent)-1], message) { - t.Fatalf("%s changed", kind) - } - if left.sent[len(left.sent)-1].Return != returning || right.sent[len(right.sent)-1].Return != returning { - t.Fatal("return capability changed") - } - } - if !reflect.DeepEqual(left.paths[0], path) || !reflect.DeepEqual(right.paths[0], path) { - t.Fatal("forward path changed") - } - left.receiver.Closed(1000, "ended") - detach() - detach() - if left.detached != 1 || right.detached != 1 || left.closed != 0 || right.closed != 0 { - t.Fatalf("lifecycle: left=%+v right=%+v", left, right) - } - left, right = &forwardRegistrationWire{}, &forwardRegistrationWire{receiveErr: errors.New("installation refused")} - if _, err := ws.ForwardWire(left, right); err == nil || left.detached != 1 || left.closed != 0 { - t.Fatalf("partial install leaked: %+v %v", left, err) - } - left, right = &forwardRegistrationWire{}, &forwardRegistrationWire{sendErr: ws.Unpublished(&ws.PublicError{Code: "busy", Message: "Busy"})} - if _, err := ws.ForwardWire(left, right); err != nil { - t.Fatal(err) - } - sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 1)} - left.receiver.Message(path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1"}, Return: &duplex.ReturnAddress{Wire: sink}}) - response := receive(t, sink.replies) - if response.Error == nil || response.Error.Code != "busy" || left.detached != 1 || right.detached != 1 || right.closed != 0 { - t.Fatalf("failed forwarding did not refuse and detach: %+v %+v %+v", response, left, right) - } -} - -func TestForwardWireCarriesUnknownPathsAndReverseCallsAcrossPeers(t *testing.T) { - client, middleIn := newPair(t, ws.Options{}, ws.Options{}) - middleOut, server := newPair(t, ws.Options{}, ws.Options{}) - inbound := testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"in": testBinding(t, middleIn.Wire()).Select([]string{"gateway"})})).Select([]string{"in"}) - outbound := testBinding(t, duplex.Mount(map[string]duplex.Endpoint{"out": testBinding(t, middleOut.Wire()).Select([]string{"service"})})).Select([]string{"out"}) - caller := testBinding(t, testBinding(t, client.Wire()).Select([]string{"gateway"})) - implementation := testBinding(t, testBinding(t, server.Wire()).Select([]string{"service"})) - detach, err := ws.ForwardWire(inbound, outbound) - if err != nil { - t.Fatal(err) - } - defer detach() - _, err = ws.HandleWire(caller, []string{"reverse", "dynamic"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) - if err != nil { - t.Fatal(err) - } - _, err = ws.HandleWire(implementation, []string{"arbitrary", "nested", "call"}, func(ctx context.Context, value json.RawMessage) (any, error) { - var result string - if err := ws.CallWire(ctx, implementation, []string{"reverse", "dynamic"}, value, &result); err != nil { - return nil, err - } - return result + " returned", nil - }) - if err != nil { - t.Fatal(err) - } - var result string - if err := ws.CallWire(context.Background(), caller, []string{"arbitrary", "nested", "call"}, "callback", &result); err != nil || result != "callback returned" { - t.Fatalf("forwarded reverse call = %q %v", result, err) - } - events := make(chan string, 2) - _, err = ws.RegisterWire(implementation, []string{"arbitrary", "nested", "event"}, ws.WireHandlers{Event: func(_ context.Context, value json.RawMessage) error { - var text string - _ = json.Unmarshal(value, &text) - events <- text - return nil - }}) - if err != nil { - t.Fatal(err) - } - if err := ws.EmitWire(context.Background(), caller, []string{"arbitrary", "nested", "event"}, "observed"); err != nil { - t.Fatal(err) - } - if got := receive(t, events); got != "observed" { - t.Fatal(got) - } - started, ended := make(chan struct{}), make(chan struct{}) - _, err = ws.HandleWire(implementation, []string{"arbitrary", "cancel"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - close(started) - <-ctx.Done() - close(ended) - return nil, ctx.Err() - }) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - finished := make(chan error, 1) - go func() { finished <- ws.CallWire(ctx, caller, []string{"arbitrary", "cancel"}, nil, nil) }() - receive(t, started) - detach() - cancel() - if err := receive(t, finished); !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - receive(t, ended) - for _, peer := range []*ws.Peer{client, middleIn, middleOut, server} { - if err := peer.Err(); err != nil { - t.Fatalf("forward detach closed peer: %v", err) - } - } -} diff --git a/dispatch/go/wire_options_test.go b/dispatch/go/wire_options_test.go deleted file mode 100644 index 64dc3cd..0000000 --- a/dispatch/go/wire_options_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" - "testing" - "time" -) - -func TestWireForwardingRetainsTheAdmittedDeadline(t *testing.T) { - client, server := newPair(t, ws.Options{RequestTimeout: time.Minute}, ws.Options{RequestTimeout: time.Minute}) - serverBinding := testBinding(t, server.Wire()) - _, err := ws.HandleWire(serverBinding, []string{"deadline"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - deadline, ok := ctx.Deadline() - if !ok { - return int64(0), nil - } - return int64(time.Until(deadline) / time.Millisecond), nil - }) - if err != nil { - t.Fatal(err) - } - var remaining int64 - if err := client.Call(context.Background(), "8:deadline", nil, &remaining); err != nil { - t.Fatal(err) - } - if remaining < 50000 { - t.Fatalf("forwarding shortened the admitted one-minute deadline to %dms", remaining) - } -} - -type configuredWirePropagator struct { - remaining time.Duration - trace ws.Trace -} - -func (p *configuredWirePropagator) Extract(ctx context.Context, _ ws.Trace) context.Context { - return ctx -} -func (p *configuredWirePropagator) Inject(ctx context.Context) ws.Trace { - if deadline, ok := ctx.Deadline(); ok { - p.remaining = time.Until(deadline) - } - return p.trace -} - -type optionWire struct { - send func([]string, duplex.Message) error -} - -func (w optionWire) Send(path []string, m duplex.Message) error { return w.send(path, m) } - -func TestWireUsesTheConfiguredOutgoingPropagatorAndTimeout(t *testing.T) { - want := ws.Trace{Parent: "00-11111111111111111111111111111111-2222222222222222-01", State: "vendor=kept"} - propagator := &configuredWirePropagator{trace: want} - received := make(chan duplex.ProfileFrame, 2) - wire := optionWire{send: func(_ []string, m duplex.Message) error { - received <- m.Frame - if m.Frame.Kind == duplex.ProfileRequest { - return m.Return.Wire.Send(nil, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: m.Frame.ID, Result: json.RawMessage(`null`)}}) - } - return nil - }} - if err := ws.CallWire(context.Background(), wire, []string{"call"}, nil, nil, ws.WireCallOptions{Propagator: propagator, RequestTimeout: time.Minute}); err != nil { - t.Fatal(err) - } - if propagator.remaining < 50*time.Second { - t.Fatalf("configured minute shortened to %v", propagator.remaining) - } - if err := ws.EmitWire(context.Background(), wire, []string{"event"}, nil, ws.WireEmitOptions{Propagator: propagator}); err != nil { - t.Fatal(err) - } - for range 2 { - frame := <-received - if frame.Traceparent != want.Parent || frame.Tracestate != want.State { - t.Fatalf("configured trace lost: %+v", frame) - } - } -} - -func TestWireConfiguredTimeoutCancelsTheSameReturnCapability(t *testing.T) { - messages := make(chan duplex.Message, 2) - wire := optionWire{send: func(_ []string, m duplex.Message) error { messages <- m; return nil }} - started := time.Now() - err := ws.CallWire(context.Background(), wire, []string{"wait"}, nil, nil, ws.WireCallOptions{RequestTimeout: 20 * time.Millisecond}) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("timeout result: %v", err) - } - if time.Since(started) > time.Second { - t.Fatal("configured timeout was ignored") - } - request, cancel := <-messages, <-messages - if cancel.Frame.Kind != duplex.ProfileCancel || cancel.Return != request.Return || cancel.Frame.ID != request.Frame.ID || cancel.Frame.Traceparent != request.Frame.Traceparent { - t.Fatal("timeout changed request correlation") - } -} diff --git a/dispatch/go/wire_send_test.go b/dispatch/go/wire_send_test.go deleted file mode 100644 index a5e7235..0000000 --- a/dispatch/go/wire_send_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package runtime_test - -import ( - "context" - "errors" - "testing" - "time" - - ws "github.com/Bitspark/nightseam/runtime/go" -) - -// A full destination's consumer remains held while the caller finishes. The -// write deadline is deliberately much longer than the caller's bound: waiting -// for that deadline would make fan-out run at its slowest destination's pace. -func TestWireSendEndsAFullCarrierWithoutWaitingForItsConsumer(t *testing.T) { - release := make(chan struct{}) - defer close(release) - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - observed := &recorder{} - _, destination := delayedWritePair(t, control, ws.Options{ - QueueCapacity: 1, - WriteTimeout: 5 * time.Second, - Observer: observed, - }, ws.Options{}) - if err := destination.Emit(context.Background(), "first", 1); err != nil { - t.Fatal(err) - } - receive(t, control.started) - if err := destination.Emit(context.Background(), "second", 2); err != nil { - t.Fatal(err) - } - finished := make(chan error, 1) - go func() { finished <- ws.EmitWire(context.Background(), destination.Wire(), []string{"overflow"}, 3) }() - select { - case err := <-finished: - if err != nil && !errors.Is(err, ws.ErrBackpressure) { - t.Fatalf("wire admission = %v, want admission or backpressure", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("send waited for a destination whose consumer is still held") - } - select { - case <-destination.Done(): - case <-time.After(500 * time.Millisecond): - t.Fatal("wire dispatch waited for a destination whose consumer is still held") - } - if !errors.Is(destination.Err(), ws.ErrBackpressure) { - t.Fatalf("full carrier remained open: %v", destination.Err()) - } - stalls := 0 - for _, event := range observed.all() { - if pressure, ok := event.(ws.Backpressure); ok && pressure.Stalled { - stalls++ - } - } - if stalls != 1 { - t.Fatalf("observer received %d terminal pressure events, want 1", stalls) - } -} - -func TestWireRequestEndsAFullCarrierWithoutWaitingForItsConsumer(t *testing.T) { - release := make(chan struct{}) - defer close(release) - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - _, destination := delayedWritePair(t, control, ws.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second}, ws.Options{}) - if err := destination.Emit(context.Background(), "first", 1); err != nil { - t.Fatal(err) - } - receive(t, control.started) - if err := destination.Emit(context.Background(), "second", 2); err != nil { - t.Fatal(err) - } - finished := make(chan error, 1) - go func() { - finished <- ws.CallWire(context.Background(), destination.Wire(), []string{"overflow"}, nil, nil) - }() - select { - case err := <-finished: - if err == nil { - t.Fatal("wire request into a full carrier succeeded") - } - // Root wire admission already succeeded. A downstream carrier refusal - // is an ordinary result, not proof of pre-admission non-publication. - wantUnpublished(t, err, false) - case <-time.After(500 * time.Millisecond): - t.Fatal("wire request waited for a destination whose consumer is still held") - } - if !errors.Is(destination.Err(), ws.ErrBackpressure) { - t.Fatalf("full carrier remained open: %v", destination.Err()) - } -} - -func TestOutputCancellationProofBelongsOnlyToTheUnadmittedCall(t *testing.T) { - release := make(chan struct{}) - defer close(release) - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - observed := &pacedOutputObserver{pressure: make(chan ws.Backpressure, 4)} - _, destination := delayedWritePair(t, control, ws.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second, Observer: observed}, ws.Options{}) - accepted := make(chan error, 1) - go func() { accepted <- destination.Call(context.Background(), "accepted", nil, nil) }() - // The earlier call is already in its carrier's write. Hold it there, then - // occupy the only queue slot before attempting the rejected call. - receive(t, control.started) - if err := destination.Emit(context.Background(), "queued", nil); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - refused := make(chan error, 1) - go func() { refused <- destination.Call(ctx, "rejected", nil, nil) }() - if pressure := receive(t, observed.pressure); pressure.Stalled { - t.Fatalf("public call did not pace: %+v", pressure) - } - cancel() - rejected := receive(t, refused) - wantUnpublished(t, rejected, true) - if !errors.Is(rejected, context.Canceled) { - t.Fatalf("rejected call = %v", rejected) - } - // A subsequent immediate Wire dispatch ends this full carrier. Its failure - // cannot make the earlier admitted call prove that it never left. - if err := ws.EmitWire(context.Background(), destination.Wire(), []string{"overflow"}, nil); err != nil { - t.Fatal(err) - } - earlier := receive(t, accepted) - wantUnpublished(t, earlier, false) - if !errors.Is(earlier, ws.ErrBackpressure) { - t.Fatalf("earlier call = %v", earlier) - } -} diff --git a/dispatch/go/wire_test.go b/dispatch/go/wire_test.go deleted file mode 100644 index e1daaaf..0000000 --- a/dispatch/go/wire_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "reflect" - "runtime" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -func testBinding(t *testing.T, endpoint duplex.Endpoint) *ws.Dispatcher { - t.Helper() - binding, err := ws.NewDispatcher(endpoint) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = binding.Close(duplex.CodeNormal, "done") }) - return binding -} - -type wireReplySink struct{ replies chan duplex.ProfileFrame } - -func TestReceiverDeadlineWinsImmediateHandlerRefusal(t *testing.T) { - previous := runtime.GOMAXPROCS(4) - t.Cleanup(func() { runtime.GOMAXPROCS(previous) }) - ended := make(chan ws.RequestEnded, 1) - client, server := newPair(t, ws.Options{RequestTimeout: 100 * time.Microsecond, Observer: observerFunc(func(event ws.ObserverEvent) { - if e, ok := event.(ws.RequestEnded); ok && e.Incoming { - ended <- e - } - })}, ws.Options{}) - if err := server.Handle("deadline", func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - <-ctx.Done() - return nil, &ws.PublicError{Code: "declined", Message: "Body completed at deadline"} - }); err != nil { - t.Fatal(err) - } - // Exercise the real timer/body race: completion can run before the - // asynchronous deadline callback, but the deadline is already selected. - for i := range 1024 { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - err := client.Call(ctx, "deadline", nil, nil) - cancel() - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "cancelled" { - t.Fatalf("call %d deadline response = %v", i, err) - } - if e := receive(t, ended); e.Outcome != ws.OutcomeTimedOut || e.ErrorCode != "request_timeout" { - t.Fatalf("call %d deadline outcome = %+v", i, e) - } - } -} - -func TestWireCancellationRetainsExecutingHandlerBudget(t *testing.T) { - for _, mode := range []string{"cancel", "caller-deadline", "receiver-deadline", "public-refusal"} { - for _, route := range []string{"wire", "forwarded", "peer"} { - t.Run(mode+"/"+route, func(t *testing.T) { - ended := make(chan ws.RequestEnded, 16) - options := ws.Options{MaxConcurrentHandlers: 1, Observer: observerFunc(func(event ws.ObserverEvent) { - if e, ok := event.(ws.RequestEnded); ok && e.Incoming { - ended <- e - } - })} - if mode == "receiver-deadline" { - options.RequestTimeout = 100 * time.Millisecond - } - client, server := newPair(t, options, ws.Options{}) - entered, cancelled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) - var released sync.Once - t.Cleanup(func() { released.Do(func() { close(release) }) }) - var calls atomic.Int32 - handler := func(ctx context.Context, _ json.RawMessage) (any, error) { - if calls.Add(1) == 1 { - close(entered) - <-ctx.Done() - close(cancelled) - <-release - if mode == "public-refusal" { - return nil, &ws.PublicError{Code: "cancelled", Message: "Application refusal"} - } - } - return "finished", nil - } - var err error - call := func(ctx context.Context) error { return ws.CallWire(ctx, client.Wire(), []string{"hold"}, nil, nil) } - if route == "peer" { - err = server.Handle("hold", func(ctx context.Context, _ *ws.Peer, params json.RawMessage) (any, error) { - return handler(ctx, params) - }) - call = func(ctx context.Context) error { return client.Call(ctx, "hold", nil, nil) } - } else { - model := server.Wire() - if route == "forwarded" { - left, right, pairErr := ws.NewWirePair(ws.Options{}) - if pairErr != nil { - t.Fatal(pairErr) - } - t.Cleanup(func() { _ = left.Close(duplex.CodeNormal, "") }) - stop, forwardErr := ws.ForwardWire(server.Wire(), left) - if forwardErr != nil { - t.Fatal(forwardErr) - } - t.Cleanup(stop) - model = right - } - modelBinding := testBinding(t, model) - _, err = ws.HandleWire(modelBinding, []string{"hold"}, handler) - } - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - if mode == "caller-deadline" { - cancel() - ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond) - } - defer cancel() - result := make(chan error, 1) - go func() { result <- call(ctx) }() - receive(t, entered) - if mode == "cancel" || mode == "public-refusal" { - cancel() - } - if mode == "receiver-deadline" { - var public *ws.PublicError - if err := receive(t, result); !errors.As(err, &public) || public.Code != "cancelled" { - t.Fatalf("receiver deadline = %v", err) - } - } else { - if err := receive(t, result); !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("caller cancellation = %v", err) - } - } - receive(t, cancelled) - if mode == "receiver-deadline" { - if e := receive(t, ended); e.Outcome != ws.OutcomeTimedOut || e.ErrorCode != "request_timeout" { - t.Fatalf("receiver deadline outcome = %+v", e) - } - } - // Repeated round trips keep exercising admission while the first body - // is explicitly held, independent of when its wrapper is scheduled. - for range 10 { - err := call(context.Background()) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatalf("request admitted while cancelled body still runs: calls=%d, err=%v", calls.Load(), err) - } - if e := receive(t, ended); e.ErrorCode != "busy" { - t.Fatalf("held request ended before its body returned: %+v", e) - } - } - if calls.Load() != 1 { - t.Fatalf("executed %d bodies at a limit of one", calls.Load()) - } - released.Do(func() { close(release) }) - if mode != "receiver-deadline" { - outcome := ws.OutcomeCancelled - if mode == "public-refusal" { - outcome = ws.OutcomeErrored - } - if e := receive(t, ended); e.Outcome != outcome || e.ErrorCode != "cancelled" { - t.Fatalf("withdrawal outcome = %+v", e) - } - } - ready, stop := context.WithTimeout(context.Background(), 5*time.Second) - defer stop() - for { - err := call(ready) - if err == nil { - break - } - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatal(err) - } - // Each retry produces an observation before its refusal. Drain - // it so the test observer cannot block the next admission. - if e := receive(t, ended); e.ErrorCode != "busy" { - t.Fatalf("retry outcome = %+v", e) - } - } - }) - } - } -} - -type wireVerifiedKey struct{} -type wireContextPropagator struct{ ws.Propagator } - -func (p wireContextPropagator) Extract(ctx context.Context, trace ws.Trace) context.Context { - return context.WithValue(p.Propagator.Extract(ctx, trace), wireVerifiedKey{}, true) -} - -func TestWireKeepsReceivedContextWithoutForwardingApplicationMetadata(t *testing.T) { - client, server := newPair(t, ws.Options{Propagator: wireContextPropagator{ws.DefaultPropagator}}, ws.Options{}) - clientBinding := testBinding(t, client.Wire()) - _, err := ws.HandleWire(clientBinding, []string{"reverse"}, func(ctx context.Context, _ json.RawMessage) (any, error) { return ws.MetaFrom(ctx), nil }) - if err != nil { - t.Fatal(err) - } - serverBinding := testBinding(t, server.Wire()) - _, err = ws.HandleWire(serverBinding, []string{"check"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - var reverse ws.Meta - if err := ws.CallWire(ctx, server.Wire(), []string{"reverse"}, nil, &reverse); err != nil { - return nil, err - } - return map[string]any{"verified": ctx.Value(wireVerifiedKey{}) == true, "received": ws.MetaFrom(ctx), "reverse": reverse}, nil - }) - if err != nil { - t.Fatal(err) - } - var got struct { - Verified bool - Received ws.Meta - Reverse ws.Meta - } - if err := ws.CallWire(ws.WithMeta(context.Background(), ws.Meta{"credential": "one-call"}), client.Wire(), []string{"check"}, nil, &got); err != nil { - t.Fatal(err) - } - if !got.Verified || got.Received["credential"] != "one-call" || len(got.Reverse) != 0 { - t.Fatalf("wire request context = %+v", got) - } -} - -func TestWireHandlerPanicStaysPrivateAndObserved(t *testing.T) { - observed := &recorder{} - client, server := newPair(t, ws.Options{Observer: observed}, ws.Options{}) - serverBinding := testBinding(t, server.Wire()) - _, err := ws.HandleWire(serverBinding, []string{"panic"}, func(context.Context, json.RawMessage) (any, error) { panic("private failure") }) - if err != nil { - t.Fatal(err) - } - err = ws.CallWire(context.Background(), client.Wire(), []string{"panic"}, nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "internal" || public.Message != "Internal error" { - t.Fatalf("panic response = %v", err) - } - count := 0 - for _, event := range observed.all() { - if failure, ok := event.(ws.HandlerPanic); ok { - count++ - if failure.Value != "private failure" { - t.Fatalf("panic observation = %+v", failure) - } - } - } - if count != 1 { - t.Fatalf("panic observations = %d", count) - } - if server.Err() != nil { - t.Fatalf("panic ended carrier: %v", server.Err()) - } -} - -// A return capability refuses what it does not implement, as every addressed -// receiver in this profile does; this one carries outcomes and nothing else. -func (s *wireReplySink) Send(path []string, message duplex.Message) error { - if len(path) != 0 { - return errors.New("this return capability carries outcomes only") - } - s.replies <- message.Frame - return nil -} - -func TestWirePreservesRequestAndEventAdmissionOrder(t *testing.T) { - observed := &recorder{} - client, server := newPair(t, ws.Options{}, ws.Options{Observer: observed}) - sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 40)} - address := &duplex.ReturnAddress{Wire: sink} - wire := client.Wire() - var want []string - serverBinding := testBinding(t, server.Wire()) - for i := range 40 { - path := []string{"ordered", fmt.Sprint(i)} - _, err := ws.HandleWire(serverBinding, path, func(context.Context, json.RawMessage) (any, error) { return nil, nil }) - if err != nil { - t.Fatal(err) - } - name, _ := duplex.EncodePath(path) - for _, kind := range []duplex.ProfileKind{duplex.ProfileRequest, duplex.ProfileEvent} { - frame := duplex.ProfileFrame{Version: 1, Kind: kind} - if kind == duplex.ProfileRequest { - frame.ID = fmt.Sprintf("c:%d", i+1) - frame.Params = json.RawMessage("{}") - } else { - frame.Data = json.RawMessage("null") - } - if err := wire.Send(path, duplex.Message{Frame: frame, Return: address}); err != nil { - t.Fatal(err) - } - want = append(want, string(kind)+" "+name) - } - } - for range 40 { - receive(t, sink.replies) - } - var got []string - for _, event := range observed.all() { - if sent, ok := event.(ws.FrameSent); ok && (sent.Kind == "request" || sent.Kind == "event") { - got = append(got, sent.Kind+" "+sent.Name) - } - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("per-wire send order changed:\n got %v\nwant %v", got, want) - } -} - -func TestWirePreservesCancellationBeforeTheFollowingEvent(t *testing.T) { - observed := &recorder{} - client, server := newPair(t, ws.Options{}, ws.Options{Observer: observed}) - started := make(chan struct{}) - eventReceived := make(chan struct{}) - serverBinding := testBinding(t, server.Wire()) - if _, err := ws.RegisterWire(serverBinding, []string{"after"}, ws.WireHandlers{Event: func(context.Context, json.RawMessage) error { close(eventReceived); return nil }}); err != nil { - t.Fatal(err) - } - _, err := ws.HandleWire(serverBinding, []string{"wait"}, func(ctx context.Context, _ json.RawMessage) (any, error) { - close(started) - <-ctx.Done() - return nil, ctx.Err() - }) - if err != nil { - t.Fatal(err) - } - sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 1)} - address := &duplex.ReturnAddress{Wire: sink} - wire := client.Wire() - if err := wire.Send([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage("{}")}, Return: address}); err != nil { - t.Fatal(err) - } - receive(t, started) - if err := wire.Send([]string{"wait"}, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: "c:1"}, Return: address}); err != nil { - t.Fatal(err) - } - if err := ws.EmitWire(context.Background(), wire, []string{"after"}, nil); err != nil { - t.Fatal(err) - } - receive(t, sink.replies) - receive(t, eventReceived) - var kinds []string - for _, event := range observed.all() { - if sent, ok := event.(ws.FrameSent); ok { - kinds = append(kinds, sent.Kind) - } - } - if !reflect.DeepEqual(kinds, []string{"request", "cancel", "event"}) { - t.Fatalf("send order = %v", kinds) - } -} - -func TestMountedWireKeepsIndependentOriginsAndCancellation(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - started := make(chan string, 2) - finished := make(chan string, 2) - allow := make(chan struct{}) - defer close(allow) - serverBinding := testBinding(t, server.Wire()) - _, err := ws.HandleWire(serverBinding, []string{"worker", "run"}, func(ctx context.Context, raw json.RawMessage) (any, error) { - var name string - if err := json.Unmarshal(raw, &name); err != nil { - return nil, err - } - started <- name - select { - case <-ctx.Done(): - finished <- name - return nil, ctx.Err() - case <-allow: - return name, nil - } - }) - if err != nil { - t.Fatal(err) - } - // Both views select the same existing carrier. Each CallWire has its own - // local return address, so cancelling one cannot cancel the other's id. - wire := duplex.At(duplex.Mount(map[string]duplex.Endpoint{"service": client.Wire()}), []string{"service", "worker"}) - first, cancelFirst := context.WithCancel(context.Background()) - second, cancelSecond := context.WithCancel(context.Background()) - defer cancelFirst() - defer cancelSecond() - var calls sync.WaitGroup - results := make(chan error, 2) - for _, call := range []struct { - ctx context.Context - name string - }{{first, "first"}, {second, "second"}} { - calls.Add(1) - go func() { - defer calls.Done() - results <- ws.CallWire(call.ctx, wire, []string{"run"}, call.name, nil) - }() - } - receive(t, started) - receive(t, started) - cancelFirst() - if err := receive(t, results); !errors.Is(err, context.Canceled) { - t.Fatalf("first cancellation = %v", err) - } - if name := receive(t, finished); name != "first" { - t.Fatalf("cancelled %q, want first", name) - } - var echoed string - _, err = ws.HandleWire(serverBinding, []string{"worker", "echo"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) - if err != nil { - t.Fatal(err) - } - if err := ws.CallWire(context.Background(), wire, []string{"echo"}, "still open", &echoed); err != nil || echoed != "still open" { - t.Fatalf("sibling call after cancellation = %q, %v", echoed, err) - } - cancelSecond() - if err := receive(t, results); !errors.Is(err, context.Canceled) { - t.Fatalf("second cancellation = %v", err) - } - if name := receive(t, finished); name != "second" { - t.Fatalf("second cancellation reached %q", name) - } - calls.Wait() -} - -func TestWirePathPreservesOpaqueSegmentsOverTheExistingEnvelope(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - serverBinding := testBinding(t, server.Wire()) - for _, route := range []struct { - path []string - want string - }{{[]string{"a.b"}, "one segment"}, {[]string{"a", "b"}, "two segments"}, {[]string{""}, "empty segment"}} { - _, err := ws.HandleWire(serverBinding, route.path, func(context.Context, json.RawMessage) (any, error) { return route.want, nil }) - if err != nil { - t.Fatal(err) - } - } - for _, route := range []struct { - path []string - want string - }{{[]string{"a.b"}, "one segment"}, {[]string{"a", "b"}, "two segments"}, {[]string{""}, "empty segment"}} { - var got string - if err := ws.CallWire(context.Background(), client.Wire(), route.path, nil, &got); err != nil || got != route.want { - t.Fatalf("path %q = %q, %v; want %q", route.path, got, err, route.want) - } - if err := ws.CallWire(context.Background(), duplex.At(client.Wire(), route.path), nil, nil, &got); err != nil || got != route.want { - t.Fatalf("selected leaf %q = %q, %v; want %q", route.path, got, err, route.want) - } - } -} diff --git a/dispatch/go/wire_validation_test.go b/dispatch/go/wire_validation_test.go deleted file mode 100644 index 6cd11a9..0000000 --- a/dispatch/go/wire_validation_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" -) - -func TestWireRefusesMalformedFramesBeforeDispatch(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{MaxFrameBytes: 256}) - invoked := 0 - serverBinding := testBinding(t, server.Wire()) - _, err := ws.HandleWire(serverBinding, []string{"echo"}, func(_ context.Context, raw json.RawMessage) (any, error) { - invoked++ - return raw, nil - }) - if err != nil { - t.Fatal(err) - } - sink := &wireReplySink{replies: make(chan duplex.ProfileFrame, 30)} - address := &duplex.ReturnAddress{Wire: sink} - valid := duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: "c:1", Params: json.RawMessage("{}")} - for name, change := range map[string]func(*duplex.ProfileFrame){ - "version": func(f *duplex.ProfileFrame) { f.Version = 0 }, - "id": func(f *duplex.ProfileFrame) { f.ID = "unscoped" }, - "zero id": func(f *duplex.ProfileFrame) { f.ID = "c:0" }, - "missing params": func(f *duplex.ProfileFrame) { f.Params = nil }, - "foreign member": func(f *duplex.ProfileFrame) { f.Result = json.RawMessage("null") }, - "trace": func(f *duplex.ProfileFrame) { f.Traceparent = "invalid" }, - "reserved metadata": func(f *duplex.ProfileFrame) { f.Meta = map[string]string{"nightseam.future": "value"} }, - "oversize": func(f *duplex.ProfileFrame) { f.Params, _ = json.Marshal(strings.Repeat("x", 300)) }, - } { - t.Run(name, func(t *testing.T) { - frame := valid - change(&frame) - if err := client.Wire().Send([]string{"echo"}, duplex.Message{Frame: frame, Return: address}); err == nil { - t.Errorf("malformed frame admitted") - } - }) - } - var result string - if err := ws.CallWire(context.Background(), client.Wire(), []string{"echo"}, "still usable", &result); err != nil || result != "still usable" { - t.Fatalf("healthy call = %q, %v", result, err) - } - if invoked != 1 { - t.Fatalf("handler invoked %d times, want only the valid call", invoked) - } -} - -func TestWireSanitizesMalformedPublicErrors(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - serverBinding := testBinding(t, server.Wire()) - for _, value := range []*ws.PublicError{nil, {Code: ""}, {Code: "bad", Message: ""}} { - detach, err := ws.HandleWire(serverBinding, []string{"fail"}, func(context.Context, json.RawMessage) (any, error) { return nil, value }) - if err != nil { - t.Fatal(err) - } - err = ws.CallWire(context.Background(), client.Wire(), []string{"fail"}, nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public == nil || public.Code != "internal" { - t.Errorf("malformed public error = %v", err) - } - detach() - } -} diff --git a/engine/go/backpressure_test.go b/engine/go/backpressure_test.go deleted file mode 100644 index acbe88d..0000000 --- a/engine/go/backpressure_test.go +++ /dev/null @@ -1,344 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net" - "net/http" - "net/http/httptest" - "sync" - "sync/atomic" - "testing" - "time" - - ws "github.com/Bitspark/nightseam/runtime/go" -) - -// Delaying actual TCP writes makes producer/transport imbalance reproducible -// without depending on the OS socket-buffer size or a stopped remote reader. -type delayedWriteControl struct { - enabled atomic.Bool - delay time.Duration - started chan struct{} - gate <-chan struct{} - once sync.Once -} - -type delayedWriteListener struct { - net.Listener - control *delayedWriteControl -} - -func (l delayedWriteListener) Accept() (net.Conn, error) { - conn, err := l.Listener.Accept() - if err != nil { - return nil, err - } - return &delayedWriteConn{Conn: conn, control: l.control}, nil -} - -type delayedWriteConn struct { - net.Conn - control *delayedWriteControl -} - -func (c *delayedWriteConn) Write(data []byte) (int, error) { - if c.control.enabled.Load() { - c.control.once.Do(func() { close(c.control.started) }) - if c.control.gate != nil { - <-c.control.gate - } - time.Sleep(c.control.delay) - } - return c.Conn.Write(data) -} - -func delayedWritePair(t *testing.T, control *delayedWriteControl, serverOptions, clientOptions ws.Options, clientWriteControl ...*delayedWriteControl) (*ws.Peer, *ws.Peer) { - t.Helper() - connected := make(chan *ws.Peer, 1) - handler, err := ws.NewHandler(ws.ServerOptions{ - Options: serverOptions, - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - OnConnect: func(peer *ws.Peer) { - // The HTTP upgrade has been flushed before introducing write delay. - control.enabled.Store(true) - connected <- peer - }, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(handler) - server.Listener = delayedWriteListener{Listener: server.Listener, control: control} - server.Start() - t.Cleanup(server.Close) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - t.Cleanup(cancel) - dialOptions := ws.DialOptions{Options: clientOptions} - if len(clientWriteControl) != 0 { - transport := &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { - conn, err := (&net.Dialer{}).DialContext(ctx, network, address) - if err != nil { - return nil, err - } - return &delayedWriteConn{Conn: conn, control: clientWriteControl[0]}, nil - }} - t.Cleanup(transport.CloseIdleConnections) - dialOptions.HTTPClient = &http.Client{Transport: transport} - } - client, _, err := ws.Dial(ctx, server.URL, dialOptions) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = client.Close() }) - remote := receive(t, connected) - t.Cleanup(func() { _ = remote.Close() }) - return client, remote -} - -func TestOutboundQueueDeliversAcceptedPrefixInOrder(t *testing.T) { - for _, capacity := range []int{2, 8} { - t.Run(fmt.Sprintf("capacity_%d", capacity), func(t *testing.T) { - release := make(chan struct{}) - var releaseOnce sync.Once - allowWrites := func() { releaseOnce.Do(func() { close(release) }) } - defer allowWrites() - received := make(chan int, capacity+1) - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - client, server := delayedWritePair(t, control, ws.Options{ - QueueCapacity: capacity, - WriteTimeout: 5 * time.Second, - Handlers: map[string]ws.Handler{ - "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { - return data, nil - }, - }, - }, ws.Options{Events: map[string]ws.EventHandler{ - "replay.item": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { - var sequence int - if err := json.Unmarshal(data, &sequence); err != nil { - t.Error(err) - sequence = -1 - } - received <- sequence - }, - }}) - if err := server.Emit(context.Background(), "replay.item", 0); err != nil { - t.Fatal(err) - } - // Hold the transport's current write, then fill precisely the bounded - // handoff. Each successful emit has been accepted without a reader. - receive(t, control.started) - for sequence := 1; sequence <= capacity; sequence++ { - if err := server.Emit(context.Background(), "replay.item", sequence); err != nil { - t.Fatalf("accepted prefix item %d: %v", sequence, err) - } - } - allowWrites() - for want := range capacity + 1 { - if got := receive(t, received); got != want { - t.Fatalf("accepted sequence = %d, want %d", got, want) - } - } - var echo string - if err := client.Call(context.Background(), "echo", "still connected", &echo); err != nil || echo != "still connected" { - t.Fatalf("echo after accepted prefix = %q, error=%v", echo, err) - } - if client.Err() != nil || server.Err() != nil { - t.Fatalf("accepted prefix disconnected peers: client=%v server=%v", client.Err(), server.Err()) - } - }) - } -} - -func TestOutboundQueuePreCancelledSendDoesNotDisconnect(t *testing.T) { - release := make(chan struct{}) - var releaseOnce sync.Once - allowWrites := func() { releaseOnce.Do(func() { close(release) }) } - defer allowWrites() - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - received := make(chan int, 4) - client, server := delayedWritePair(t, control, ws.Options{ - QueueCapacity: 2, - WriteTimeout: 5 * time.Second, - Handlers: map[string]ws.Handler{ - "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { return data, nil }, - }, - }, ws.Options{Events: map[string]ws.EventHandler{ - "progress": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { - var value int - if err := json.Unmarshal(data, &value); err != nil { - received <- -1 - return - } - received <- value - }, - }}) - if err := server.Emit(context.Background(), "progress", 0); err != nil { - t.Fatal(err) - } - // Hold the first real write until the cancellation assertion is complete. This - // guarantees the two queued frames cannot drain, even on a heavily loaded host. - receive(t, control.started) - for _, value := range []int{1, 2} { - if err := server.Emit(context.Background(), "progress", value); err != nil { - t.Fatal(err) - } - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - err := server.Emit(ctx, "progress", 999) - if !errors.Is(err, context.Canceled) { - t.Fatalf("pre-cancelled send = %v, want context canceled", err) - } - if client.Err() != nil || server.Err() != nil { - t.Fatalf("unadmitted cancellation disconnected peers: client=%v server=%v", client.Err(), server.Err()) - } - allowWrites() - for want := range 3 { - if got := receive(t, received); got != want { - t.Fatalf("queued event = %d, want %d", got, want) - } - } - if err := server.Emit(context.Background(), "progress", 3); err != nil { - t.Fatal(err) - } - if got := receive(t, received); got != 3 { - t.Fatalf("cancelled event was published: received %d before marker 3", got) - } - var echo string - if err := client.Call(context.Background(), "echo", "alive", &echo); err != nil || echo != "alive" { - t.Fatalf("echo after cancelled enqueue = %q, error=%v", echo, err) - } -} - -func TestOutboundQueueDoesNotDelayCancellationOfSentCall(t *testing.T) { - release := make(chan struct{}) - var releaseOnce sync.Once - allowWrites := func() { releaseOnce.Do(func() { close(release) }) } - defer allowWrites() - serverControl := &delayedWriteControl{started: make(chan struct{})} - clientControl := &delayedWriteControl{started: make(chan struct{}), gate: release} - handlerStarted := make(chan struct{}) - client, server := delayedWritePair(t, serverControl, ws.Options{Handlers: map[string]ws.Handler{ - "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - close(handlerStarted) - <-ctx.Done() - return nil, ctx.Err() - }, - }}, ws.Options{QueueCapacity: 2, WriteTimeout: 5 * time.Second}, clientControl) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - returned := make(chan error, 1) - go func() { returned <- client.Call(ctx, "wait", nil, nil) }() - // Only introduce congestion after the request has reached its handler. - receive(t, handlerStarted) - clientControl.enabled.Store(true) - if err := client.Emit(context.Background(), "progress", 0); err != nil { - t.Fatal(err) - } - receive(t, clientControl.started) - for _, value := range []int{1, 2} { - if err := client.Emit(context.Background(), "progress", value); err != nil { - t.Fatal(err) - } - } - cancel() - select { - case err := <-returned: - if !errors.Is(err, context.Canceled) { - t.Fatalf("sent call cancellation = %v, want context canceled", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("caller cancellation waited for space to enqueue the best-effort cancellation frame") - } - if client.Err() != nil || server.Err() != nil { - t.Fatalf("call cancellation disconnected peers: client=%v server=%v", client.Err(), server.Err()) - } - // Delivery of the remote cancellation is intentionally not required when the - // output queue is full. Connection cleanup releases the waiting handler. - allowWrites() -} - -// TestInboundEventBurstIsPacedRatherThanDisconnected: a queue filled faster -// than its consumer drains it is a burst, not a stall, and the peer pacing it -// is what tells them apart โ€” the events are delivered, in order, and the -// connection is whole. Before the queue was paced this ended the connection -// on the third event. -func TestInboundEventBurstIsPacedRatherThanDisconnected(t *testing.T) { - release := make(chan struct{}) - delivered := make(chan int, 8) - client, server := newPair(t, ws.Options{}, ws.Options{ - QueueCapacity: 1, - WriteTimeout: 5 * time.Second, - Events: map[string]ws.EventHandler{ - "progress": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { - <-release - var value int - if err := json.Unmarshal(data, &value); err != nil { - t.Error(err) - return - } - delivered <- value - }, - }, - }) - for _, value := range []int{1, 2, 3} { - if err := server.Emit(context.Background(), "progress", value); err != nil { - t.Fatal(err) - } - } - // The burst is held while the consumer is busy and drains when it is not. - close(release) - for want := 1; want <= 3; want++ { - if got := receive(t, delivered); got != want { - t.Fatalf("event %d arrived where %d was due", got, want) - } - } - select { - case <-client.Done(): - t.Fatalf("a burst that drained ended the connection: %v", client.Err()) - default: - } -} - -// TestOutstandingCallLimitRefusesWithoutEndingTheConnection: the caller's own -// bound. The call past it is refused busy where it stands โ€” no frame, no -// request an observer is told of โ€” and the connection serves the next call, -// which is what makes it a refusal and not a failure. -func TestOutstandingCallLimitRefusesWithoutEndingTheConnection(t *testing.T) { - started := make(chan struct{}, 4) - client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - started <- struct{}{} - <-ctx.Done() - return nil, ctx.Err() - }, - "echo": func(_ context.Context, _ *ws.Peer, params json.RawMessage) (any, error) { return params, nil }, - }}, ws.Options{MaxPendingRequests: 2}) - - ctx, cancel := context.WithCancel(context.Background()) - var waiting sync.WaitGroup - for range 2 { - waiting.Add(1) - go func() { defer waiting.Done(); _ = client.Call(ctx, "wait", nil, nil) }() - } - receive(t, started) - receive(t, started) - - err := client.Call(context.Background(), "wait", nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatalf("the call past the limit ended with %v, not busy", err) - } - - cancel() - waiting.Wait() - var echoed int - if err := client.Call(context.Background(), "echo", 7, &echoed); err != nil || echoed != 7 { - t.Fatalf("the connection did not serve on: %v, %d", err, echoed) - } -} diff --git a/engine/go/carriage_test.go b/engine/go/carriage_test.go deleted file mode 100644 index d464ee6..0000000 --- a/engine/go/carriage_test.go +++ /dev/null @@ -1,341 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" -) - -// The carriage a request and an event may take: what is about the call rather -// than the call. The peer accepts it and keeps it on the decoded frame, and -// sends what WithMeta placed on the sending context โ€” never one of its own. - -// TestMetaIsKeptOnTheDecodedFrame: a frame of each kind that may carry meta -// decodes, and the member reaches the frame verbatim rather than being read -// and dropped. -func TestMetaIsKeptOnTheDecodedFrame(t *testing.T) { - for _, test := range []struct { - name string - frame string - meta map[string]string - }{ - {"request", `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"tenant":"acme","idempotency":"k-1"}}`, - map[string]string{"tenant": "acme", "idempotency": "k-1"}}, - {"request with an empty carriage", `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{}}`, - map[string]string{}}, - {"event", `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"cause":"nightly"}}`, - map[string]string{"cause": "nightly"}}, - {"event beside a trace", `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"tenant":"acme"},` + - `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}`, - map[string]string{"tenant": "acme"}}, - } { - t.Run(test.name, func(t *testing.T) { - f, err := decodeFrame([]byte(test.frame)) - if err != nil { - t.Fatalf("a frame carrying meta was refused: %v", err) - } - if !reflect.DeepEqual(f.Meta, test.meta) { - t.Fatalf("meta = %v, want %v", f.Meta, test.meta) - } - }) - } - // A frame carrying none leaves the member absent rather than empty, so the - // emitting half can tell a carriage with nothing in it from no carriage. - f, err := decodeFrame([]byte(`{"version":1,"kind":"request","id":"c:1","method":"read","params":{}}`)) - if err != nil || f.Meta != nil { - t.Fatalf("a frame carrying no meta = %v, %v", f.Meta, err) - } -} - -// TestMetaIsRefusedInEveryOtherForm: the kinds that may not carry it, the -// forms that are not an object of strings, and the keys the profile keeps. -func TestMetaIsRefusedInEveryOtherForm(t *testing.T) { - for _, frame := range []string{ - // A response says what it says in its result; a cancel withdraws a call - // rather than making one. - `{"version":1,"kind":"response","id":"s:1","result":1,"meta":{"tenant":"acme"}}`, - `{"version":1,"kind":"response","id":"s:1","error":{"code":"busy","message":"Try later"},"meta":{"tenant":"acme"}}`, - `{"version":1,"kind":"cancel","id":"c:1","meta":{"tenant":"acme"}}`, - // An object of strings, and nothing else. - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":"acme"}`, - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":["acme"]}`, - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":7}`, - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":null}`, - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"attempt":2}}`, - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"tenant":null}}`, - `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"live":true}}`, - `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"who":{"id":"u1"}}}`, - // The namespace the profile keeps for itself, which it fills with - // nothing in this version. - `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"nightseam.deadline":"2026-01-01T00:00:00Z"}}`, - `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"nightseam.cause":"nightly"}}`, - } { - t.Run(frame, func(t *testing.T) { - if _, err := decodeFrame([]byte(frame)); err == nil { - t.Fatal("a frame the profile does not admit was accepted") - } - }) - } -} - -// TestTheConformanceTableIsJudgedAsItJudges: every row of tables/frames.json, -// held the way the peer holds a frame it is handed โ€” the envelope decoded and -// the id held to the prefix its kind carries โ€” so that the two runtimes and -// the suite read one description of the wire, this one. -func TestTheConformanceTableIsJudgedAsItJudges(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "conformance", "tables", "frames.json")) - if err != nil { - t.Fatal(err) - } - var table struct { - Rows []struct { - Name string - To string - Frame string - Valid bool - } - } - if err := json.Unmarshal(data, &table); err != nil { - t.Fatal(err) - } - carriages := 0 - for _, row := range table.Rows { - // A row addressed to the server carries the client's ids and answers - // the server's; one addressed to either is read as a server's. - local, remote := "s:", "c:" - if row.To == "client" { - local, remote = "c:", "s:" - } - f, err := decodeFrame([]byte(row.Frame)) - accepted := err == nil - if accepted && f.ID != "" { - prefix := remote - if f.Kind == "response" { - prefix = local - } - accepted = validID(f.ID, prefix) - } - if accepted != row.Valid { - t.Errorf("%s: valid=%v, decode error %v", row.Name, row.Valid, err) - } - var members map[string]json.RawMessage - if json.Unmarshal([]byte(row.Frame), &members) == nil { - if _, carried := members["meta"]; carried { - carriages++ - } - } - } - if len(table.Rows) < 70 || carriages < 12 { - t.Fatalf("the table holds %d rows and names meta in %d; the wire is held by more than that", len(table.Rows), carriages) - } -} - -// TestAFrameWithARefusedMetaEndsTheConnection: the refusal is the profile's -// own close, 4011, as any malformed frame is. The peer aborts rather than -// closing with a handshake, so the code this side decided on is what the -// observer is told, and the connection is dead either way. -func TestAFrameWithARefusedMetaEndsTheConnection(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - far, near := duplex.Pipe(1 << 20) - closes := make(chan ConnectionClosed, 1) - peer, err := NewPeer(ctx, near, ServerRole, Options{Observer: observerFunc(func(event ObserverEvent) { - if closed, ok := event.(ConnectionClosed); ok { - closes <- closed - } - })}) - if err != nil { - t.Fatal(err) - } - defer func() { _ = peer.Close() }() - frame := `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"nightseam.cause":"nightly"}}` - if err := far.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte(frame)}); err != nil { - t.Fatal(err) - } - select { - case closed := <-closes: - if closed.Code != int(duplex.CodeDuplex) || !closed.Local { - t.Fatalf("the connection closed with %d local=%v, want %d local", closed.Code, closed.Local, int(duplex.CodeDuplex)) - } - case <-ctx.Done(): - t.Fatal("a frame the profile does not admit left the connection open") - } - if peer.Err() == nil { - t.Fatal("the peer ended without an error") - } -} - -// TestMetaTravelsFromTheContextToTheFrame: what WithMeta said reaches the -// request and the event sent from that context, and a context that said -// nothing carries the member nowhere. -func TestMetaTravelsFromTheContextToTheFrame(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - far, near := duplex.Pipe(1 << 20) - peer, err := NewPeer(ctx, near, ClientRole, Options{}) - if err != nil { - t.Fatal(err) - } - defer func() { _ = peer.Close() }() - carried := Meta{"tenant": "acme", "idempotency": "k-1"} - // The call waits for a response nobody sends; the frame it sent is the - // assertion, and the test's own context releases it at the end โ€” cancelling - // it here would put a cancel frame between the reads below. - go func() { _ = peer.Call(WithMeta(ctx, carried), "read", nil, nil) }() - if meta := metaOfNextFrame(ctx, t, far); !reflect.DeepEqual(meta, carried) { - t.Fatalf("the request carried meta %v, want %v", meta, carried) - } - if err := peer.Emit(WithMeta(ctx, Meta{"cause": "nightly"}), "updated", 1); err != nil { - t.Fatal(err) - } - if meta := metaOfNextFrame(ctx, t, far); !reflect.DeepEqual(meta, Meta{"cause": "nightly"}) { - t.Fatalf("the event carried meta %v", meta) - } - // A context that said nothing sends the member nowhere: absent, not empty. - if err := peer.Emit(ctx, "updated", 1); err != nil { - t.Fatal(err) - } - if meta := metaOfNextFrame(ctx, t, far); meta != nil { - t.Fatalf("an event from a bare context carried meta %v", meta) - } - // A key of the reserved prefix is the profile's; WithMeta drops it rather - // than sending a frame the far peer would refuse. - if err := peer.Emit(WithMeta(ctx, Meta{"nightseam.cause": "nightly", "tenant": "acme"}), "updated", 1); err != nil { - t.Fatal(err) - } - if meta := metaOfNextFrame(ctx, t, far); !reflect.DeepEqual(meta, Meta{"tenant": "acme"}) { - t.Fatalf("a reserved key reached the wire: %v", meta) - } -} - -// metaOfNextFrame is the meta of the next frame the far side of a pipe reads, -// and nil where the frame carried none. -func metaOfNextFrame(ctx context.Context, t *testing.T, conn duplex.Conn) Meta { - t.Helper() - frame, err := conn.Receive(ctx) - if err != nil { - t.Fatalf("receive: %v", err) - } - var members struct { - Meta Meta `json:"meta"` - } - if err := json.Unmarshal(frame.Data, &members); err != nil { - t.Fatalf("decode %s: %v", frame.Data, err) - } - return members.Meta -} - -// TestAHandlerReadsItsMetaAndForwardsNothingOfItself: a carriage reaches the -// handler of the frame that carried it, and goes no further on its own โ€” a -// trace is the peer's to propagate and a credential is not, so a handler that -// means to forward one says WithMeta(ctx, MetaFrom(ctx)). -func TestAHandlerReadsItsMetaAndForwardsNothingOfItself(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - clientConn, serverConn := duplex.Pipe(1 << 20) - nested := make(chan Meta, 1) - events := make(chan Meta, 1) - server, err := NewPeer(ctx, serverConn, ServerRole, Options{ - Handlers: map[string]Handler{ - // Reads its own meta, then calls back without saying to forward it. - "read": func(ctx context.Context, p *Peer, _ json.RawMessage) (any, error) { - mine := MetaFrom(ctx) - var back string - if err := p.Call(ctx, "reverse", nil, &back); err != nil { - return nil, err - } - return mine, nil - }, - // Reads its own meta, then forwards it as a handler must say to. - "relay": func(ctx context.Context, p *Peer, _ json.RawMessage) (any, error) { - var back string - return back, p.Call(WithMeta(ctx, MetaFrom(ctx)), "reverse", nil, &back) - }, - }, - Events: map[string]EventHandler{ - "updated": func(ctx context.Context, _ *Peer, _ json.RawMessage) { events <- MetaFrom(ctx) }, - }, - }) - if err != nil { - t.Fatal(err) - } - defer func() { _ = server.Close() }() - client, err := NewPeer(ctx, clientConn, ClientRole, Options{Handlers: map[string]Handler{ - "reverse": func(ctx context.Context, _ *Peer, _ json.RawMessage) (any, error) { - nested <- MetaFrom(ctx) - return "back", nil - }, - }}) - if err != nil { - t.Fatal(err) - } - defer func() { _ = client.Close() }() - - carried := Meta{"tenant": "acme"} - var seen Meta - if err := client.Call(WithMeta(ctx, carried), "read", nil, &seen); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(seen, carried) { - t.Fatalf("the handler read meta %v, want %v", seen, carried) - } - if forwarded := receiveMeta(ctx, t, nested); forwarded != nil { - t.Fatalf("a call from the handler carried the caller's meta %v of its own accord", forwarded) - } - if err := client.Call(WithMeta(ctx, carried), "relay", nil, nil); err != nil { - t.Fatal(err) - } - if forwarded := receiveMeta(ctx, t, nested); !reflect.DeepEqual(forwarded, carried) { - t.Fatalf("a handler that said to forward carried %v, want %v", forwarded, carried) - } - // An event's handler reads its event's carriage across the bounded queue. - if err := client.Emit(WithMeta(ctx, Meta{"cause": "nightly"}), "updated", 1); err != nil { - t.Fatal(err) - } - if got := receiveMeta(ctx, t, events); !reflect.DeepEqual(got, Meta{"cause": "nightly"}) { - t.Fatalf("the event handler read meta %v", got) - } - // A handler of a frame that carried none reads nil, not an empty carriage. - if err := client.Emit(ctx, "updated", 1); err != nil { - t.Fatal(err) - } - if got := receiveMeta(ctx, t, events); got != nil { - t.Fatalf("a handler of a bare event read meta %v", got) - } -} - -func receiveMeta(ctx context.Context, t *testing.T, from <-chan Meta) Meta { - t.Helper() - select { - case meta := <-from: - return meta - case <-ctx.Done(): - t.Fatal("nothing arrived") - return nil - } -} - -// TestMetaFromIsACopy: what a handler writes into what it read reaches no -// frame and no other handler. -func TestMetaFromIsACopy(t *testing.T) { - carried := Meta{"tenant": "acme"} - ctx := withIncomingMeta(context.Background(), carried) - mine := MetaFrom(ctx) - mine["tenant"] = "other" - if MetaFrom(ctx)["tenant"] != "acme" { - t.Fatal("a handler's write reached the frame's carriage") - } - if MetaFrom(context.Background()) != nil { - t.Fatal("a context no frame ran carries a carriage") - } -} - -type observerFunc func(ObserverEvent) - -func (f observerFunc) Observe(event ObserverEvent) { f(event) } diff --git a/engine/go/peer.go b/engine/go/peer.go index 88f69c1..2514674 100644 --- a/engine/go/peer.go +++ b/engine/go/peer.go @@ -1,18 +1,20 @@ -// Package runtime implements the nightseam.duplex/1 profile over a frames -// duplex connection (duplex): JSON text frames carrying requests, -// responses, events and cancellations. It never touches a WebSocket; Dial -// and Accept open one and hand it over as a connection, and a Peer over any -// other transport speaks the same profile byte for byte. It has no -// application authorization, replay, retries, or persistence policy. -package runtime +// Package engine implements the bitwire/1 protocol over a frames duplex +// connection: JSON text frames carrying requests, responses, events and +// cancellations, correlated by serial request identifiers. A Peer realizes an +// Endpoint over any transport of the seam; the addressed path of a request or +// event travels as its canonical encoding in the frame's method or event +// name. It has no application authorization, replay, retries or persistence +// policy. +// +// bitwire/1 is the behavior of Nightseam v0.6.0's nightseam.duplex/1 profile +// (Bitwire decision 0008). This engine accepts, refuses and sends what that +// release does; it presents the protocol only through its root Endpoint. +package engine import ( - "bytes" "context" "encoding/json" "errors" - "fmt" - "io" "runtime" "strconv" "strings" @@ -20,100 +22,69 @@ import ( "time" "unicode/utf8" - "github.com/Bitspark/nightseam/duplex/go" - "github.com/Bitspark/nightseam/internal/scalarjson" + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + "github.com/Bitspark/bitruntime/internal/request/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" ) -// Profile names what this package speaks: JSON text frames carrying requests, -// responses, events and cancellations both ways over a connection of the seam. -// docs/wire/profile.md is its specification. -const Profile = "nightseam.duplex/1" +// Protocol names the protocol revision this engine speaks. The name never +// travels on a connection: bitwire/1 agrees on its revision out of band. +const Protocol = "bitwire/1" // Role is the side of a connection a peer takes. It decides the prefix of the -// request ids the peer mints โ€” c: for a client, s: for a server โ€” so the two -// sides never mint the same id, and a tunnel over the peer chooses its channel -// ids by it. +// request identifiers the peer mints โ€” c: for a client, s: for a server โ€” so +// the two sides never mint the same one. type Role string const ( - // ClientRole dials and mints request ids under the c: prefix. + // ClientRole dials and mints request identifiers under the c: prefix. ClientRole Role = "client" - // ServerRole accepts and mints request ids under the s: prefix. + // ServerRole accepts and mints request identifiers under the s: prefix. ServerRole Role = "server" ) -// The errors a peer ends with: ErrClosed when Close was called or the -// connection ended, ErrBackpressure when a queue stayed full past its write -// deadline โ€” a consumer that does not drain is disconnected rather than -// allowed to hold the connection up. Both reach Err and every pending call. -var ( - ErrClosed = errors.New("duplex connection closed") - ErrBackpressure = errors.New("duplex consumer is stalled") -) - -// PublicError is safe to send to the remote caller. Other handler errors are -// replaced by a generic internal error; their messages are not disclosed. -type PublicError struct { - Code string `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data,omitempty"` -} - -// Error is the code and the message, as a log line shows them. -func (e *PublicError) Error() string { return e.Code + ": " + e.Message } - -// Handler answers one request: it takes the params as they arrived and -// returns the result, or an error โ€” a *PublicError crosses the wire with its -// code; any other error reaches the caller as internal. The context is -// cancelled when the caller withdraws the request or its deadline passes, and -// carries the trace and the meta the frame brought. -type Handler func(context.Context, *Peer, json.RawMessage) (any, error) - -// EventHandler takes one event's data; events have no answer. Handlers run -// one at a time in the order the events arrived. -type EventHandler func(context.Context, *Peer, json.RawMessage) - -// Event is one event of the profile as a handler or an emitter sees it: its -// name and its data. -type Event struct { - Name string `json:"event"` - Data json.RawMessage `json:"data"` -} - -// Options limits are per connection. Zero values select the documented defaults. -// Handlers may run concurrently; event callbacks run serially in receive order. +// Options limits are per connection. Zero values select the defaults. +// Handlers may run concurrently; event deliveries run serially in receive +// order. type Options struct { - Handlers map[string]Handler - Events map[string]EventHandler // Prepare runs on the peer once it is built and before it reads its - // first frame: what it installs โ€” Handle, HandleEvent, a tunnel over the - // peer โ€” is there before anything can arrive, so the other side's first - // request cannot be refused method_not_found by a peer whose handlers - // are still on their way. Install in Prepare, use in OnConnect, which - // runs on a peer that is already live. An error fails the construction: - // the peer never runs and the constructor answers with it. - Prepare func(*Peer) error + // first frame: a receiver it attaches to the peer's Wire is there before + // anything can arrive, so the other side's first request cannot be + // refused method_not_found while the receiver is on its way. An error + // fails the construction: the peer never runs and the constructor answers + // with it. + Prepare func(*Peer) error + // MaxConcurrentHandlers bounds the incoming requests running at once; the + // one past it is refused busy. Default 64. MaxConcurrentHandlers int // MaxPendingRequests bounds the calls this peer may have outstanding at - // once; the one past it is refused busy without reaching the wire. It is - // the caller's own bound, as MaxConcurrentHandlers is the receiver's. + // once; the one past it is refused busy without reaching the wire. It + // also bounds the outgoing requests its root holds admitted. Default 128. MaxPendingRequests int - QueueCapacity int - MaxFrameBytes int64 - RequestTimeout time.Duration - WriteTimeout time.Duration - Propagator Propagator - // Observer is told what the peer does with the traffic it carries; nil - // observes nothing and costs nothing. Families labels a method or event - // name with the family it belongs to, which the generated install fills: - // an unlabelled name has no family, and the runtime parses none. - Observer Observer - Families map[string]string -} - -func (o Options) normalized() (Options, error) { + // QueueCapacity bounds the outgoing frame queue, the incoming event queue + // and the root's own queue, in frames. Default 128. + QueueCapacity int + // MaxFrameBytes is the largest frame the peer sends or receives. Default + // 1 MiB. + MaxFrameBytes int64 + // RequestTimeout bounds an outgoing call and an incoming request's + // handler. Default 30 seconds. + RequestTimeout time.Duration + // WriteTimeout bounds a stalled write and a stalled consumer before the + // connection ends. Default 10 seconds. + WriteTimeout time.Duration + // Propagator moves a trace between a frame and a handler's context. + Propagator core.Propagator +} + +// Normalized returns the options with every default applied, or an error for +// a negative limit. +func (o Options) Normalized() (Options, error) { if o.MaxConcurrentHandlers < 0 || o.MaxPendingRequests < 0 || o.QueueCapacity < 0 || o.MaxFrameBytes < 0 || o.RequestTimeout < 0 || o.WriteTimeout < 0 { - return o, errors.New("duplex limits must not be negative") + return o, errors.New("bitruntime: engine limits must not be negative") } if o.MaxConcurrentHandlers == 0 { o.MaxConcurrentHandlers = 64 @@ -134,140 +105,87 @@ func (o Options) normalized() (Options, error) { o.WriteTimeout = 10 * time.Second } if o.Propagator == nil { - o.Propagator = DefaultPropagator - } - for name, h := range o.Handlers { - if name == "" || h == nil { - return o, errors.New("invalid duplex method handler") - } - } - for name, h := range o.Events { - if name == "" || h == nil { - return o, errors.New("invalid duplex event handler") - } + o.Propagator = core.DefaultPropagator } return o, nil } -type frame struct { - Version int `json:"version"` - Kind string `json:"kind"` - ID string `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *PublicError `json:"error,omitempty"` - Event string `json:"event,omitempty"` - Data json.RawMessage `json:"data,omitempty"` - // W3C Trace Context, which every kind may carry and none requires. - Traceparent string `json:"traceparent,omitempty"` - Tracestate string `json:"tracestate,omitempty"` - // What is about a call rather than the call, which a request and an event - // may carry. The peer keeps it for what reads it above and emits none. - Meta map[string]string `json:"meta,omitempty"` -} - -// queuedFrame is one frame waiting for the writer: the bytes it will write and -// the frame they were rendered from. The two travel together so that the send -// is observed by the one goroutine that writes, immediately before the bytes -// leave โ€” one serialization point per peer, which is what makes the observer's -// events one order (docs/runtime/observer.md). +// queuedFrame is one frame waiting for the writer. type queuedFrame struct { data []byte - frame frame + frame profile.Frame } -// queuedEvent keeps an event's trace beside it across the bounded queue: the -// handler runs under the context the trace was extracted into, not under one -// the reader has already left behind. +// queuedEvent keeps an event's trace beside it across the bounded queue: its +// delivery runs under the context the trace was extracted into. type queuedEvent struct { - event Event - trace Trace - meta Meta -} - -type pendingResult struct { - result json.RawMessage - err error + name string + data json.RawMessage + trace core.Trace + meta core.Meta } // Peer owns a frames duplex connection until Close or transport failure. // Never send on or receive from the connection after handing it to NewPeer. -// The connection's receive limit is its maker's to set to MaxFrameBytes; -// the peer refuses a larger frame it is nonetheless handed. +// The connection's receive limit is its maker's to set to MaxFrameBytes; the +// peer refuses a larger frame it is nonetheless handed. type Peer struct { - wireOnce sync.Once - wire *peerWire - conn duplex.Conn - ctx context.Context - cancel context.CancelFunc - options Options - prefix string - remotePrefix string - subprotocol string - next uint64 - publish sync.Mutex - admitted uint64 - done chan struct{} - once sync.Once - mu sync.Mutex - err error - pending map[string]chan pendingResult - incoming map[string]context.CancelFunc - handlers map[string]Handler - eventHandlers map[string]EventHandler - requestFallback func(string) Handler - eventFallback func(string) EventHandler - listeners map[uint64]func(context.Context, Event) - listenerID uint64 - outputs chan queuedFrame - events chan queuedEvent - slots chan struct{} -} - -// NewPeer speaks the profile over any connection of the seam โ€” a pipe, a -// tunnel channel, a socket already accepted โ€” as the given role. The peer -// owns the connection from here and closes it when it ends; ctx ending ends -// the peer. Dial and Accept are this over a WebSocket. -func NewPeer(ctx context.Context, conn duplex.Conn, role Role, options Options) (*Peer, error) { - return newPeer(ctx, conn, role, options, "") -} - -// newPeer is NewPeer carrying what the handshake beneath selected, which only -// Accept and Dial are in a position to know; every other connection has none. -// It is set before the loops start, so Subprotocol is read without a lock. -func newPeer(ctx context.Context, conn duplex.Conn, role Role, options Options, subprotocol string) (*Peer, error) { + root *rootWire + conn transports.Conn + ctx context.Context + cancel context.CancelFunc + options Options + prefix string + remotePrefix string + subprotocol string + next uint64 + publish sync.Mutex + admitted uint64 + done chan struct{} + once sync.Once + mu sync.Mutex + err error + pending map[string]chan request.Result + incoming map[string]context.CancelFunc + outputs chan queuedFrame + events chan queuedEvent + slots chan struct{} +} + +// NewPeer speaks bitwire/1 over any connection of the seam โ€” a pipe, a +// socket already accepted, a tunnel channel โ€” as the given role. The peer owns +// the connection from here and closes it when it ends; ctx ending ends the +// peer. If the connection reports a Subprotocol, the peer reports it too. +func NewPeer(ctx context.Context, conn transports.Conn, role Role, options Options) (*Peer, error) { if ctx == nil || conn == nil { - return nil, errors.New("duplex requires a context and connection") + return nil, errors.New("bitruntime: a peer requires a context and connection") } if role != ClientRole && role != ServerRole { - return nil, errors.New("invalid duplex role") + return nil, errors.New("bitruntime: invalid peer role") } - o, err := options.normalized() + o, err := options.Normalized() if err != nil { return nil, err } + var subprotocol string + if negotiated, ok := conn.(interface{ Subprotocol() string }); ok { + subprotocol = negotiated.Subprotocol() + } ctx, cancel := context.WithCancel(ctx) p := &Peer{conn: conn, ctx: ctx, cancel: cancel, options: o, prefix: "c:", remotePrefix: "s:", subprotocol: subprotocol, done: make(chan struct{}), - pending: make(map[string]chan pendingResult), incoming: make(map[string]context.CancelFunc), handlers: make(map[string]Handler), - eventHandlers: make(map[string]EventHandler), listeners: make(map[uint64]func(context.Context, Event)), + pending: make(map[string]chan request.Result), incoming: make(map[string]context.CancelFunc), outputs: make(chan queuedFrame, o.QueueCapacity), events: make(chan queuedEvent, o.QueueCapacity), slots: make(chan struct{}, o.MaxConcurrentHandlers)} if role == ServerRole { p.prefix, p.remotePrefix = "s:", "c:" } - for k, v := range o.Handlers { - p.handlers[k] = v - } - for k, v := range o.Events { - p.eventHandlers[k] = v - } + p.root = &rootWire{peer: p, wake: make(chan struct{}, 1), incoming: map[returnKey]*routedCall{}} if o.Prepare != nil { if err := o.Prepare(p); err != nil { p.abandon(err) return nil, err } } - p.observeOpened(role) + go p.root.run() go p.readLoop() go p.writeLoop() go p.eventLoop() @@ -279,11 +197,12 @@ func newPeer(ctx context.Context, conn duplex.Conn, role Role, options Options, func (p *Peer) Done() <-chan struct{} { return p.done } // Context is the peer's own, cancelled when it ends: what a handler or a -// caller derives its own from to be released with the connection. +// caller derives its own from to be released with the connection. It carries +// the values of the context the peer was made with, such as an authenticated +// identity. func (p *Peer) Context() context.Context { return p.ctx } -// Role is the side of the connection this peer is: it prefixes the request -// ids it mints, and a tunnel over it chooses channel ids by it. +// Role is the side of the connection this peer is. func (p *Peer) Role() Role { if p.prefix == "s:" { return ServerRole @@ -291,44 +210,45 @@ func (p *Peer) Role() Role { return ClientRole } -// Subprotocol is what the WebSocket handshake beneath this peer selected, and -// "" when it selected none or the peer does not run over a WebSocket. It is -// fixed for the peer's life; the profile reads nothing into it. +// Subprotocol is what the handshake beneath this peer selected, and "" when it +// selected none. bitwire/1 reads nothing into it. func (p *Peer) Subprotocol() string { return p.subprotocol } // MaxFrameBytes is the largest frame this peer sends or receives. func (p *Peer) MaxFrameBytes() int64 { return p.options.MaxFrameBytes } -// Err is why the peer ended, or nil while it runs: ErrClosed, ErrBackpressure, -// the context's error, or the connection's own. +// Err is why the peer ended, or nil while it runs. An ended peer's error is a +// closed carrier โ€” errors.Is(err, transports.ErrClosed) โ€” and also matches its +// cause: core.ErrBackpressure, the context's error, or the connection's own. func (p *Peer) Err() error { p.mu.Lock(); defer p.mu.Unlock(); return p.err } -// Close ends the peer with ErrClosed, closing the connection beneath it with -// 1000 and failing every pending call. It is safe to call more than once. -func (p *Peer) Close() error { p.end(ErrClosed, duplex.CodeNormal, ""); return nil } +// Close ends the peer, closing the connection with 1000 and failing every +// pending call. It is safe to call more than once. +func (p *Peer) Close() error { p.end(transports.ErrClosed, transports.CodeNormal, ""); return nil } + +// Wire is the peer's root origin: send access to the remote side's paths, +// receive attachment for the requests and events the remote side sends, and +// the connection's closure. Repeated calls return the same endpoint. +func (p *Peer) Wire() wire.Endpoint { return p.root } // fail ends the peer on a transport there is nothing to say over: a write that // failed, the context ending, a consumer that stalled past its deadline. The -// connection is aborted rather than closed with a handshake nobody is left to -// answer, and the far side reads an abnormal closure. +// connection is aborted and the far side reads an abnormal closure. func (p *Peer) fail(err error) { p.end(err, codeAborted, "") } -// refuse ends the peer on a frame the profile does not admit โ€” a malformed -// envelope, an id that correlates with nothing, a frame of the wrong kind. The -// other side broke the profile and is told so, with 4011 and a reason, because -// a gateway or a proxy between the two can act on a code and can act on -// nothing at all (docs/wire/profile.md). -func (p *Peer) refuse(err error) { p.end(err, duplex.CodeDuplex, err.Error()) } +// refuse ends the peer on a frame the protocol does not admit โ€” a malformed +// envelope, an identifier that correlates with nothing, a frame of the wrong +// kind. The other side broke the protocol and is told so, with 4011 and a +// reason, because an intermediary can act on a code. +func (p *Peer) refuse(err error) { p.end(err, transports.CodeProtocol, err.Error()) } -// abandon releases a peer that never ran: Prepare failed, the loops were -// never started and nothing of the profile reached the wire, so there is -// nothing to close with a code here โ€” the connection is disposed of by the -// constructor that opened it. Whatever Prepare started before it failed sees -// the context cancelled and Done closed, as it would on any other end. +// abandon releases a peer that never ran: Prepare failed, the loops were never +// started and nothing reached the wire. The connection is disposed of by the +// constructor that opened it. func (p *Peer) abandon(err error) { p.once.Do(func() { p.mu.Lock() - p.err = err + p.err = core.Ended(err) p.mu.Unlock() p.cancel() close(p.done) @@ -337,43 +257,37 @@ func (p *Peer) abandon(err error) { // codeAborted stands for no close at all: the connection is aborted, nothing // is sent, and the far side reads 1006. -const codeAborted duplex.Code = 0 +const codeAborted transports.Code = 0 -// end ends the peer once, whatever ended it: every pending call is released, -// the connection is closed with the code this side decided on or aborted where -// there is none, and the observer is told what the wire carried. -func (p *Peer) end(err error, code duplex.Code, reason string) { +// end ends the peer once, whatever ended it: every pending call is released +// and the connection is closed with the code this side decided on, or aborted +// where there is none or the code may only be observed. +func (p *Peer) end(err error, code transports.Code, reason string) { p.once.Do(func() { - if err == nil { - err = ErrClosed - } // This outcome settles every pending request, including ones already // delivered. Another send's proof cannot be broadcast as their proof. - err = WithoutUnpublishedProof(err) + err = core.WithoutUnpublishedProof(core.Ended(err)) p.mu.Lock() p.err = err p.mu.Unlock() p.cancel() close(p.done) - if code == codeAborted { + if code == codeAborted || !transports.Sendable(code) { _ = p.conn.Abort() } else { // The peer's own context is already cancelled, so the handshake // waits on one of its own: a far side that answers is told the // code, and one that does not holds nothing up past the deadline. - reason = closeReason(reason) ctx, cancel := context.WithTimeout(context.Background(), p.options.WriteTimeout) - _ = p.conn.Close(ctx, code, reason) + _ = p.conn.Close(ctx, code, closeReason(reason)) cancel() } - p.observeClosed(err, code, reason) }) } -// closeReason is what a close frame admits: the registry bounds a reason at -// 123 bytes and requires valid UTF-8, and a transport handed a longer one -// would close with no code at all โ€” which is the one thing a refusal must not -// do. A refused frame's own text may reach it, so it is cut on a rune. +// closeReason is what a close frame admits: a reason of at most 123 bytes of +// valid UTF-8, cut on a rune, since a transport handed a longer one would +// close with no code at all. func closeReason(reason string) string { const limit = 123 if len(reason) <= limit { @@ -386,102 +300,26 @@ func closeReason(reason string) string { return reason } -// Handle registers a method. Duplicate registrations are rejected. -func (p *Peer) Handle(method string, handler Handler) error { - if method == "" || handler == nil { - return errors.New("invalid duplex method handler") - } - p.mu.Lock() - defer p.mu.Unlock() - if p.err != nil { - return p.err - } - if _, exists := p.handlers[method]; exists { - return fmt.Errorf("method %q already registered", method) - } - p.handlers[method] = handler - return nil -} - -// HandleEvent registers the handler for the event of that name, replacing -// any before it; an event with no handler is dropped. -func (p *Peer) HandleEvent(name string, handler EventHandler) error { - if name == "" || handler == nil { - return errors.New("invalid duplex event handler") - } - p.mu.Lock() - defer p.mu.Unlock() - if p.err != nil { - return p.err - } - if _, exists := p.eventHandlers[name]; exists { - return fmt.Errorf("event %q already registered", name) - } - p.eventHandlers[name] = handler - return nil -} - -// OnEvent observes every event and returns an idempotent unsubscribe function. -// Slow callbacks consume the bounded event queue and can disconnect the peer. -func (p *Peer) OnEvent(listener func(context.Context, Event)) func() { - if listener == nil { - return func() {} - } - p.mu.Lock() - p.listenerID++ - id := p.listenerID - p.listeners[id] = listener - p.mu.Unlock() - return func() { p.mu.Lock(); delete(p.listeners, id); p.mu.Unlock() } -} - -// Call sends one request and waits for its response. It never retries. A caller -// cancellation also sends best-effort cancellation to the remote handler. -func (p *Peer) Call(ctx context.Context, method string, params, result any) error { - call, err := p.beginCall(ctx, method, params) - if err != nil { - return err - } - return call.await(result) -} - type admittedCall struct { await func(any) error withdraw func() } -// beginCall performs the bounded admission synchronously. A wire dispatcher -// admits frames in its delivery order, then waits for each result separately; -// starting goroutines before admission would reorder requests and events. -func (p *Peer) beginCall(ctx context.Context, method string, params any) (*admittedCall, error) { - return p.beginCallTrace(ctx, method, params, nil, false) -} - -// A structured wire frame already carries its trace. Public Call still injects -// a child; forwarding the existing frame uses these exact members instead. -func (p *Peer) beginCallTrace(ctx context.Context, method string, params any, carried *Trace, immediate bool) (*admittedCall, error) { +// beginCall performs the bounded admission of one outgoing request +// synchronously. The root admits frames in its delivery order, then waits for +// each result separately; starting goroutines before admission would reorder +// requests and events. A structured frame already carries its trace, which the +// request keeps verbatim. +func (p *Peer) beginCall(ctx context.Context, method string, params json.RawMessage, trace core.Trace) (*admittedCall, error) { if ctx == nil || method == "" { - return nil, Unpublished(errors.New("duplex call requires context and method")) + return nil, core.Unpublished(errors.New("bitruntime: a call requires a context and method")) } ctx, cancel := context.WithTimeout(ctx, p.options.RequestTimeout) if err := ctx.Err(); err != nil { cancel() - return nil, Unpublished(err) + return nil, core.Unpublished(err) } - data, err := MarshalJSON(params) - if err != nil { - cancel() - return nil, Unpublished(err) - } - // One trace serves the request and the cancellation that may follow it: a - // cancel carries its request's members, not a sibling span of them. - var trace Trace - if carried != nil { - trace = *carried - } else { - trace = p.options.Propagator.Inject(ctx) - } - reply := make(chan pendingResult, 1) + reply := make(chan request.Result, 1) // Reservation and publication happen under the outgoing queue's one // ordering gate, so serials reach the other side in the order they were // taken. A reservation that never publishes has still spent its serial, @@ -490,44 +328,41 @@ func (p *Peer) beginCallTrace(ctx context.Context, method string, params any, ca if p.next == maxRequestSerial { p.publish.Unlock() cancel() - p.fail(errors.New("duplex request serials exhausted")) - return nil, Unpublished(&PublicError{Code: "identifier_exhausted", Message: "Create a new peer before issuing further calls"}) + p.fail(errors.New("bitruntime: request serials exhausted")) + return nil, core.Unpublished(&core.PublicError{Code: "identifier_exhausted", Message: "Create a new peer before issuing further calls"}) } p.next++ id := p.prefix + strconv.FormatUint(p.next, 10) p.mu.Lock() if p.err != nil { - err = p.err + err := p.err p.mu.Unlock() p.publish.Unlock() cancel() - return nil, Unpublished(err) + return nil, core.Unpublished(err) } - // The caller's own bound. A call past it never reaches the wire and never - // becomes an observer's request: nothing started, so nothing ended. + // The caller's own bound. A call past it never reaches the wire. if len(p.pending) >= p.options.MaxPendingRequests { p.mu.Unlock() p.publish.Unlock() cancel() - return nil, Unpublished(&PublicError{Code: "busy", Message: "Outstanding call limit reached"}) + return nil, core.Unpublished(&core.PublicError{Code: "busy", Message: "Outstanding call limit reached"}) } p.pending[id] = reply p.mu.Unlock() finish := func() { cancel(); p.mu.Lock(); delete(p.pending, id); p.mu.Unlock() } - started := p.requestStarted(id, method, false, trace) - err = p.enqueueFrame(ctx, frame{Version: 1, Kind: "request", ID: id, Method: method, Params: data, - Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)}, immediate) + err := p.enqueueFrame(ctx, profile.Frame{Version: 1, Kind: "request", ID: id, Method: method, Params: params, + Traceparent: trace.Parent, Tracestate: trace.State, Meta: delivery.OutgoingMeta(ctx)}, true) p.publish.Unlock() if err != nil { finish() - err = Unpublished(err) - p.requestEnded(started, id, method, false, trace, err) - return nil, err + return nil, core.Unpublished(err) } + // The call completes once, by its response, its deadline or the caller's + // withdrawal; only a withdrawal still owes the remote side a cancellation. var completed sync.Once - complete := func(err error, withdrawn bool) { + complete := func(withdrawn bool) { completed.Do(func() { - p.requestEnded(started, id, method, false, trace, err) if withdrawn { p.cancelRequest(id, trace) } @@ -535,44 +370,22 @@ func (p *Peer) beginCallTrace(ctx context.Context, method string, params any, ca } return &admittedCall{await: func(result any) error { defer finish() - cancelRemote, err := awaitReply(ctx, reply, p.done, p.Err, result) - complete(err, cancelRemote) + cancelRemote, err := request.Await(ctx, reply, p.done, p.Err, result) + complete(cancelRemote) return err }, withdraw: func() { cancel() // A routed cancel occupies a position in this wire's send order. Its - // observation and best-effort admission finish before the next frame. - complete(context.Canceled, true) + // best-effort admission finishes before the next frame. + complete(true) }}, nil } -// awaitReply is the request primitive shared by carrier calls and relative -// wires. A wire changes where a request is dispatched, not how it completes. -func awaitReply(ctx context.Context, reply <-chan pendingResult, done <-chan struct{}, ended func() error, result any) (bool, error) { - select { - case r := <-reply: - if r.err != nil { - return false, r.err - } - if result == nil { - return false, nil - } - if err := json.Unmarshal(r.result, result); err != nil { - return false, fmt.Errorf("decode duplex result: %w", err) - } - return false, nil - case <-ctx.Done(): - return true, ctx.Err() - case <-done: - return false, ended() - } -} - -// Cancellation is best effort; a congested transport must not extend the +// cancelRequest is best effort: a congested transport must not extend the // caller's already-expired deadline while waiting to send its cancellation. -func (p *Peer) cancelRequest(id string, trace Trace) { - f := frame{Version: 1, Kind: "cancel", ID: id, Traceparent: trace.Parent, Tracestate: trace.State} - data, err := MarshalJSON(f) +func (p *Peer) cancelRequest(id string, trace core.Trace) { + f := profile.Frame{Version: 1, Kind: "cancel", ID: id, Traceparent: trace.Parent, Tracestate: trace.State} + data, err := profile.MarshalJSON(f) if err != nil || int64(len(data)) > p.options.MaxFrameBytes { return } @@ -587,48 +400,31 @@ func (p *Peer) cancelRequest(id string, trace Trace) { } } -// Emit queues an event. Success means queued for this connection, not persisted -// or processed by the remote application. -func (p *Peer) Emit(ctx context.Context, event string, data any) error { - return p.emitTrace(ctx, event, data, nil, false) -} - -func (p *Peer) emitTrace(ctx context.Context, event string, data any, carried *Trace, immediate bool) error { +// emit queues an outgoing event. Success means queued for this connection, +// not persisted or processed by the remote application. +func (p *Peer) emit(ctx context.Context, event string, data json.RawMessage, trace core.Trace) error { if ctx == nil || event == "" { - return Unpublished(errors.New("duplex event requires context and name")) + return core.Unpublished(errors.New("bitruntime: an event requires a context and name")) } - encoded, err := MarshalJSON(data) - if err != nil { - return Unpublished(err) - } - var trace Trace - if carried != nil { - trace = *carried - } else { - trace = p.options.Propagator.Inject(ctx) - } - f := frame{Version: 1, Kind: "event", Event: event, Data: encoded, - Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)} - // The application emitted it here; the frame carrying it is sent when the - // queue takes it, which is one event of its own and may not happen at all. - p.observeEmitted(f) - return Unpublished(p.enqueueFrame(ctx, f, immediate)) + f := profile.Frame{Version: 1, Kind: "event", Event: event, Data: data, + Traceparent: trace.Parent, Tracestate: trace.State, Meta: delivery.OutgoingMeta(ctx)} + return core.Unpublished(p.enqueueFrame(ctx, f, true)) } -func (p *Peer) enqueue(ctx context.Context, f frame) error { +func (p *Peer) enqueue(ctx context.Context, f profile.Frame) error { return p.enqueueFrame(ctx, f, false) } -func (p *Peer) enqueueFrame(ctx context.Context, f frame, immediate bool) error { +func (p *Peer) enqueueFrame(ctx context.Context, f profile.Frame, immediate bool) error { if err := ctx.Err(); err != nil { return err } - data, err := MarshalJSON(f) + data, err := profile.MarshalJSON(f) if err != nil { return err } if int64(len(data)) > p.options.MaxFrameBytes { - return errors.New("duplex frame exceeds size limit") + return errors.New("bitruntime: frame exceeds size limit") } select { case <-p.done: @@ -641,11 +437,9 @@ func (p *Peer) enqueueFrame(ctx context.Context, f frame, immediate bool) error default: } if !immediate { - // Public Peer producers pace a transient burst for one write deadline. - // Their own cancellation withdraws only this unadmitted frame. Wire - // dispatch instead requires an immediate handoff so a composition does + // A response paces a transient burst for one write deadline. Root + // traffic instead requires an immediate handoff so a composition does // not run at its slowest destination's pace. - p.observeBackpressure(len(p.outputs), false, p.options.WriteTimeout) timer := time.NewTimer(p.options.WriteTimeout) defer timer.Stop() select { @@ -658,9 +452,8 @@ func (p *Peer) enqueueFrame(ctx context.Context, f frame, immediate bool) error case <-timer.C: } } - p.observeBackpressure(len(p.outputs), true, p.options.WriteTimeout) - p.fail(ErrBackpressure) - return ErrBackpressure + p.fail(core.ErrBackpressure) + return core.Ended(core.ErrBackpressure) } func (p *Peer) writeLoop() { @@ -669,12 +462,8 @@ func (p *Peer) writeLoop() { case <-p.done: return case queued := <-p.outputs: - // The one place a send is observed, and before the bytes leave: a - // reply cannot be read, let alone observed, ahead of the frame.sent - // of the request that drew it. - p.observeSent(queued.frame, len(queued.data)) ctx, cancel := context.WithTimeout(p.ctx, p.options.WriteTimeout) - err := p.conn.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: queued.data}) + err := p.conn.Send(ctx, transports.Frame{Kind: transports.Text, Data: queued.data}) cancel() if err != nil { p.fail(err) @@ -691,7 +480,7 @@ func (p *Peer) readLoop() { p.fail(err) return } - if received.Kind != duplex.Text { + if received.Kind != transports.Text { p.refuse(errors.New("duplex requires JSON text frames")) return } @@ -699,7 +488,7 @@ func (p *Peer) readLoop() { p.refuse(errors.New("duplex frame exceeds size limit")) return } - f, err := decodeFrame(received.Data) + f, err := profile.Decode(received.Data) if err != nil { p.refuse(err) return @@ -709,7 +498,7 @@ func (p *Peer) readLoop() { if f.Kind == "response" { prefix = p.prefix } - if !validID(f.ID, prefix) { + if !profile.ValidID(f.ID, prefix) { p.refuse(errors.New("invalid duplex request identifier")) return } @@ -720,7 +509,6 @@ func (p *Peer) readLoop() { return } } - p.observeReceived(f, len(received.Data)) switch f.Kind { case "response": p.mu.Lock() @@ -728,9 +516,9 @@ func (p *Peer) readLoop() { delete(p.pending, f.ID) p.mu.Unlock() if reply != nil { - r := pendingResult{result: f.Result} + r := request.Result{Value: f.Result} if f.Error != nil { - r.err = f.Error + r.Err = &core.PublicError{Code: f.Error.Code, Message: f.Error.Message, Data: f.Error.Data} } reply <- r } @@ -744,7 +532,7 @@ func (p *Peer) readLoop() { case "request": p.startRequest(f) case "event": - if !p.enqueueEvent(queuedEvent{event: Event{Name: f.Event, Data: f.Data}, trace: Trace{Parent: f.Traceparent, State: f.Tracestate}, meta: f.Meta}) { + if !p.enqueueEvent(queuedEvent{name: f.Event, data: f.Data, trace: core.Trace{Parent: f.Traceparent, State: f.Tracestate}, meta: f.Meta}) { return } } @@ -757,14 +545,11 @@ func (p *Peer) enqueueEvent(event queuedEvent) bool { return true default: } - // A full queue can be a healthy transient burst โ€” a tight decoder loop - // outrunning a ready consumer โ€” so the producer is paced for one write - // deadline before the consumer is declared stalled, as the outgoing queue - // does. The producer here is the remote, and the only way to pace it is to - // stop reading: while this waits, responses and cancellations on this - // connection wait with it. That is the cost of not ending a connection - // that would drain in a second, and the deadline is what bounds it. - p.observeBackpressure(len(p.events), false, p.options.WriteTimeout) + // A full queue can be a healthy transient burst, so the producer is paced + // for one write deadline before the consumer is declared stalled. The + // producer here is the remote, and the only way to pace it is to stop + // reading: while this waits, responses and cancellations on this + // connection wait with it. The deadline is what bounds that cost. timer := time.NewTimer(p.options.WriteTimeout) defer timer.Stop() select { @@ -773,8 +558,7 @@ func (p *Peer) enqueueEvent(event queuedEvent) bool { case <-p.done: return false case <-timer.C: - p.observeBackpressure(len(p.events), true, p.options.WriteTimeout) - p.fail(ErrBackpressure) + p.fail(core.ErrBackpressure) return false } } @@ -784,9 +568,9 @@ func (p *Peer) enqueueEvent(event queuedEvent) bool { // would name an invocation the receiver has already seen. const maxRequestSerial = uint64(1)<<63 - 1 -// admit holds an incoming request to the profile's publication order: within -// one connection instance and one direction, each request's serial is greater -// than every request's published before it. Gaps are allowed. +// admit holds an incoming request to publication order: within one +// connection and one direction, each request's serial is greater than every +// request's published before it. Gaps are allowed. func (p *Peer) admit(id, prefix string) bool { serial, err := strconv.ParseUint(strings.TrimPrefix(id, prefix), 10, 64) if err != nil || serial == 0 { @@ -801,84 +585,39 @@ func (p *Peer) admit(id, prefix string) bool { return true } -func validID(id, prefix string) bool { - if !strings.HasPrefix(id, prefix) { - return false - } - n := strings.TrimPrefix(id, prefix) - if n == "" || n[0] == '0' { - return false - } - for _, r := range n { - if r < '0' || r > '9' { - return false - } - } - return len(n) <= 20 -} - -// validTraceparent holds a traceparent to the one form W3C Trace Context gives -// it: version, trace id, parent id and flags, lower-case hexadecimal, dashed. -func validTraceparent(value string) bool { - if len(value) != 55 || value[2] != '-' || value[35] != '-' || value[52] != '-' { - return false - } - for i := 0; i < len(value); i++ { - if i == 2 || i == 35 || i == 52 { - continue - } - if c := value[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { - return false - } - } - return true -} +type frameKey struct{} -func (p *Peer) startRequest(f frame) { +func (p *Peer) startRequest(f profile.Frame) { p.mu.Lock() if _, exists := p.incoming[f.ID]; exists { p.mu.Unlock() p.refuse(errors.New("duplicate active duplex request identifier")) return } - handler := p.handlers[f.Method] - fallback := p.requestFallback p.mu.Unlock() - if fallback != nil { - if attached := fallback(f.Method); attached != nil { - handler = attached - } - } + handler := p.root.requestHandler(f.Method) // A response carries its request's trace, whether a handler ran or not. - trace := Trace{Parent: f.Traceparent, State: f.Tracestate} - // A request this peer refuses for want of a method or of a slot is still a - // request it began: what starts is what ends, and the refusal is the outcome. - started := p.requestStarted(f.ID, f.Method, true, trace) + trace := core.Trace{Parent: f.Traceparent, State: f.Tracestate} if handler == nil { - refusal := &PublicError{Code: "method_not_found", Message: "Unknown method"} - p.requestEnded(started, f.ID, f.Method, true, trace, refusal) - p.rejectRequest(f.ID, trace, refusal) + p.rejectRequest(f.ID, trace, &core.PublicError{Code: "method_not_found", Message: "Unknown method"}) return } select { case p.slots <- struct{}{}: default: - refusal := &PublicError{Code: "busy", Message: "Too many concurrent requests"} - p.requestEnded(started, f.ID, f.Method, true, trace, refusal) - p.rejectRequest(f.ID, trace, refusal) + p.rejectRequest(f.ID, trace, &core.PublicError{Code: "busy", Message: "Too many concurrent requests"}) return } - // What the handler sends is a child of the request that ran it, and carries - // the request's meta only where the handler says so: a trace is the peer's - // to propagate, a carriage the consumer's. - handling := withIncomingMeta(p.options.Propagator.Extract(p.ctx, trace), f.Meta) + // What the handler sends is a child of the request that ran it, and + // carries the request's meta only where the handler says so. + handling := delivery.WithIncomingMeta(p.options.Propagator.Extract(p.ctx, trace), f.Meta) ctx, cancel := context.WithTimeout(handling, p.options.RequestTimeout) - ctx = context.WithValue(ctx, wireFrameKey{}, f) + ctx = context.WithValue(ctx, frameKey{}, f) p.mu.Lock() p.incoming[f.ID] = cancel p.mu.Unlock() var answered sync.Once - respond := func(result any, err error) { + respond := func(result json.RawMessage, err error) { answered.Do(func() { // The deadline has already won when it releases the body, even if // that body beats the asynchronous deadline callback to this once. @@ -886,7 +625,6 @@ func (p *Peer) startRequest(f frame) { if errors.Is(ctx.Err(), context.DeadlineExceeded) { result, err = nil, context.DeadlineExceeded } - p.requestEnded(started, f.ID, f.Method, true, trace, err) p.respond(f.ID, trace, result, err) }) } @@ -900,7 +638,7 @@ func (p *Peer) startRequest(f frame) { go func() { defer func() { cancel(); p.mu.Lock(); delete(p.incoming, f.ID); p.mu.Unlock(); <-p.slots }() defer stopDeadline() - result, err := invokeHandler(ctx, p, handler, f) + result, err := invoke(ctx, handler, f.Params) if err == nil && ctx.Err() != nil { err = ctx.Err() } @@ -909,16 +647,25 @@ func (p *Peer) startRequest(f frame) { }() } +func invoke(ctx context.Context, handler func(context.Context, json.RawMessage) (json.RawMessage, error), params json.RawMessage) (result json.RawMessage, err error) { + defer func() { + if value := recover(); value != nil { + err = errors.New("bitruntime: handler panic") + } + }() + return handler(ctx, params) +} + // Rejections run on the reader because they do not consume handler slots. They -// must never wait for outbound capacity: that could hold up a response needed by -// an already active reverse call. A flood exhausting the rejection capacity +// must never wait for outbound capacity: that could hold up a response needed +// by an already active reverse call. A flood exhausting the rejection capacity // closes the overloaded connection after giving the writer a scheduling turn. -func (p *Peer) rejectRequest(id string, trace Trace, public *PublicError) { - f := frame{Version: 1, Kind: "response", ID: id, Error: public, +func (p *Peer) rejectRequest(id string, trace core.Trace, public *core.PublicError) { + f := profile.Frame{Version: 1, Kind: "response", ID: id, Error: &wire.ProfileError{Code: public.Code, Message: public.Message, Data: public.Data}, Traceparent: trace.Parent, Tracestate: trace.State} - data, err := MarshalJSON(f) + data, err := profile.MarshalJSON(f) if err != nil || int64(len(data)) > p.options.MaxFrameBytes { - p.fail(errors.New("duplex rejection exceeds frame limit")) + p.fail(errors.New("bitruntime: rejection exceeds frame limit")) return } select { @@ -933,44 +680,32 @@ func (p *Peer) rejectRequest(id string, trace Trace, public *PublicError) { case p.outputs <- queuedFrame{data: data, frame: f}: case <-p.done: default: - p.fail(ErrBackpressure) + p.fail(core.ErrBackpressure) } } -// invokeHandler takes the whole frame so that a panic is reported as what the -// handler was called for, never as what it was called with. -func invokeHandler(ctx context.Context, p *Peer, h Handler, f frame) (result any, err error) { - defer func() { - if value := recover(); value != nil { - p.observePanic(f, value) - err = errors.New("duplex handler panic") - } - }() - return h(ctx, p, f.Params) -} - -func (p *Peer) respond(id string, trace Trace, result any, err error) { - f := frame{Version: 1, Kind: "response", ID: id, Traceparent: trace.Parent, Tracestate: trace.State} +func (p *Peer) respond(id string, trace core.Trace, result json.RawMessage, err error) { + f := profile.Frame{Version: 1, Kind: "response", ID: id, Traceparent: trace.Parent, Tracestate: trace.State} if err != nil { - var public *PublicError + var public *core.PublicError switch { case errors.As(err, &public) && public != nil && public.Code != "" && public.Message != "": - f.Error = public + f.Error = &wire.ProfileError{Code: public.Code, Message: public.Message, Data: public.Data} case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - f.Error = &PublicError{Code: "cancelled", Message: "Request cancelled"} + f.Error = &wire.ProfileError{Code: "cancelled", Message: "Request cancelled"} default: - f.Error = &PublicError{Code: "internal", Message: "Internal error"} + f.Error = &wire.ProfileError{Code: "internal", Message: "Internal error"} } } else { - f.Result, err = MarshalJSON(result) - if err != nil { - f.Error = &PublicError{Code: "internal", Message: "Internal error"} - f.Result = nil + f.Result = result + if len(f.Result) == 0 { + f.Result = json.RawMessage("null") } } if err := p.enqueue(p.ctx, f); err != nil && p.ctx.Err() == nil { - // An oversized/unencodable result cannot leave the remote call hanging. - fallback := frame{Version: 1, Kind: "response", ID: id, Error: &PublicError{Code: "internal", Message: "Response could not be encoded"}, + // An oversized or unencodable result cannot leave the remote call + // hanging. + fallback := profile.Frame{Version: 1, Kind: "response", ID: id, Error: &wire.ProfileError{Code: "internal", Message: "Response could not be encoded"}, Traceparent: trace.Parent, Tracestate: trace.State} if retryErr := p.enqueue(p.ctx, fallback); retryErr != nil { p.fail(retryErr) @@ -984,137 +719,19 @@ func (p *Peer) eventLoop() { case <-p.done: return case queued := <-p.events: - event := queued.event - ctx := withIncomingMeta(p.options.Propagator.Extract(p.ctx, queued.trace), queued.meta) - ctx = context.WithValue(ctx, wireFrameKey{}, frame{Version: 1, Kind: "event", Event: event.Name, Data: event.Data, - Traceparent: queued.trace.Parent, Tracestate: queued.trace.State, Meta: queued.meta}) - p.observeDelivered(queued) - p.mu.Lock() - handler := p.eventHandlers[event.Name] - fallback := p.eventFallback - listeners := make([]func(context.Context, Event), 0, len(p.listeners)) - for _, l := range p.listeners { - listeners = append(listeners, l) - } - p.mu.Unlock() - if fallback != nil { - if attached := fallback(event.Name); attached != nil { - handler = attached - } - } - func() { + ctx := delivery.WithIncomingMeta(p.options.Propagator.Extract(p.ctx, queued.trace), queued.meta) + failed := func() (failed bool) { defer func() { if recover() != nil { - p.fail(errors.New("duplex event handler panic")) + failed = true } }() - if handler != nil { - handler(ctx, p, event.Data) - } - for _, listener := range listeners { - listener(ctx, event) - } + p.root.deliverEvent(ctx, queued) + return false }() + if failed { + p.fail(errors.New("bitruntime: event receiver panic")) + } } } } - -func decodeFrame(data []byte) (frame, error) { - var f frame - if err := scalarjson.Raw(data); err != nil { - return f, err - } - // Validate members separately so duplicate fields and explicit members from - // another frame kind cannot disappear into Go zero values while decoding. - fields, err := frameMembers(data) - if err != nil { - return f, err - } - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&f); err != nil { - return f, fmt.Errorf("invalid duplex frame: %w", err) - } - if err := d.Decode(new(any)); err != io.EOF { - return f, errors.New("invalid trailing duplex frame content") - } - if f.Version != 1 { - return f, errors.New("unsupported duplex frame version") - } - valid := false - allowed := map[string]bool{"version": true, "kind": true, "traceparent": true, "tracestate": true} - switch f.Kind { - case "request": - allowed["id"], allowed["method"], allowed["params"], allowed["meta"] = true, true, true, true - valid = f.ID != "" && f.Method != "" && len(f.Params) > 0 && len(f.Result) == 0 && f.Error == nil && f.Event == "" && len(f.Data) == 0 - case "response": - allowed["id"], allowed["result"], allowed["error"] = true, true, true - valid = f.ID != "" && f.Method == "" && len(f.Params) == 0 && (len(f.Result) > 0) != (f.Error != nil) && f.Event == "" && len(f.Data) == 0 && f.Meta == nil - _, hasResult := fields["result"] - _, hasError := fields["error"] - valid = valid && hasResult != hasError - case "event": - allowed["event"], allowed["data"], allowed["meta"] = true, true, true - valid = f.ID == "" && f.Method == "" && len(f.Params) == 0 && len(f.Result) == 0 && f.Error == nil && f.Event != "" && len(f.Data) > 0 - case "cancel": - allowed["id"] = true - valid = f.ID != "" && f.Method == "" && len(f.Params) == 0 && len(f.Result) == 0 && f.Error == nil && f.Event == "" && len(f.Data) == 0 && f.Meta == nil - } - for name := range fields { - if !allowed[name] { - valid = false - } - } - if f.Error != nil && (f.Error.Code == "" || f.Error.Message == "") { - valid = false - } - // A trace the peer cannot read is a trace it would carry wrongly; tracestate - // has no form of its own and travels alone when an intermediary strips one. - if _, traced := fields["traceparent"]; traced && !validTraceparent(f.Traceparent) { - valid = false - } - // Meta maps names to strings and may be empty; keys under the reserved - // prefix are the profile's to define and it defines none in this version, - // so a frame carrying one is refused rather than read as a consumer's. - if raw, carried := fields["meta"]; carried && !validMeta(raw) { - valid = false - } - if !valid { - return f, errors.New("invalid duplex frame shape") - } - return f, nil -} - -func frameMembers(data []byte) (map[string]json.RawMessage, error) { - d := json.NewDecoder(bytes.NewReader(data)) - token, err := d.Token() - if err != nil || token != json.Delim('{') { - return nil, errors.New("duplex frame must be an object") - } - members := make(map[string]json.RawMessage) - for d.More() { - key, err := d.Token() - if err != nil { - return nil, err - } - name, ok := key.(string) - if !ok { - return nil, errors.New("invalid duplex field name") - } - if _, exists := members[name]; exists { - return nil, fmt.Errorf("duplicate duplex field %q", name) - } - var value json.RawMessage - if err := d.Decode(&value); err != nil { - return nil, err - } - members[name] = value - } - if _, err := d.Token(); err != nil { - return nil, err - } - if err := d.Decode(new(any)); err != io.EOF { - return nil, errors.New("invalid trailing duplex frame content") - } - return members, nil -} diff --git a/engine/go/peer_pacing_test.go b/engine/go/peer_pacing_test.go deleted file mode 100644 index 5d2d705..0000000 --- a/engine/go/peer_pacing_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "sync" - "testing" - "time" - - ws "github.com/Bitspark/nightseam/runtime/go" -) - -type pacedOutputObserver struct { - pressure chan ws.Backpressure -} - -func (o *pacedOutputObserver) Observe(event ws.ObserverEvent) { - if pressure, ok := event.(ws.Backpressure); ok { - o.pressure <- pressure - } -} - -func TestPublicPeerFullOutputPacesUntilCallerCancellation(t *testing.T) { - for _, operation := range []string{"event", "request"} { - t.Run(operation, func(t *testing.T) { - release := make(chan struct{}) - defer close(release) - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - observed := &pacedOutputObserver{pressure: make(chan ws.Backpressure, 4)} - _, destination := delayedWritePair(t, control, ws.Options{ - QueueCapacity: 1, - WriteTimeout: 5 * time.Second, - Observer: observed, - }, ws.Options{}) - if err := destination.Emit(context.Background(), "first", 1); err != nil { - t.Fatal(err) - } - receive(t, control.started) - if err := destination.Emit(context.Background(), "second", 2); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - finished := make(chan error, 1) - go func() { - if operation == "event" { - finished <- destination.Emit(ctx, "cancelled", 3) - } else { - finished <- destination.Call(ctx, "cancelled", 3, nil) - } - }() - // Pressure is an admission barrier: the caller is waiting while the - // accepted write and one queued frame remain held by the consumer. - pressure := receive(t, observed.pressure) - if pressure.Stalled || pressure.Queued != 1 { - t.Fatalf("full public queue = %+v, want pacing at capacity", pressure) - } - cancel() - err := receive(t, finished) - if !errors.Is(err, context.Canceled) { - t.Fatalf("cancelled producer = %v, want caller cancellation", err) - } - wantUnpublished(t, err, true) - if err := destination.Err(); err != nil { - t.Fatalf("cancelling an unadmitted producer ended its carrier: %v", err) - } - }) - } -} - -func TestPublicPeerFullOutputResumesWhenConsumerDrains(t *testing.T) { - for _, operation := range []string{"event", "request"} { - t.Run(operation, func(t *testing.T) { - release := make(chan struct{}) - var once sync.Once - allowWrites := func() { once.Do(func() { close(release) }) } - defer allowWrites() - control := &delayedWriteControl{started: make(chan struct{}), gate: release} - observed := &pacedOutputObserver{pressure: make(chan ws.Backpressure, 4)} - prefix := make(chan int, 3) - _, destination := delayedWritePair(t, control, ws.Options{ - QueueCapacity: 1, - WriteTimeout: 5 * time.Second, - Observer: observed, - }, ws.Options{ - Handlers: map[string]ws.Handler{ - "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { return data, nil }, - }, - Events: map[string]ws.EventHandler{ - "item": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { - var item int - if err := json.Unmarshal(data, &item); err != nil { - t.Error(err) - } - prefix <- item - }, - }, - }) - if err := destination.Emit(context.Background(), "item", 1); err != nil { - t.Fatal(err) - } - receive(t, control.started) - if err := destination.Emit(context.Background(), "item", 2); err != nil { - t.Fatal(err) - } - finished := make(chan error, 1) - go func() { - if operation == "event" { - finished <- destination.Emit(context.Background(), "item", 3) - } else { - var result int - err := destination.Call(context.Background(), "echo", 3, &result) - if err == nil && result != 3 { - t.Errorf("resumed request result = %d, want 3", result) - } - finished <- err - } - }() - if pressure := receive(t, observed.pressure); pressure.Stalled { - t.Fatalf("transient full public queue was declared stalled: %+v", pressure) - } - allowWrites() - if err := receive(t, finished); err != nil { - t.Fatalf("resumed producer = %v", err) - } - count := 2 - if operation == "event" { - count = 3 - } - for want := 1; want <= count; want++ { - if got := receive(t, prefix); got != want { - t.Fatalf("accepted prefix = %d, want %d", got, want) - } - } - if err := destination.Err(); err != nil { - t.Fatalf("resumed producer ended its carrier: %v", err) - } - }) - } -} diff --git a/engine/go/peer_test.go b/engine/go/peer_test.go deleted file mode 100644 index 1204dc4..0000000 --- a/engine/go/peer_test.go +++ /dev/null @@ -1,612 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" - "github.com/coder/websocket" -) - -func receive[T any](t *testing.T, channel <-chan T) T { - t.Helper() - select { - case value := <-channel: - return value - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for peer activity") - var zero T - return zero - } -} - -func newPair(t *testing.T, serverOptions, clientOptions ws.Options) (*ws.Peer, *ws.Peer) { - t.Helper() - connected := make(chan *ws.Peer, 1) - handler, err := ws.NewHandler(ws.ServerOptions{ - Options: serverOptions, - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - OnConnect: func(peer *ws.Peer) { connected <- peer }, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - t.Cleanup(cancel) - client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Options: clientOptions}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = client.Close() }) - remote := receive(t, connected) - t.Cleanup(func() { _ = remote.Close() }) - return client, remote -} - -// rawPeer is one peer reached over a raw connection, so a test spells the -// frames it sends byte for byte rather than letting a peer encode them. -func rawPeer(t *testing.T, options ws.Options) (*ws.Peer, *websocket.Conn, context.Context) { - t.Helper() - connected := make(chan *ws.Peer, 1) - handler, err := ws.NewHandler(ws.ServerOptions{ - Options: options, - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - OnConnect: func(peer *ws.Peer) { connected <- peer }, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - t.Cleanup(cancel) - conn, _, err := websocket.Dial(ctx, server.URL, nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.CloseNow() }) - return receive(t, connected), conn, ctx -} - -func readFrame(ctx context.Context, t *testing.T, conn *websocket.Conn) map[string]json.RawMessage { - t.Helper() - kind, data, err := conn.Read(ctx) - if err != nil || kind != websocket.MessageText { - t.Fatalf("read frame: kind=%v error=%v", kind, err) - } - var members map[string]json.RawMessage - if err := json.Unmarshal(data, &members); err != nil { - t.Fatalf("decode frame %s: %v", data, err) - } - return members -} - -func TestReverseCallCompletesWhileOriginalRequestIsOutstanding(t *testing.T) { - client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - var response int - if err := peer.Call(ctx, "reverse", 6, &response); err != nil { - return nil, err - } - return response + 1, nil - }, - "inner": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { - var value int - if err := json.Unmarshal(data, &value); err != nil { - return nil, err - } - return value * 7, nil - }, - }}, ws.Options{Handlers: map[string]ws.Handler{ - "reverse": func(ctx context.Context, peer *ws.Peer, data json.RawMessage) (any, error) { - var response int - err := peer.Call(ctx, "inner", data, &response) - return response, err - }, - }}) - var result int - if err := client.Call(context.Background(), "outer", nil, &result); err != nil { - t.Fatal(err) - } - if result != 43 { - t.Fatalf("nested duplex result = %d, want 43", result) - } -} - -func TestEventsTravelInBothDirections(t *testing.T) { - clientEvents, serverEvents := make(chan string, 2), make(chan string, 2) - eventHandler := func(output chan<- string) ws.EventHandler { - return func(_ context.Context, _ *ws.Peer, data json.RawMessage) { output <- string(data) } - } - client, server := newPair(t, - ws.Options{Events: map[string]ws.EventHandler{"progress": eventHandler(serverEvents)}}, - ws.Options{Events: map[string]ws.EventHandler{"progress": eventHandler(clientEvents)}}) - observed := make(chan ws.Event, 1) - unsubscribe := client.OnEvent(func(_ context.Context, event ws.Event) { observed <- event }) - for _, value := range []int{1, 2} { - if err := client.Emit(context.Background(), "progress", value); err != nil { - t.Fatal(err) - } - } - if err := server.Emit(context.Background(), "progress", "done"); err != nil { - t.Fatal(err) - } - if got := receive(t, serverEvents); got != "1" { - t.Fatalf("first server event = %s", got) - } - if got := receive(t, serverEvents); got != "2" { - t.Fatalf("second server event = %s", got) - } - if got := receive(t, clientEvents); got != `"done"` { - t.Fatalf("client event = %s", got) - } - if got := receive(t, observed); got.Name != "progress" || string(got.Data) != `"done"` { - t.Fatalf("observed event = %+v", got) - } - unsubscribe() - unsubscribe() -} - -func TestPublicErrorsArePreservedAndInternalFailuresHidden(t *testing.T) { - client, _ := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "public": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { - return nil, fmt.Errorf("wrapper: %w", &ws.PublicError{Code: "conflict", Message: "Changed", Data: json.RawMessage(`{"revision":3}`)}) - }, - "private": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { - return nil, errors.New("private database password") - }, - "panic": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { - panic("private panic details") - }, - "unencodable": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { - return make(chan int), nil - }, - "ok": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return true, nil }, - }}, ws.Options{}) - for _, test := range []struct{ method, code, message string }{ - {"public", "conflict", "Changed"}, - {"private", "internal", "Internal error"}, - {"panic", "internal", "Internal error"}, - {"unencodable", "internal", "Internal error"}, - {"missing", "method_not_found", "Unknown method"}, - } { - t.Run(test.method, func(t *testing.T) { - err := client.Call(context.Background(), test.method, nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != test.code || public.Message != test.message { - t.Fatalf("error = %v, want %s: %s", err, test.code, test.message) - } - if test.method == "public" && string(public.Data) != `{"revision":3}` { - t.Fatalf("public error data = %s", public.Data) - } - if strings.Contains(err.Error(), "private") { - t.Fatalf("internal error leaked: %v", err) - } - }) - } - var result bool - if err := client.Call(context.Background(), "ok", nil, &result); err != nil || !result { - t.Fatalf("connection did not survive handler failure: result=%v err=%v", result, err) - } -} - -func TestCallerCancellationReachesRemoteHandler(t *testing.T) { - started, cancelled := make(chan struct{}), make(chan error, 1) - client, server := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - close(started) - <-ctx.Done() - cancelled <- ctx.Err() - return nil, ctx.Err() - }, - }}, ws.Options{}) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - returned := make(chan error, 1) - go func() { returned <- client.Call(ctx, "wait", nil, nil) }() - receive(t, started) - cancel() - if err := receive(t, returned); !errors.Is(err, context.Canceled) { - t.Fatalf("caller result = %v", err) - } - if err := receive(t, cancelled); !errors.Is(err, context.Canceled) { - t.Fatalf("handler context = %v", err) - } - if client.Err() != nil || server.Err() != nil { - t.Fatalf("request cancellation closed connection: client=%v server=%v", client.Err(), server.Err()) - } -} - -func TestSaturationRejectsNewWorkButStillRoutesReverseResponses(t *testing.T) { - reverseStarted, release := make(chan struct{}), make(chan struct{}) - client, _ := newPair(t, ws.Options{ - MaxConcurrentHandlers: 1, - Handlers: map[string]ws.Handler{ - "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - var result string - err := peer.Call(ctx, "reverse", nil, &result) - return result, err - }, - "extra": func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return "unexpected", nil }, - }, - }, ws.Options{Handlers: map[string]ws.Handler{ - "reverse": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - close(reverseStarted) - select { - case <-release: - return "released", nil - case <-ctx.Done(): - return nil, ctx.Err() - } - }, - }}) - type callResult struct { - value string - err error - } - returned := make(chan callResult, 1) - go func() { - var value string - err := client.Call(context.Background(), "outer", nil, &value) - returned <- callResult{value, err} - }() - receive(t, reverseStarted) - err := client.Call(context.Background(), "extra", nil, nil) - var public *ws.PublicError - if !errors.As(err, &public) || public.Code != "busy" { - t.Fatalf("saturated request returned %v, want busy", err) - } - close(release) - if got := receive(t, returned); got.err != nil || got.value != "released" { - t.Fatalf("pending reverse response was blocked by handler saturation: %+v", got) - } -} - -func TestStalledEventConsumerDisconnects(t *testing.T) { - started := make(chan struct{}) - // A stalled consumer is paced for one write deadline before it is - // disconnected; the deadline is short here so the pacing is not the wait. - client, server := newPair(t, ws.Options{}, ws.Options{ - QueueCapacity: 1, - WriteTimeout: 200 * time.Millisecond, - Events: map[string]ws.EventHandler{ - "progress": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) { - close(started) - <-ctx.Done() - }, - }, - }) - if err := server.Emit(context.Background(), "progress", 1); err != nil { - t.Fatal(err) - } - receive(t, started) - for _, value := range []int{2, 3} { - if err := server.Emit(context.Background(), "progress", value); err != nil { - t.Fatal(err) - } - } - receive(t, client.Done()) - if !errors.Is(client.Err(), ws.ErrBackpressure) { - t.Fatalf("stalled event consumer error = %v", client.Err()) - } - receive(t, server.Done()) -} - -func TestDisconnectCancelsHandlersAndRejectsPendingCalls(t *testing.T) { - started, stopped := make(chan struct{}), make(chan struct{}) - client, server := newPair(t, ws.Options{Handlers: map[string]ws.Handler{ - "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - close(started) - <-ctx.Done() - close(stopped) - return nil, ctx.Err() - }, - }}, ws.Options{}) - returned := make(chan error, 1) - go func() { returned <- client.Call(context.Background(), "wait", nil, nil) }() - receive(t, started) - _ = server.Close() - receive(t, stopped) - if err := receive(t, returned); err == nil { - t.Fatal("pending call succeeded after disconnect") - } - receive(t, client.Done()) -} - -func TestServerRequiresAndEnforcesAuthenticationAndOriginPolicies(t *testing.T) { - authenticate := func(r *http.Request) (context.Context, error) { return r.Context(), nil } - allowOrigin := func(*http.Request) bool { return true } - for _, options := range []ws.ServerOptions{{}, {Authenticate: authenticate}, {CheckOrigin: allowOrigin}} { - if _, err := ws.NewHandler(options); err == nil { - t.Fatal("server accepted missing explicit policy") - } - } - type userKey struct{} - handler, err := ws.NewHandler(ws.ServerOptions{ - Authenticate: func(r *http.Request) (context.Context, error) { - if r.Header.Get("Authorization") != "Bearer valid" { - return nil, errors.New("private authentication failure") - } - return context.WithValue(r.Context(), userKey{}, "alice"), nil - }, - CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://allowed.example" }, - Options: ws.Options{Handlers: map[string]ws.Handler{ - "identity": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - return ctx.Value(userKey{}), nil - }, - }}, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - for _, test := range []struct { - origin, authorization string - status int - }{ - {"https://denied.example", "Bearer valid", http.StatusForbidden}, - {"https://allowed.example", "Bearer wrong", http.StatusUnauthorized}, - } { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - peer, response, err := ws.Dial(ctx, server.URL, ws.DialOptions{HTTPHeader: http.Header{ - "Origin": {test.origin}, "Authorization": {test.authorization}, - }}) - cancel() - if peer != nil { - _ = peer.Close() - } - if err == nil || response == nil || response.StatusCode != test.status { - t.Fatalf("rejected handshake = peer %v, response %v, error %v; want %d", peer, response, err, test.status) - } - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{HTTPHeader: http.Header{ - "Origin": {"https://allowed.example"}, "Authorization": {"Bearer valid"}, - }}) - if err != nil { - t.Fatal(err) - } - defer client.Close() - var identity string - if err := client.Call(ctx, "identity", nil, &identity); err != nil || identity != "alice" { - t.Fatalf("authenticated identity = %q, error=%v", identity, err) - } -} - -func TestMalformedWireFramesDisconnect(t *testing.T) { - // Each row is a frame that would be served but for the one member named: - // a traceparent of another form is refused as any other malformed frame is. - for _, data := range []string{ - `{"version":2,"kind":"event","event":"progress","data":1}`, - `{"version":1,"kind":"request","id":"s:1","method":"wait","params":null}`, - `{"version":1,"kind":"response","id":"s:1","result":null,"error":{"code":"bad","message":"bad"}}`, - `{"version":1,"kind":"event","event":"progress","data":1,"extra":true}`, - `{"version":1,"kind":"event","event":"progress","data":1,"traceparent":"nonsense"}`, - `{"version":1,"kind":"request","id":"c:1","method":"wait","params":{},"traceparent":"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01"}`, - `{"version":1,"kind":"request","id":"c:1","method":"wait","params":{},"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99"}`, - `{"version":1,"kind":"cancel","id":"c:1","traceparent":""}`, - } { - t.Run(data, func(t *testing.T) { - peer, conn, ctx := rawPeer(t, ws.Options{}) - if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { - t.Fatal(err) - } - receive(t, peer.Done()) - if peer.Err() == nil { - t.Fatal("malformed frame closed without error") - } - }) - } -} - -// The members are optional on every kind and the peer emits none of its own: -// what a frame carries it carries past the decoder, and the frame is served. -func TestTraceContextTravelsOnEveryFrameKind(t *testing.T) { - const trace = `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"congo=t61rcWkgMzE"` - events, cancelled := make(chan string, 2), make(chan struct{}) - peer, conn, ctx := rawPeer(t, ws.Options{ - Events: map[string]ws.EventHandler{ - "progress": func(_ context.Context, _ *ws.Peer, data json.RawMessage) { events <- string(data) }, - }, - Handlers: map[string]ws.Handler{ - "outer": func(ctx context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { - var answer string - if err := peer.Call(ctx, "reverse", nil, &answer); err != nil { - return nil, err - } - return answer, nil - }, - "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - <-ctx.Done() - close(cancelled) - return nil, ctx.Err() - }, - }, - }) - write := func(data string) { - t.Helper() - if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { - t.Fatal(err) - } - } - write(`{"version":1,"kind":"event","event":"progress","data":1,` + trace + `}`) - if got := receive(t, events); got != "1" { - t.Fatalf("traced event = %s", got) - } - // A traced request is served, and the response to the reverse call it makes - // is itself traced: both kinds cross the decoder in one exchange. - write(`{"version":1,"kind":"request","id":"c:1","method":"outer","params":{},` + trace + `}`) - reverse := readFrame(ctx, t, conn) - if string(reverse["method"]) != `"reverse"` { - t.Fatalf("reverse request = %v", reverse) - } - write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back",` + trace + `}`) - if response := readFrame(ctx, t, conn); string(response["id"]) != `"c:1"` || string(response["result"]) != `"back"` { - t.Fatalf("response to traced request = %v", response) - } - // An intermediary may strip one member and not the other. - write(`{"version":1,"kind":"event","event":"progress","data":2,"tracestate":"congo=t61rcWkgMzE"}`) - if got := receive(t, events); got != "2" { - t.Fatalf("event carrying tracestate alone = %s", got) - } - write(`{"version":1,"kind":"request","id":"c:2","method":"wait","params":{},` + trace + `}`) - write(`{"version":1,"kind":"cancel","id":"c:2",` + trace + `}`) - receive(t, cancelled) - if peer.Err() != nil { - t.Fatalf("a traced frame closed the connection: %v", peer.Err()) - } -} - -// TestSubprotocolNegotiation holds what the handshake selected against what -// both peers report, and the profile is spoken over the connection either -// way: nothing about it turns on a subprotocol. -func TestSubprotocolNegotiation(t *testing.T) { - // The ticket case: a browser can carry one nowhere but in the offer, and - // accepts the handshake only if it comes back unchanged. - ticket := func(_ *http.Request, offered []string) string { - for _, token := range offered { - if strings.HasPrefix(token, "ticket.") { - return token - } - } - return "" - } - for _, test := range []struct { - name string - server ws.ServerOptions - offer []string - selected string - }{ - {name: "both name it", server: ws.ServerOptions{Subprotocols: []string{"a", "b"}}, offer: []string{"b"}, selected: "b"}, - {name: "the offer meets none of them", server: ws.ServerOptions{Subprotocols: []string{"a", "b"}}, offer: []string{"c"}}, - {name: "the server names none", offer: []string{"a"}}, - {name: "neither side names one", server: ws.ServerOptions{}}, - {name: "a ticket is selected back unchanged", server: ws.ServerOptions{SelectSubprotocol: ticket}, offer: []string{"ticket.4f9c", "a"}, selected: "ticket.4f9c"}, - {name: "the hook selects none", server: ws.ServerOptions{Subprotocols: []string{"a"}, SelectSubprotocol: ticket}, offer: []string{"a"}}, - } { - t.Run(test.name, func(t *testing.T) { - connected := make(chan *ws.Peer, 1) - options := test.server - options.Authenticate = func(r *http.Request) (context.Context, error) { return r.Context(), nil } - options.CheckOrigin = func(*http.Request) bool { return true } - options.OnConnect = func(peer *ws.Peer) { connected <- peer } - options.Options = ws.Options{Handlers: map[string]ws.Handler{ - "selected": func(_ context.Context, peer *ws.Peer, _ json.RawMessage) (any, error) { return peer.Subprotocol(), nil }, - }} - handler, err := ws.NewHandler(options) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Subprotocols: test.offer}) - if err != nil { - t.Fatalf("dial offering %v: %v", test.offer, err) - } - defer client.Close() - remote := receive(t, connected) - defer remote.Close() - if got := client.Subprotocol(); got != test.selected { - t.Fatalf("client subprotocol = %q, want %q", got, test.selected) - } - if got := remote.Subprotocol(); got != test.selected { - t.Fatalf("server subprotocol = %q, want %q", got, test.selected) - } - // And the connection carries the profile whatever was selected, - // which the server reads back over it. - var answer string - if err := client.Call(ctx, "selected", nil, &answer); err != nil { - t.Fatalf("call over a connection negotiating %q: %v", test.selected, err) - } - if answer != test.selected { - t.Fatalf("subprotocol a handler read = %q, want %q", answer, test.selected) - } - }) - } -} - -// TestSubprotocolIsNoneOverAnyOtherTransport: a peer that is not over a -// WebSocket negotiated nothing and says so. -func TestSubprotocolIsNoneOverAnyOtherTransport(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - clientConn, serverConn := duplex.Pipe(1 << 20) - client, err := ws.NewPeer(ctx, clientConn, ws.ClientRole, ws.Options{}) - if err != nil { - t.Fatal(err) - } - defer client.Close() - server, err := ws.NewPeer(ctx, serverConn, ws.ServerRole, ws.Options{}) - if err != nil { - t.Fatal(err) - } - defer server.Close() - if client.Subprotocol() != "" || server.Subprotocol() != "" { - t.Fatalf("subprotocols over a pipe = %q and %q", client.Subprotocol(), server.Subprotocol()) - } -} - -// TestDialRefusesAHandshakeThatNeverAnswers: ConnectTimeout is the Go twin of -// TypeScript's connectTimeoutMs โ€” a listener that takes the connection and -// never answers the upgrade is refused with the same code, connect_timeout, -// with nothing opened and the caller's own context untouched, the deadline -// having been the handshake's and not the connection's. A negative bound is -// refused before anything is dialled at all. -func TestDialRefusesAHandshakeThatNeverAnswers(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer listener.Close() - done := make(chan struct{}) - defer close(done) - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - go func() { <-done; conn.Close() }() - } - }() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - url := "ws://" + listener.Addr().String() - started := time.Now() - peer, _, err := ws.Dial(ctx, url, ws.DialOptions{ConnectTimeout: 50 * time.Millisecond}) - if peer != nil { - t.Fatal("a dial past its bound opened a peer") - } - var refusal *ws.PublicError - if !errors.As(err, &refusal) || refusal.Code != "connect_timeout" { - t.Fatalf("dial error = %v, want the code connect_timeout", err) - } - if elapsed := time.Since(started); elapsed > 5*time.Second { - t.Fatalf("the dial waited %v past its 50ms bound", elapsed) - } - if ctx.Err() != nil { - t.Fatal("the handshake's deadline ended the caller's own context") - } - - if _, _, err := ws.Dial(ctx, url, ws.DialOptions{ConnectTimeout: -time.Second}); err == nil || !strings.Contains(err.Error(), "must not be negative") { - t.Fatalf("a negative connect timeout = %v, want a refusal", err) - } -} diff --git a/engine/go/prepare_test.go b/engine/go/prepare_test.go deleted file mode 100644 index 76bca78..0000000 --- a/engine/go/prepare_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" - ws "github.com/Bitspark/nightseam/runtime/go" - "github.com/Bitspark/nightseam/tunnel/go" - "github.com/coder/websocket" -) - -// A tunnel installed in Prepare is there before the peer has read anything, -// so the client's first channel.open โ€” the natural first act of a consumer -// that came for a channel โ€” meets a handler rather than method_not_found. -// The hook takes a millisecond here on purpose: with Prepare, how long the -// install takes cannot matter, because no frame is read while it runs. The -// same server with the same install in OnConnect refuses 868 of these 1000 -// opens; with the tunnel installed in Prepare, none. -func TestATunnelInstalledInPrepareMeetsTheFirstChannelOpen(t *testing.T) { - handler, err := ws.NewHandler(ws.ServerOptions{ - Options: ws.Options{Prepare: func(peer *ws.Peer) error { - time.Sleep(time.Millisecond) - _, err := tunnel.New(peer, tunnel.Options{}) - return err - }}, - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - iterations := 3000 - if testing.Short() { - iterations = 250 - } - for i := range iterations { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{}) - if err != nil { - cancel() - t.Fatalf("iteration %d: dial: %v", i, err) - } - carrier, err := tunnel.New(client, tunnel.Options{}) - if err != nil { - cancel() - t.Fatalf("iteration %d: tunnel: %v", i, err) - } - channel, err := carrier.OpenConnection(ctx, "probe", "") - if err != nil { - var public *ws.PublicError - if errors.As(err, &public) { - t.Fatalf("iteration %d: the first open was refused %s", i, public.Code) - } - t.Fatalf("iteration %d: the first open was refused: %v", i, err) - } - _ = channel.Close(ctx, duplex.CodeNormal, "") - _ = client.Close() - cancel() - } -} - -// Prepare is where a peer's own handlers go, and it holds the peer alone: -// Handle inside it registers on a peer nothing has reached yet. -func TestPrepareInstallsBeforeTheFirstFrameAndOnConnectSeesALivePeer(t *testing.T) { - order := make(chan string, 2) - handler, err := ws.NewHandler(ws.ServerOptions{ - Options: ws.Options{Prepare: func(peer *ws.Peer) error { - order <- "prepare" - return peer.Handle("probe", func(context.Context, *ws.Peer, json.RawMessage) (any, error) { - return map[string]any{"ready": true}, nil - }) - }}, - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - OnConnect: func(*ws.Peer) { order <- "connect" }, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{}) - if err != nil { - t.Fatal(err) - } - defer client.Close() - var answer struct { - Ready bool `json:"ready"` - } - if err := client.Call(ctx, "probe", map[string]any{}, &answer); err != nil || !answer.Ready { - t.Fatalf("the handler Prepare installed did not answer: %v", err) - } - if first, second := receive(t, order), receive(t, order); first != "prepare" || second != "connect" { - t.Fatalf("hooks ran %s then %s", first, second) - } -} - -// A Prepare that fails fails the construction, on each of the three -// constructors: nothing is returned that could read a frame. -func TestPrepareFailingFailsNewPeer(t *testing.T) { - refusal := errors.New("this peer serves nothing") - near, far := duplex.Pipe(1 << 20) - defer far.Abort() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - peer, err := ws.NewPeer(ctx, near, ws.ClientRole, ws.Options{Prepare: func(*ws.Peer) error { return refusal }}) - if !errors.Is(err, refusal) || peer != nil { - t.Fatalf("NewPeer answered peer=%v error=%v", peer, err) - } -} - -func TestPrepareFailingFailsAcceptAndClosesTheSocket(t *testing.T) { - refusal := errors.New("this server serves nothing") - accepted := make(chan error, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := ws.Accept(w, r, ws.ServerOptions{ - Options: ws.Options{Prepare: func(*ws.Peer) error { return refusal }}, - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - }) - accepted <- err - })) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - conn, _, err := websocket.Dial(ctx, server.URL, nil) - if err != nil { - t.Fatal(err) - } - defer conn.CloseNow() - // The upgrade was answered, so the client is told why the profile will - // not be spoken over it rather than left reading a socket in silence. - _, _, err = conn.Read(ctx) - var closed websocket.CloseError - if !errors.As(err, &closed) || closed.Code != websocket.StatusPolicyViolation { - t.Fatalf("the refused socket ended with %v", err) - } - if err := receive(t, accepted); !errors.Is(err, refusal) { - t.Fatalf("Accept answered %v", err) - } -} - -func TestPrepareFailingFailsDial(t *testing.T) { - refusal := errors.New("this client serves nothing") - handler, err := ws.NewHandler(ws.ServerOptions{ - Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, - CheckOrigin: func(*http.Request) bool { return true }, - }) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - peer, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Options: ws.Options{Prepare: func(*ws.Peer) error { return refusal }}}) - if !errors.Is(err, refusal) || peer != nil { - t.Fatalf("Dial answered peer=%v error=%v", peer, err) - } -} diff --git a/engine/go/seam_test.go b/engine/go/seam_test.go deleted file mode 100644 index eb2374b..0000000 --- a/engine/go/seam_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/Bitspark/nightseam/duplex/go" -) - -// TestPeerSpeaksTheProfileOverAnyConnection: two peers over an in-memory -// pipe, no socket anywhere, complete a call, a reverse call, an event and a -// cancellation, and a closed pipe ends both. The profile is written to the -// seam, not to a WebSocket. -func TestPeerSpeaksTheProfileOverAnyConnection(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - clientConn, serverConn := duplex.Pipe(1 << 20) - blocked := make(chan struct{}) - server, err := NewPeer(ctx, serverConn, ServerRole, Options{Handlers: map[string]Handler{ - "echo": func(ctx context.Context, p *Peer, raw json.RawMessage) (any, error) { - var s string - if err := json.Unmarshal(raw, &s); err != nil { - return nil, err - } - var back string - if err := p.Call(ctx, "reverse", s, &back); err != nil { - return nil, err - } - return back, nil - }, - "block": func(ctx context.Context, p *Peer, raw json.RawMessage) (any, error) { - <-ctx.Done() - close(blocked) - return nil, ctx.Err() - }, - }}) - if err != nil { - t.Fatal(err) - } - defer server.Close() - observed := make(chan Event, 1) - client, err := NewPeer(ctx, clientConn, ClientRole, Options{ - Handlers: map[string]Handler{"reverse": func(ctx context.Context, p *Peer, raw json.RawMessage) (any, error) { - var s string - if err := json.Unmarshal(raw, &s); err != nil { - return nil, err - } - runes := []rune(s) - for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { - runes[i], runes[j] = runes[j], runes[i] - } - return string(runes), nil - }}, - Events: map[string]EventHandler{"changed": func(ctx context.Context, p *Peer, raw json.RawMessage) { - observed <- Event{Name: "changed", Data: raw} - }}, - }) - if err != nil { - t.Fatal(err) - } - defer client.Close() - - var result string - if err := client.Call(ctx, "echo", "seam", &result); err != nil { - t.Fatal(err) - } - if result != "maes" { - t.Fatalf("a call and its reverse call over the pipe returned %q", result) - } - if err := server.Emit(ctx, "changed", map[string]int{"count": 7}); err != nil { - t.Fatal(err) - } - select { - case event := <-observed: - if string(event.Data) != `{"count":7}` { - t.Fatalf("the event arrived as %s", event.Data) - } - case <-ctx.Done(): - t.Fatal("no event arrived over the pipe") - } - short, cancelShort := context.WithTimeout(ctx, 100*time.Millisecond) - defer cancelShort() - if err := client.Call(short, "block", nil, nil); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("a cancelled call returned %v", err) - } - select { - case <-blocked: - case <-ctx.Done(): - t.Fatal("the cancellation never reached the handler over the pipe") - } - if err := clientConn.Close(ctx, duplex.CodeNormal, "done"); err != nil { - t.Fatal(err) - } - select { - case <-server.Done(): - case <-ctx.Done(): - t.Fatal("closing the connection did not end the server peer") - } - var closed *duplex.CloseError - if err := server.Err(); !errors.As(err, &closed) || closed.Code != duplex.CodeNormal || closed.Reason != "done" { - t.Fatalf("the server peer ended with %v, not the close it was sent", err) - } -} - -// TestPeerRefusesAFrameOverItsLimit: a connection whose maker set a laxer -// limit than the peer's still cannot hand the peer an oversized frame. -func TestPeerRefusesAFrameOverItsLimit(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - near, far := duplex.Pipe(1 << 20) - peer, err := NewPeer(ctx, near, ClientRole, Options{MaxFrameBytes: 512}) - if err != nil { - t.Fatal(err) - } - defer peer.Close() - big := make([]byte, 600) - for i := range big { - big[i] = ' ' - } - copy(big, `{"version":1,"kind":"event","event":"e","data":1`) - big[len(big)-1] = '}' - if err := far.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: big}); err != nil { - t.Fatal(err) - } - select { - case <-peer.Done(): - case <-ctx.Done(): - t.Fatal("the peer accepted a frame over its limit") - } - if err := peer.Err(); err == nil || err.Error() != "duplex frame exceeds size limit" { - t.Fatalf("the peer ended with %v", err) - } -} - -// TestHowAPeerEndsAConnectionIsWhatItsObserverIsTold: the profile closes with -// 4011 and a reason when the other side broke it, so that a gateway or a -// proxy between the two has a code to act on; a close this side chose carries -// 1000; and a transport there is nothing to say over is aborted, which the -// far side reads as 1006. The observer is told the code the wire carried in -// every one of them and never one it did not. -func TestHowAPeerEndsAConnectionIsWhatItsObserverIsTold(t *testing.T) { - for _, c := range []struct { - name string - end func(ctx context.Context, cancel context.CancelFunc, peer *Peer, far duplex.Conn) - code duplex.Code - reason string - local bool - }{ - { - name: "a malformed frame is refused", - end: func(ctx context.Context, _ context.CancelFunc, _ *Peer, far duplex.Conn) { - _ = far.Send(ctx, duplex.Frame{Kind: duplex.Text, Data: []byte(`{"version":1,"kind":"event"}`)}) - }, - code: duplex.CodeDuplex, - reason: "invalid duplex frame shape", - local: true, - }, - { - name: "a frame of the wrong kind is refused", - end: func(ctx context.Context, _ context.CancelFunc, _ *Peer, far duplex.Conn) { - _ = far.Send(ctx, duplex.Frame{Kind: duplex.Binary, Data: []byte{0}}) - }, - code: duplex.CodeDuplex, - reason: "duplex requires JSON text frames", - local: true, - }, - { - name: "a close this side chose", - end: func(context.Context, context.CancelFunc, *Peer, duplex.Conn) {}, - code: duplex.CodeNormal, - local: true, - }, - { - name: "a context that ended", - end: func(_ context.Context, cancel context.CancelFunc, _ *Peer, _ duplex.Conn) { - cancel() - }, - code: duplex.CodeAbnormalClosure, - local: true, - }, - { - name: "a far side that aborted", - end: func(_ context.Context, _ context.CancelFunc, _ *Peer, far duplex.Conn) { - _ = far.Abort() - }, - code: duplex.CodeAbnormalClosure, - local: false, - }, - } { - t.Run(c.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - near, far := duplex.Pipe(1 << 20) - closes := make(chan ConnectionClosed, 1) - peer, err := NewPeer(ctx, near, ServerRole, Options{Observer: observerFunc(func(event ObserverEvent) { - if closed, ok := event.(ConnectionClosed); ok { - closes <- closed - } - })}) - if err != nil { - t.Fatal(err) - } - c.end(ctx, cancel, peer, far) - if c.code == duplex.CodeNormal { - _ = peer.Close() - } - var closed ConnectionClosed - select { - case closed = <-closes: - case <-time.After(5 * time.Second): - t.Fatal("the connection did not end") - } - if closed.Code != int(c.code) || closed.Reason != c.reason || closed.Local != c.local { - t.Fatalf("the observer was told %d %q local=%v, want %d %q local=%v", - closed.Code, closed.Reason, closed.Local, int(c.code), c.reason, c.local) - } - // And the far side reads what this side sent, which is the whole - // reason the code is decided here rather than reported here. - if !c.local { - return - } - read, cancelRead := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelRead() - var wire *duplex.CloseError - if _, err := far.Receive(read); !errors.As(err, &wire) { - t.Fatalf("the far side read %v, not a close", err) - } - if wire.Code != c.code || wire.Reason != c.reason { - t.Fatalf("the far side read %d %q, want %d %q", int(wire.Code), wire.Reason, int(c.code), c.reason) - } - }) - } -} diff --git a/engine/go/serial_test.go b/engine/go/serial_test.go deleted file mode 100644 index 21ed1a9..0000000 --- a/engine/go/serial_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - - ws "github.com/Bitspark/nightseam/runtime/go" - "github.com/coder/websocket" -) - -func echoOptions() ws.Options { - return ws.Options{Handlers: map[string]ws.Handler{ - "echo": func(_ context.Context, _ *ws.Peer, data json.RawMessage) (any, error) { return data, nil }, - }} -} - -// TestTheSerialTableIsHeldAsItJudges: every row of tables/serials.json, held -// the way the peer holds what arrives โ€” the first frame published, then the -// request that follows it โ€” so that the two runtimes and the suite read one -// description of the order, this one. -func TestTheSerialTableIsHeldAsItJudges(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "conformance", "tables", "serials.json")) - if err != nil { - t.Fatal(err) - } - var table struct { - Rows []struct { - Name string - Before, Frame string - Valid bool - } - } - if err := json.Unmarshal(data, &table); err != nil { - t.Fatal(err) - } - if len(table.Rows) == 0 { - t.Fatal("the serials table has no rows") - } - for _, row := range table.Rows { - t.Run(row.Name, func(t *testing.T) { - peer, conn, ctx := rawPeer(t, echoOptions()) - for _, frame := range []string{row.Before, row.Frame} { - if err := conn.Write(ctx, websocket.MessageText, []byte(frame)); err != nil { - t.Fatal(err) - } - } - if !row.Valid { - receive(t, peer.Done()) - if peer.Err() == nil { - t.Fatal("a serial that did not increase was admitted") - } - return - } - if members := readFrame(ctx, t, conn); string(members["kind"]) != `"response"` { - t.Fatalf("frame was %s", members["kind"]) - } - if peer.Err() != nil { - t.Fatalf("an admissible serial ended the connection: %v", peer.Err()) - } - }) - } -} - -// Only a request advances the mark. A response answers a serial the receiver -// itself took, and a control names one it already admitted. -func TestOnlyRequestAdmissionAdvancesTheMark(t *testing.T) { - peer, conn, ctx := rawPeer(t, ws.Options{Handlers: map[string]ws.Handler{ - "wait": func(ctx context.Context, _ *ws.Peer, _ json.RawMessage) (any, error) { - <-ctx.Done() - return nil, ctx.Err() - }, - }}) - for _, frame := range []string{ - `{"version":1,"kind":"request","id":"c:4","method":"wait","params":null}`, - `{"version":1,"kind":"cancel","id":"c:4"}`, - `{"version":1,"kind":"response","id":"s:1","result":null}`, - `{"version":1,"kind":"request","id":"c:5","method":"wait","params":null}`, - } { - if err := conn.Write(ctx, websocket.MessageText, []byte(frame)); err != nil { - t.Fatal(err) - } - } - if members := readFrame(ctx, t, conn); string(members["id"]) != `"c:4"` { - t.Fatalf("first answer was %s", members["id"]) - } - if peer.Err() != nil { - t.Fatalf("a control or a response advanced the mark: %v", peer.Err()) - } -} - -// Serials are published in the order they were reserved, whatever order the -// callers that took them are scheduled in. -func TestConcurrentCallsPublishSerialsInOrder(t *testing.T) { - peer, conn, ctx := rawPeer(t, ws.Options{}) - calling, withdraw := context.WithCancel(context.Background()) - var wait sync.WaitGroup - t.Cleanup(func() { withdraw(); wait.Wait() }) - for range 24 { - wait.Add(1) - go func() { - defer wait.Done() - _ = peer.Call(calling, "probe", nil, nil) - }() - } - previous := uint64(0) - for range 24 { - members := readFrame(ctx, t, conn) - if string(members["kind"]) != `"request"` { - continue - } - var id string - if err := json.Unmarshal(members["id"], &id); err != nil { - t.Fatal(err) - } - serial, err := strconv.ParseUint(strings.TrimPrefix(id, "s:"), 10, 64) - if err != nil { - t.Fatal(err) - } - if serial <= previous { - t.Fatalf("published %d after %d", serial, previous) - } - previous = serial - } -} - -// Every carrier bridge mints its own serials on its own connection and maps -// replies back: an inner peer's ids are its own, whatever ids arrived. -func TestACarrierBridgeMintsItsOwnSerials(t *testing.T) { - peer, conn, ctx := rawPeer(t, ws.Options{}) - // The peer's Wire takes a request whose id is the sender's; publishing it - // onward is the bridge's own request, with a serial of the bridge's. - wire := peer.Wire() - go func() { _ = ws.CallWire(context.Background(), wire, []string{"probe"}, nil, nil) }() - members := readFrame(ctx, t, conn) - var id string - if err := json.Unmarshal(members["id"], &id); err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(id, "s:") { - t.Fatalf("the bridge published %q rather than a serial of its own", id) - } - if serial, err := strconv.ParseUint(strings.TrimPrefix(id, "s:"), 10, 64); err != nil || serial == 0 { - t.Fatalf("the bridge published %q", id) - } -} diff --git a/engine/go/smoke_test.go b/engine/go/smoke_test.go new file mode 100644 index 0000000..9453217 --- /dev/null +++ b/engine/go/smoke_test.go @@ -0,0 +1,112 @@ +package engine_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// TestCallsEventsAndCancellationCrossAPipe: the path an adapter uses, end to +// end over the protocol engine โ€” a dispatcher at the server's root, a call +// through the client's root, an event each way, and a withdrawn call whose +// cancellation reaches the handler. +func TestCallsEventsAndCancellationCrossAPipe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + a, b := transports.Pipe(1 << 20) + events := make(chan string, 4) + cancelled := make(chan struct{}) + server, err := engine.NewPeer(ctx, b, engine.ServerRole, engine.Options{Prepare: func(p *engine.Peer) error { + d, err := dispatch.NewDispatcher(p.Wire()) + if err != nil { + return err + } + if _, err := dispatch.Handle(d, []string{"spaces", "a/b", "echo"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + return map[string]json.RawMessage{"echo": raw}, nil + }); err != nil { + return err + } + if _, err := dispatch.Handle(d, []string{"wait"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + <-ctx.Done() + close(cancelled) + return nil, ctx.Err() + }); err != nil { + return err + } + _, err = dispatch.Register(d, []string{"ping"}, dispatch.Handlers{Event: func(ctx context.Context, raw json.RawMessage) error { + events <- "server:" + string(raw) + return dispatch.Emit(ctx, p.Wire(), []string{"pong"}, "back") + }}) + return err + }}) + if err != nil { + t.Fatal(err) + } + defer server.Close() + client, err := engine.NewPeer(ctx, a, engine.ClientRole, engine.Options{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + d, err := dispatch.NewDispatcher(client.Wire()) + if err != nil { + t.Fatal(err) + } + if _, err := dispatch.Register(d, []string{"pong"}, dispatch.Handlers{Event: func(ctx context.Context, raw json.RawMessage) error { + events <- "client:" + string(raw) + return nil + }}); err != nil { + t.Fatal(err) + } + + var got map[string]json.RawMessage + space := core.At(client.Wire(), []string{"spaces", "a/b"}) + if err := dispatch.Call(ctx, space, []string{"echo"}, map[string]int{"n": 1}, &got); err != nil { + t.Fatal(err) + } + if string(got["echo"]) != `{"n":1}` { + t.Fatalf("echo: %s", got["echo"]) + } + var public *core.PublicError + if err := dispatch.Call(ctx, client.Wire(), []string{"missing"}, nil, nil); !errors.As(err, &public) || public.Code != "method_not_found" { + t.Fatalf("missing: %v", err) + } + + if err := dispatch.Emit(ctx, client.Wire(), []string{"ping"}, 7); err != nil { + t.Fatal(err) + } + for _, want := range []string{"server:7", `client:"back"`} { + select { + case got := <-events: + if got != want { + t.Fatalf("event %q, want %q", got, want) + } + case <-ctx.Done(): + t.Fatalf("no event %q", want) + } + } + + withdrawn, withdraw := context.WithTimeout(ctx, 200*time.Millisecond) + defer withdraw() + if err := dispatch.Call(withdrawn, client.Wire(), []string{"wait"}, nil, nil); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("withdrawn: %v", err) + } + select { + case <-cancelled: + case <-ctx.Done(): + t.Fatal("the cancellation never reached the handler") + } + + _ = server.Close() + <-client.Done() + if err := dispatch.Call(ctx, client.Wire(), []string{"wait"}, nil, nil); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("after close: %v", err) + } +} diff --git a/engine/go/unicode_peer_test.go b/engine/go/unicode_peer_test.go deleted file mode 100644 index ca6ab7e..0000000 --- a/engine/go/unicode_peer_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package runtime_test - -import ( - "context" - "encoding/json" - "testing" - - ws "github.com/Bitspark/nightseam/runtime/go" -) - -func TestPeerRefusesMalformedOutgoingUnicode(t *testing.T) { - client, server := newPair(t, ws.Options{}, ws.Options{}) - server.Handle("echo", func(_ context.Context, _ *ws.Peer, raw json.RawMessage) (any, error) { return raw, nil }) - server.Handle("bad", func(context.Context, *ws.Peer, json.RawMessage) (any, error) { return string([]byte{0xff}), nil }) - ctx := context.Background() - for _, value := range []any{string([]byte{0xff}), map[string]any{"x": string([]byte{0xff})}, json.RawMessage(`"\uD800"`)} { - if err := client.Emit(ctx, "probe", value); err == nil { - t.Fatalf("emitted %T", value) - } - var result any - if err := client.Call(ctx, "echo", value, &result); err == nil { - t.Fatalf("called with %T", value) - } - } - if err := client.Emit(ctx, string([]byte{0xff}), nil); err == nil { - t.Fatal("emitted malformed event name") - } - if err := client.Emit(ws.WithMeta(ctx, map[string]string{"x": string([]byte{0xff})}), "probe", nil); err == nil { - t.Fatal("emitted malformed metadata") - } - var result string - if err := client.Call(ctx, "bad", nil, &result); err == nil { - t.Fatal("malformed response was silently replaced") - } - if err := client.Call(ctx, "echo", "๐Ÿ˜€๏ฟฝ", &result); err != nil || result != "๐Ÿ˜€๏ฟฝ" { - t.Fatalf("valid Unicode after refusal: %q, %v", result, err) - } -} diff --git a/engine/go/wire.go b/engine/go/wire.go index 3586a97..48e035e 100644 --- a/engine/go/wire.go +++ b/engine/go/wire.go @@ -1,4 +1,4 @@ -package runtime +package engine import ( "context" @@ -6,21 +6,26 @@ import ( "errors" "maps" "sync" - "time" - "github.com/Bitspark/nightseam/duplex/go" + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + "github.com/Bitspark/bitruntime/internal/request/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" ) type routedFrame struct { path []string - message duplex.Message + message wire.Message call *routedCall refusal error } // A request reserves one cancellation at admission. Completed requests retain // their reservation until an already queued control has drained, so repeated -// completion/admission cannot turn the control queue into an unbounded buffer. +// completion and admission cannot turn the control queue into an unbounded +// buffer. type routedCall struct { cancel context.CancelFunc completed bool @@ -29,99 +34,46 @@ type routedCall struct { } type returnKey struct { - address *duplex.ReturnAddress + address *wire.ReturnAddress id string } -// peerWire is the existing peer's relative dispatch surface. Its return -// associations stay beside the peer's carrier pending/incoming tables; views -// in duplex only choose a path and never correlate an id. -type peerWire struct { +// rootWire is the peer's addressed origin. Outgoing requests, events and +// cancellations queue here in admission order and are handed to the peer one +// at a time; its return associations stay beside the peer's pending and +// incoming tables. What the remote side sends is delivered to its one +// receiver with the path the frame's name encodes. +type rootWire struct { peer *Peer queue []routedFrame dataQueued int wake chan struct{} mu sync.Mutex incoming map[returnKey]*routedCall - receiver *wireRegistration + receiver *wire.Receiver } -type wireRegistration struct { - receiver duplex.Receiver -} - -type wireFrameKey struct{} -type wireDispatchContext struct { - ctx context.Context - peer *Peer - frame frame - panic func(any) - maxFrameBytes int64 - completion *wireCompletion -} - -// Only a local handler can supply the cause of its cancellation. Serialized -// public errors, even one named cancelled, retain their ordinary error outcome. -type wireCompletion struct { - mu sync.Mutex - cancellation error -} - -// An event has no reply or request lifetime. Its local capability only retains -// the context already established by the receiving runtime across queued local -// composition; it is never reconstructed from event data or metadata. -type wireEventContext struct{ ctx context.Context } - -func (*wireEventContext) Send([]string, duplex.Message) error { - return errors.New("an event context is not a return address") -} -func eventContextOf(message duplex.Message) (context.Context, bool) { - if message.Return != nil { - if held, ok := message.Return.Wire.(*wireEventContext); ok { - return held.ctx, true - } - } - return nil, false -} -func withWireEventContext(message duplex.Message, ctx context.Context) duplex.Message { - message.Return = &duplex.ReturnAddress{Wire: &wireEventContext{ctx: ctx}} - return message -} - -// Wire selects this peer's root origin. Repeated selection shares the peer, -// its queues and its carrier lifetime. -func (p *Peer) Wire() duplex.Endpoint { - p.wireOnce.Do(func() { - p.wire = &peerWire{peer: p, wake: make(chan struct{}, 1), incoming: map[returnKey]*routedCall{}} - p.mu.Lock() - p.requestFallback = p.wire.namespaceHandler - p.eventFallback = p.wire.namespaceEvent - p.mu.Unlock() - go p.wire.run() - }) - return p.wire -} - -func (w *peerWire) Send(path []string, message duplex.Message) error { - name, err := duplex.EncodePath(path) +func (w *rootWire) Send(path []string, message wire.Message) error { + name, err := profile.EncodePath(path) if err != nil { - return err + return core.Unpublished(err) } - if name == "" && (message.Frame.Kind == duplex.ProfileRequest || message.Frame.Kind == duplex.ProfileEvent) { - return errors.New("a root wire operation needs a nonempty path") + if name == "" && (message.Frame.Kind == wire.ProfileRequest || message.Frame.Kind == wire.ProfileEvent) { + return core.Unpublished(errors.New("bitruntime: a root operation needs a nonempty path")) } if err := w.peer.Err(); err != nil { - return err + return core.Unpublished(err) } - if (message.Frame.Kind == duplex.ProfileRequest || message.Frame.Kind == duplex.ProfileCancel) && (message.Return == nil || message.Return.Wire == nil) { - return errors.New("a wire request or cancellation requires a return address") + if (message.Frame.Kind == wire.ProfileRequest || message.Frame.Kind == wire.ProfileCancel) && (message.Return == nil || message.Return.Wire == nil) { + return core.Unpublished(errors.New("bitruntime: a request or cancellation requires a return address")) } - if message.Frame.Kind != duplex.ProfileRequest && message.Frame.Kind != duplex.ProfileEvent && message.Frame.Kind != duplex.ProfileCancel { - return errors.New("a response is sent to its request's return address") + if message.Frame.Kind != wire.ProfileRequest && message.Frame.Kind != wire.ProfileEvent && message.Frame.Kind != wire.ProfileCancel { + return core.Unpublished(errors.New("bitruntime: a response is sent to its request's return address")) } - if err := validateWireFrame(name, message.Frame, w.peer.options.MaxFrameBytes); err != nil { - return err + if err := profile.Validate(name, message.Frame, w.peer.options.MaxFrameBytes); err != nil { + return core.Unpublished(err) } + // Copy, then keep: what is published is what was validated. message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) message.Frame.Meta = maps.Clone(message.Frame.Meta) @@ -130,9 +82,9 @@ func (w *peerWire) Send(path []string, message duplex.Message) error { w.mu.Lock() if err := w.peer.Err(); err != nil { w.mu.Unlock() - return err + return core.Unpublished(err) } - if message.Frame.Kind == duplex.ProfileCancel { + if message.Frame.Kind == wire.ProfileCancel { call := w.incoming[key] if call == nil || call.completed || call.cancelQueued || call.cancelled { w.mu.Unlock() @@ -142,18 +94,17 @@ func (w *peerWire) Send(path []string, message duplex.Message) error { delivered.call = call } else { if w.dataQueued >= w.peer.options.QueueCapacity { - depth := w.dataQueued w.mu.Unlock() - w.peer.observeBackpressure(depth, true, w.peer.options.WriteTimeout) - w.peer.fail(ErrBackpressure) - return ErrBackpressure + // bitwire/1: a full root queue ends the carrier. + w.peer.fail(core.ErrBackpressure) + return core.Unpublished(core.Ended(core.ErrBackpressure)) } w.dataQueued++ - if message.Frame.Kind == duplex.ProfileRequest { + if message.Frame.Kind == wire.ProfileRequest { if w.incoming[key] != nil { - delivered.refusal = &PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} + delivered.refusal = &core.PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} } else if len(w.incoming) >= w.peer.options.MaxPendingRequests { - delivered.refusal = &PublicError{Code: "busy", Message: "Outstanding call limit reached"} + delivered.refusal = &core.PublicError{Code: "busy", Message: "Outstanding call limit reached"} } else { delivered.call = &routedCall{} w.incoming[key] = delivered.call @@ -171,20 +122,20 @@ func (w *peerWire) Send(path []string, message duplex.Message) error { // retireLocked never removes a newer admission that reused the same local // return identity. It is called with w.mu held on completion and control drain. -func (w *peerWire) retireLocked(key returnKey, call *routedCall) { +func (w *rootWire) retireLocked(key returnKey, call *routedCall) { if call.completed && !call.cancelQueued && w.incoming[key] == call { delete(w.incoming, key) } } -func (w *peerWire) complete(key returnKey, call *routedCall) { +func (w *rootWire) complete(key returnKey, call *routedCall) { w.mu.Lock() call.completed = true w.retireLocked(key, call) w.mu.Unlock() } -func (w *peerWire) next() (routedFrame, bool) { +func (w *rootWire) next() (routedFrame, bool) { w.mu.Lock() defer w.mu.Unlock() if len(w.queue) == 0 { @@ -193,31 +144,40 @@ func (w *peerWire) next() (routedFrame, bool) { delivered := w.queue[0] w.queue[0] = routedFrame{} w.queue = w.queue[1:] - if delivered.message.Frame.Kind != duplex.ProfileCancel { + if delivered.message.Frame.Kind != wire.ProfileCancel { w.dataQueued-- } return delivered, true } -func (w *peerWire) Close(code duplex.Code, reason string) error { - w.peer.end(ErrClosed, code, reason) +// Close ends the peer. An observe-only code aborts the connection instead of +// transmitting a code that may only be observed. +func (w *rootWire) Close(code wire.Code, reason string) error { + w.peer.end(transports.ErrClosed, code, reason) return nil } -func (w *peerWire) run() { +func (w *rootWire) run() { defer func() { w.mu.Lock() - var receivers []duplex.Receiver - var cancels []context.CancelFunc - if w.receiver != nil { - receivers = append(receivers, w.receiver.receiver) - } + receiver := w.receiver w.receiver = nil + var cancels []context.CancelFunc for _, call := range w.incoming { if call.cancel != nil { cancels = append(cancels, call.cancel) } } + // Every request still queued is owed an answer: a queued refusal its + // refusal, an admitted request that never reached the peer + // disconnected. A request already handed to the peer is answered by its + // own waiter, which the peer's end releases. + var answers []routedFrame + for _, queued := range w.queue { + if queued.message.Frame.Kind == wire.ProfileRequest { + answers = append(answers, queued) + } + } w.incoming = map[returnKey]*routedCall{} w.queue = nil w.dataQueued = 0 @@ -225,10 +185,15 @@ func (w *peerWire) run() { for _, cancel := range cancels { cancel() } - for _, receiver := range receivers { - if receiver.Closed != nil { - receiver.Closed(duplex.CodeGoingAway, "peer ended") + for _, queued := range answers { + refusal := queued.refusal + if refusal == nil { + refusal = w.peer.Err() } + core.Respond(queued.message, nil, core.WithoutUnpublishedProof(refusal)) + } + if receiver != nil && receiver.Closed != nil { + receiver.Closed(transports.CodeGoingAway, "peer ended") } }() for { @@ -249,7 +214,7 @@ func (w *peerWire) run() { f := delivered.message.Frame key := returnKey{delivered.message.Return, f.ID} switch f.Kind { - case duplex.ProfileCancel: + case wire.ProfileCancel: w.mu.Lock() state := delivered.call var cancel context.CancelFunc @@ -265,24 +230,24 @@ func (w *peerWire) run() { if cancel != nil { cancel() } - case duplex.ProfileRequest: + case wire.ProfileRequest: if delivered.refusal != nil { - sendWireResponse(delivered.message, nil, delivered.refusal) + core.Respond(delivered.message, nil, delivered.refusal) continue } state := delivered.call - ctx := w.peer.options.Propagator.Extract(w.peer.Context(), Trace{Parent: f.Traceparent, State: f.Tracestate}) - ctx = WithMeta(ctx, f.Meta) + ctx := w.peer.options.Propagator.Extract(w.peer.Context(), core.Trace{Parent: f.Traceparent, State: f.Tracestate}) + ctx = delivery.WithOutgoingMeta(ctx, f.Meta) ctx, cancel := context.WithCancel(ctx) - name, err := duplex.EncodePath(delivered.path) + name, err := profile.EncodePath(delivered.path) var call *admittedCall if err == nil { - call, err = w.peer.beginCallTrace(ctx, name, f.Params, &Trace{Parent: f.Traceparent, State: f.Tracestate}, true) + call, err = w.peer.beginCall(ctx, name, f.Params, core.Trace{Parent: f.Traceparent, State: f.Tracestate}) } if err != nil { cancel() w.complete(key, state) - sendWireResponse(delivered.message, nil, WithoutUnpublishedProof(err)) + core.Respond(delivered.message, nil, core.WithoutUnpublishedProof(err)) continue } w.mu.Lock() @@ -295,13 +260,13 @@ func (w *peerWire) run() { // Retire before delivering the response: its callback can admit // another request, but a queued cancellation still owns budget. w.complete(key, state) - sendWireResponse(delivered.message, result, WithoutUnpublishedProof(err)) + core.Respond(delivered.message, result, core.WithoutUnpublishedProof(err)) }() - case duplex.ProfileEvent: - name, err := duplex.EncodePath(delivered.path) - ctx := w.peer.options.Propagator.Extract(w.peer.Context(), Trace{Parent: f.Traceparent, State: f.Tracestate}) + case wire.ProfileEvent: + name, err := profile.EncodePath(delivered.path) if err == nil { - err = w.peer.emitTrace(WithMeta(ctx, f.Meta), name, f.Data, &Trace{Parent: f.Traceparent, State: f.Tracestate}, true) + ctx := w.peer.options.Propagator.Extract(w.peer.Context(), core.Trace{Parent: f.Traceparent, State: f.Tracestate}) + err = w.peer.emit(delivery.WithOutgoingMeta(ctx, f.Meta), name, f.Data, core.Trace{Parent: f.Traceparent, State: f.Tracestate}) } if err != nil { w.peer.fail(err) @@ -310,15 +275,15 @@ func (w *peerWire) run() { } } -func (w *peerWire) Receive(receiver duplex.Receiver) (func(), error) { - registration := &wireRegistration{receiver: receiver} +func (w *rootWire) Receive(receiver wire.Receiver) (func(), error) { + registration := &receiver w.mu.Lock() defer w.mu.Unlock() if err := w.peer.Err(); err != nil { return nil, err } if w.receiver != nil { - return nil, duplex.ErrReceiverExists + return nil, core.ErrReceiverExists } w.receiver = registration return func() { @@ -330,538 +295,57 @@ func (w *peerWire) Receive(receiver duplex.Receiver) (func(), error) { }, nil } -// The profile presents canonical addressed operations to its one attachment. -// Registration and path precedence belong to an explicit Dispatcher. -func (w *peerWire) namespace(name string) ([]string, *wireRegistration) { - path, err := duplex.DecodePath(name) +// attached is the path a frame's name encodes and the receiver attached now, +// or nothing when the name encodes no path or nothing is attached. +func (w *rootWire) attached(name string) ([]string, *wire.Receiver) { + path, err := profile.DecodePath(name) if err != nil { return nil, nil } w.mu.Lock() defer w.mu.Unlock() - registration := w.receiver - if registration == nil { - return nil, nil - } - return path, registration -} - -func (w *peerWire) namespaceHandler(name string) Handler { - path, registration := w.namespace(name) - if registration == nil { - return nil - } - return w.requestReceiver(path, registration.receiver) + return path, w.receiver } -func (w *peerWire) namespaceEvent(name string) EventHandler { - path, registration := w.namespace(name) - if registration == nil { +// requestHandler is the body the peer runs for an incoming request: it hands +// the request to the attached receiver with a fresh return capability that +// carries its invocation lifecycle and the context this peer established, and +// waits for its response. The receiver chosen now stays with this request, so +// a later detach or replacement cannot redirect its cancellation. +func (w *rootWire) requestHandler(name string) func(context.Context, json.RawMessage) (json.RawMessage, error) { + path, receiver := w.attached(name) + if receiver == nil { return nil } - return w.eventReceiver(path, registration.receiver) -} - -func (w *peerWire) requestReceiver(path []string, receiver duplex.Receiver) Handler { - return func(ctx context.Context, _ *Peer, params json.RawMessage) (any, error) { - if receiver.Message == nil { - return nil, &PublicError{Code: "method_not_found", Message: "Unknown method"} + chosen := *receiver + return func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) { + if chosen.Message == nil { + return nil, &core.PublicError{Code: "method_not_found", Message: "Unknown method"} } + incoming, _ := ctx.Value(frameKey{}).(profile.Frame) + dispatch := &delivery.Context{Ctx: ctx, MaxFrameBytes: w.peer.options.MaxFrameBytes, Traceparent: incoming.Traceparent, Tracestate: incoming.Tracestate} var result json.RawMessage - incoming, _ := ctx.Value(wireFrameKey{}).(frame) - dispatch := &wireDispatchContext{ctx: ctx, peer: w.peer, frame: incoming} - // The chosen registration stays with this request. Later detach or - // replacement cannot redirect its correlated cancellation. - err := callWire(WithMeta(ctx, MetaFrom(ctx)), &receiverWire{receiver: receiver}, append([]string{}, path...), params, &result, dispatch) + err := request.Call(delivery.WithOutgoingMeta(ctx, delivery.IncomingMeta(ctx)), receiverWire{chosen}, append([]string{}, path...), params, &result, dispatch, request.Options{}) return result, err } } -func (w *peerWire) eventReceiver(path []string, receiver duplex.Receiver) EventHandler { - return func(ctx context.Context, _ *Peer, data json.RawMessage) { - if receiver.Message == nil { - return - } - incoming, _ := ctx.Value(wireFrameKey{}).(frame) - receiver.Message(append([]string{}, path...), withWireEventContext(duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: data, Traceparent: incoming.Traceparent, Tracestate: incoming.Tracestate, Meta: MetaFrom(ctx)}}, ctx)) - } -} - -// ForwardWire joins two existing origins without allocating a peer or channel. -// Detach removes only the forwarding registrations; both wires remain owned by -// their callers. Each root remains responsible for ending its failed carrier. -func ForwardWire(inbound, outbound duplex.Endpoint) (func(), error) { - if inbound == nil || outbound == nil { - return nil, errors.New("wire forwarding requires two origins") - } - var mu sync.Mutex - var detaches []func() - ended := false - stop := func() { - mu.Lock() - if ended { - mu.Unlock() - return - } - ended = true - owned := detaches - detaches = nil - mu.Unlock() - for _, detach := range owned { - detach() - } - } - receiver := func(destination duplex.Wire) duplex.Receiver { - return duplex.Receiver{Closed: func(duplex.Code, string) { stop() }, Message: func(path []string, message duplex.Message) { - if err := destination.Send(path, message); err != nil { - stop() - if message.Frame.Kind == duplex.ProfileRequest { - sendWireResponse(message, nil, WithoutUnpublishedProof(err)) - } - } - }} - } - for _, direction := range []struct{ source, destination duplex.Endpoint }{{inbound, outbound}, {outbound, inbound}} { - detach, err := direction.source.Receive(receiver(direction.destination)) - if err != nil { - stop() - return nil, err - } - mu.Lock() - active := !ended - if active { - detaches = append(detaches, detach) - } - mu.Unlock() - if !active { - detach() - return nil, ErrClosed - } +// deliverEvent hands an incoming event to the attached receiver, with the +// context this peer established held beside it. +func (w *rootWire) deliverEvent(ctx context.Context, queued queuedEvent) { + path, receiver := w.attached(queued.name) + if receiver == nil || receiver.Message == nil { + return } - return stop, nil + message := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: queued.data, Traceparent: queued.trace.Parent, Tracestate: queued.trace.State, Meta: delivery.IncomingMeta(ctx)}} + receiver.Message(path, delivery.WithEventContext(message, ctx)) } -// receiverWire is used only inside the peer's already asynchronous request -// dispatch. It reuses the same completion primitive when handing a decoded -// request to a generated wire receiver. -type receiverWire struct{ receiver duplex.Receiver } +// receiverWire hands a request, already inside the peer's asynchronous +// dispatch, to the receiver chosen for it. +type receiverWire struct{ receiver wire.Receiver } -func (w *receiverWire) Send(path []string, message duplex.Message) error { +func (w receiverWire) Send(path []string, message wire.Message) error { w.receiver.Message(path, message) return nil } - -type replyWire struct { - id string - reply chan pendingResult - done chan struct{} - once sync.Once - dispatch *wireDispatchContext - invocation *Invocation -} - -// Invocation exposes this return capability's lifecycle to the runtime that -// owns it. The vocabulary reaches it through Send like any participant's. -func (w *replyWire) Invocation() *Invocation { return w.invocation } - -func (w *replyWire) wireDispatch() *wireDispatchContext { return w.dispatch } - -func (w *replyWire) Send(path []string, message duplex.Message) error { - if len(path) != 0 { - return w.invocation.Deliver(path, message) - } - if message.Frame.Kind != duplex.ProfileResponse || message.Frame.ID != w.id { - return errors.New("invalid wire response") - } - var limit int64 - if w.dispatch != nil { - limit = w.dispatch.maxFrameBytes - if limit == 0 && w.dispatch.peer != nil { - limit = w.dispatch.peer.options.MaxFrameBytes - } - } - if err := validateWireFrame("", message.Frame, limit); err != nil { - return err - } - r := pendingResult{result: message.Frame.Result} - if f := message.Frame.Error; f != nil { - r.err = &PublicError{Code: f.Code, Message: f.Message, Data: f.Data} - if f.Code == "cancelled" && w.dispatch != nil && w.dispatch.ctx.Err() != nil && w.dispatch.completion != nil { - completion := w.dispatch.completion - completion.mu.Lock() - if completion.cancellation != nil { - r.err = completion.cancellation - } - completion.mu.Unlock() - } - } - select { - case <-w.done: - return ErrClosed - default: - } - select { - case w.reply <- r: - w.invocation.Settle() - return nil - default: - return errors.New("duplicate wire response") - } -} -func (w *replyWire) finish() error { - w.once.Do(func() { - close(w.done) - w.invocation.Settle() - w.invocation.DispatchDone() - }) - return nil -} - -// CallWire calls a relative operation through the peer's request primitive. -// Its local return address is independent of every other call's identifier. -func CallWire(ctx context.Context, wire duplex.Wire, path []string, params, result any, options ...WireCallOptions) error { - return callWire(ctx, wire, path, params, result, nil, options...) -} -func callWire(ctx context.Context, wire duplex.Wire, path []string, params, result any, dispatch *wireDispatchContext, options ...WireCallOptions) (err error) { - if ctx == nil || wire == nil { - return Unpublished(errors.New("a wire call requires a context and wire")) - } - if err := ctx.Err(); err != nil { - return Unpublished(err) - } - name, err := duplex.EncodePath(path) - if err != nil { - return Unpublished(errors.New("a wire call requires a valid operation path")) - } - encoded, err := MarshalJSON(params) - if err != nil { - return Unpublished(err) - } - var observation WireCallOptions - if len(options) > 0 { - observation = options[0] - } - if observation.RequestTimeout < 0 { - return Unpublished(errors.New("wire request timeout must not be negative")) - } - // A forwarded request already has its carrier's admitted deadline. - if dispatch == nil { - timeout := observation.RequestTimeout - if timeout == 0 { - timeout = 30 * time.Second - } - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, timeout) - defer cancel() - } - if dispatch != nil { - copied := *dispatch - copied.completion = &wireCompletion{} - dispatch = &copied - } - returning := &replyWire{id: "c:1", reply: make(chan pendingResult, 1), done: make(chan struct{}), dispatch: dispatch, invocation: NewInvocation(DefaultInvocationLimits(), nil)} - defer returning.finish() - address := &duplex.ReturnAddress{Wire: returning} - var trace Trace - if dispatch != nil { - trace = Trace{Parent: dispatch.frame.Traceparent, State: dispatch.frame.Tracestate} - } else { - propagator := observation.Propagator - if propagator == nil { - propagator = DefaultPropagator - } - trace = propagator.Inject(ctx) - } - finish := observeWireRequest(observation.Observer, observation.Family, name, false, trace) - defer func() { finish(err) }() - request := duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileRequest, ID: returning.id, Params: encoded, Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)}, Return: address} - if err := wire.Send(path, request); err != nil { - return Unpublished(err) - } - cancelRemote, err := awaitReply(ctx, returning.reply, returning.done, func() error { return ErrClosed }, result) - finish(err) - if cancelRemote { - _ = wire.Send(path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileCancel, ID: returning.id, Traceparent: trace.Parent, Tracestate: trace.State}, Return: address}) - if dispatch != nil { - // This waiter is the carrier's admitted handler, not the outgoing - // caller. Cancellation reaches the body immediately, but its slot - // remains occupied until the receiver actually finishes its work. - _, err = awaitReply(context.WithoutCancel(ctx), returning.reply, returning.done, func() error { return ErrClosed }, result) - } - } - return err -} - -// WireHandler is a typed adapter's decoded request body, independent of the -// concrete carrier. The runtime supplies cancellation and response routing. -type WireHandler func(context.Context, json.RawMessage) (any, error) - -// WireEventHandler receives an event body beside its carried context. -type WireEventHandler func(context.Context, json.RawMessage) error - -// WireHandlers groups a method and event that share one declared name. -type WireHandlers struct { - Request WireHandler - Event WireEventHandler - Observer Observer - Family string -} - -// AdapterContext carries runtime options used when constructing model wires. -type AdapterContext struct { - Options Options - ValueEnvironment ValueEnvironment -} - -// EmitWire admits one event at a relative path. The return says only that the -// destination accepted it; processing and transport remain asynchronous. -func EmitWire(ctx context.Context, wire duplex.Wire, path []string, data any, options ...WireEmitOptions) error { - if ctx == nil || wire == nil { - return Unpublished(errors.New("a wire event requires a context and wire")) - } - if err := ctx.Err(); err != nil { - return Unpublished(err) - } - name, err := duplex.EncodePath(path) - if err != nil { - return Unpublished(errors.New("a wire event requires a valid operation path")) - } - encoded, err := MarshalJSON(data) - if err != nil { - return Unpublished(err) - } - propagator := DefaultPropagator - if len(options) > 0 && options[0].Propagator != nil { - propagator = options[0].Propagator - } - trace := propagator.Inject(ctx) - if len(options) > 0 && options[0].Observer != nil { - observeWire(options[0].Observer, EventEmitted{At: time.Now(), Name: name, Bytes: len(encoded), Trace: trace, Family: options[0].Family}) - } - return Unpublished(wire.Send(path, duplex.Message{Frame: duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileEvent, Data: encoded, Traceparent: trace.Parent, Tracestate: trace.State, Meta: outgoingMeta(ctx)}})) -} - -// HandleWire registers one relative operation. The receiver returns before -// running application code, and request cancellation uses its return address. -func HandleWire(wire HandlerRegistry, path []string, handler WireHandler) (func(), error) { - if wire == nil || handler == nil { - return nil, errors.New("a wire handler requires a wire and body") - } - return RegisterWire(wire, path, WireHandlers{Request: handler}) -} - -// RegisterWire installs a single receiver for a declared method, event, or both. -// The one detach removes the group; an event-only path refuses requests. -func RegisterWire(wire HandlerRegistry, path []string, handlers WireHandlers) (func(), error) { - if wire == nil || (handlers.Request == nil && handlers.Event == nil) { - return nil, errors.New("wire registration requires a wire and at least one handler") - } - name, err := duplex.EncodePath(path) - if err != nil { - return nil, err - } - var mu sync.Mutex - incoming := map[returnKey]context.CancelFunc{} - return wire.Register(path, duplex.Receiver{ - Closed: func(duplex.Code, string) { - mu.Lock() - defer mu.Unlock() - for _, cancel := range incoming { - cancel() - } - }, - Message: func(_ []string, message duplex.Message) { - if message.Frame.Kind == duplex.ProfileEvent { - if handlers.Event != nil { - if handlers.Observer != nil { - observeWire(handlers.Observer, EventDelivered{At: time.Now(), Name: name, Bytes: len(message.Frame.Data), - Trace: Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}, Family: handlers.Family}) - } - ctx, associated := eventContextOf(message) - if !associated { - ctx = DefaultPropagator.Extract(context.Background(), Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) - } - if err := invokeWireEvent(withIncomingMeta(ctx, message.Frame.Meta), handlers.Event, message.Frame.Data); err != nil { - _ = wire.Close(duplex.CodeProtocolError, "wire event rejected") - } - } - return - } - key := returnKey{message.Return, message.Frame.ID} - if message.Frame.Kind == duplex.ProfileCancel { - mu.Lock() - cancel := incoming[key] - mu.Unlock() - if cancel != nil { - cancel() - } - return - } - if message.Frame.Kind != duplex.ProfileRequest { - return - } - finish := observeWireRequest(handlers.Observer, handlers.Family, name, true, - Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) - if handlers.Request == nil { - err := &PublicError{Code: "method_not_found", Message: "Unknown method"} - finish(sendWireResponse(message, nil, err)) - return - } - var dispatch *wireDispatchContext - base := context.Background() - if message.Return != nil { - if returning, ok := message.Return.Wire.(interface{ wireDispatch() *wireDispatchContext }); ok { - dispatch = returning.wireDispatch() - } - } - if dispatch != nil { - base = dispatch.ctx - } - ctx := DefaultPropagator.Extract(base, Trace{Parent: message.Frame.Traceparent, State: message.Frame.Tracestate}) - ctx, cancel := context.WithCancel(withIncomingMeta(ctx, message.Frame.Meta)) - mu.Lock() - if incoming[key] != nil { - mu.Unlock() - cancel() - err := &PublicError{Code: "invalid_message", Message: "Duplicate active request identifier"} - finish(sendWireResponse(message, nil, err)) - return - } - incoming[key] = cancel - mu.Unlock() - // The body runs after this receiver returns, so returning is not - // completion. The lease says so to whoever admitted the request: - // an early answer to the caller cannot retire an invocation whose - // body is still running. A return capability that carries no - // lifecycle still gets ordinary addressed delivery. - // A bound reached is a refusal; any other refusal means this - // return capability carries no lifecycle, and ordinary addressed - // delivery goes on without one. - body, leaseErr := BeginInvocationBody(message) - if errors.Is(leaseErr, ErrInvocationLimit) { - mu.Lock() - delete(incoming, key) - mu.Unlock() - cancel() - err := &PublicError{Code: "busy", Message: "Invocation participation limit reached"} - finish(sendWireResponse(message, nil, err)) - return - } - go func() { - defer func() { body.Done(); cancel(); mu.Lock(); delete(incoming, key); mu.Unlock() }() - result, err := invokeWireHandler(ctx, handlers.Request, message.Frame.Params, dispatch) - if err == nil { - err = ctx.Err() - } - data, marshalErr := MarshalJSON(result) - if err == nil { - err = marshalErr - } - if dispatch != nil && dispatch.completion != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { - dispatch.completion.mu.Lock() - dispatch.completion.cancellation = err - dispatch.completion.mu.Unlock() - } - finish(sendWireResponse(message, data, WithoutUnpublishedProof(err))) - }() - }, - }) -} - -func invokeWireEvent(ctx context.Context, handler WireEventHandler, data json.RawMessage) (err error) { - defer func() { - if recover() != nil { - err = errors.New("wire event handler panic") - } - }() - return handler(ctx, data) -} - -func invokeWireHandler(ctx context.Context, handler WireHandler, params json.RawMessage, dispatch *wireDispatchContext) (result any, err error) { - defer func() { - if value := recover(); value != nil { - if dispatch != nil { - if dispatch.panic != nil { - dispatch.panic(value) - } else if dispatch.peer != nil { - dispatch.peer.observePanic(dispatch.frame, value) - } - } - err = errors.New("wire handler panic") - } - }() - return handler(ctx, params) -} - -func sendWireResponse(request duplex.Message, result json.RawMessage, err error) error { - if request.Return == nil || request.Return.Wire == nil { - return ErrClosed - } - f := duplex.ProfileFrame{Version: 1, Kind: duplex.ProfileResponse, ID: request.Frame.ID, Result: result, Traceparent: request.Frame.Traceparent, Tracestate: request.Frame.Tracestate} - if err != nil { - var public *PublicError - switch { - case errors.As(err, &public) && public != nil && public.Code != "" && public.Message != "": - f.Error = &duplex.ProfileError{Code: public.Code, Message: public.Message, Data: public.Data} - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - f.Error = &duplex.ProfileError{Code: "cancelled", Message: "Request cancelled"} - case errors.Is(err, ErrClosed): - f.Error = &duplex.ProfileError{Code: "disconnected", Message: "Connection ended; outcome may be unknown"} - default: - f.Error = &duplex.ProfileError{Code: "internal", Message: "Internal error"} - } - f.Result = nil - // Preserve the local cancellation cause, but otherwise observe exactly - // the normalized public error selected for this response. - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - err = &PublicError{Code: f.Error.Code, Message: f.Error.Message, Data: f.Error.Data} - } - } - if sendErr := request.Return.Wire.Send(nil, duplex.Message{Frame: f}); sendErr != nil { - // A malformed or oversized public result must settle as a bounded - // refusal, just as the carrier peer's respond does. - f.Result = nil - f.Error = &duplex.ProfileError{Code: "internal", Message: "Response could not be encoded"} - if fallbackErr := request.Return.Wire.Send(nil, duplex.Message{Frame: f}); fallbackErr == nil { - return &PublicError{Code: f.Error.Code, Message: f.Error.Message} - } - // A caller that already withdrew cannot receive either response. Its - // selected refusal remains that refusal; a failed success is no success. - if err == nil { - return WithoutUnpublishedProof(sendErr) - } - } - return err -} - -// The structured boundary uses the profile's existing validator. A logical -// return address identifies an origin independently of the carrier role, so -// either profile identifier prefix is valid before the peer remaps it. -func validateWireFrame(name string, value duplex.ProfileFrame, limit int64) error { - f := frame{Version: value.Version, Kind: string(value.Kind), ID: value.ID, - Params: value.Params, Result: value.Result, Data: value.Data, - Traceparent: value.Traceparent, Tracestate: value.Tracestate, Meta: value.Meta} - if value.Error != nil { - f.Error = &PublicError{Code: value.Error.Code, Message: value.Error.Message, Data: value.Error.Data} - } - switch value.Kind { - case duplex.ProfileRequest: - f.Method = name - case duplex.ProfileEvent: - f.Event = name - } - data, err := MarshalJSON(f) - if err != nil { - return err - } - if limit > 0 && int64(len(data)) > limit { - return errors.New("wire frame exceeds the carrier limit") - } - if _, err := decodeFrame(data); err != nil { - return err - } - if f.ID != "" && !validID(f.ID, "c:") && !validID(f.ID, "s:") { - return errors.New("invalid wire request identifier") - } - return nil -} diff --git a/engine/websocket/go/websocket.go b/engine/websocket/go/websocket.go index fe7bc49..46767d4 100644 --- a/engine/websocket/go/websocket.go +++ b/engine/websocket/go/websocket.go @@ -1,4 +1,7 @@ -package runtime +// Package websocket sets up bitwire/1 connections over WebSockets: Accept and +// NewHandler serve the protocol at an HTTP endpoint, and Dial connects to one. +// Each returns an engine.Peer that owns its connection. +package websocket import ( "context" @@ -9,62 +12,61 @@ import ( "github.com/coder/websocket" - "github.com/Bitspark/nightseam/duplex/go" - "github.com/Bitspark/nightseam/duplex/go/ws" + core "github.com/Bitspark/bitruntime/core/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" + ws "github.com/Bitspark/bitruntime/transports/websocket/go" ) // ServerOptions requires an explicit authentication and origin policy. The // authenticated context is the base context for every incoming invocation. type ServerOptions struct { - Options Options + Options engine.Options Authenticate func(*http.Request) (context.Context, error) CheckOrigin func(*http.Request) bool // OnConnect is called with a peer that is already live: it has read - // frames and may have answered them. It is where a server uses the peer - // โ€” calls it, keeps it, waits on it. What a peer must serve is installed - // in Options.Prepare, which runs before it reads anything; a handler - // installed here can be too late for the other side's first request. - OnConnect func(*Peer) + // frames and may have answered them. What a peer must serve is attached + // in Options.Prepare, which runs before it reads anything. + OnConnect func(*engine.Peer) // Subprotocols are what the server will select, in its own order of - // preference, from what a client offers; empty selects none, which is - // the default and what every consumer that sets nothing keeps. The - // profile names itself here nowhere and refuses nothing on this ground - // (docs/wire/profile.md). + // preference, from what a client offers; empty selects none, which is the + // default. bitwire/1 names itself nowhere here and refuses nothing on + // this ground. Subprotocols []string // SelectSubprotocol answers with the one subprotocol to select out of // what this request offered, "" for none. It is the selection, not a // filter over Subprotocols: a browser's ticket travels in the offer and - // is accepted only if it is selected back unchanged, which no fixed - // list can do. Nil selects the first offered that Subprotocols names. + // is accepted only if it is selected back unchanged. Nil selects the + // first offered that Subprotocols names. SelectSubprotocol func(r *http.Request, offered []string) string } func (o ServerOptions) validate() error { if o.Authenticate == nil || o.CheckOrigin == nil { - return errors.New("duplex server requires explicit authentication and origin policies") + return errors.New("bitruntime: a server requires explicit authentication and origin policies") } - _, err := o.Options.normalized() + _, err := o.Options.Normalized() return err } -// Accept upgrades an authenticated request to a WebSocket and speaks the -// profile over it. The caller must keep its HTTP handler alive until +// Accept upgrades an authenticated request to a WebSocket and speaks +// bitwire/1 over it. The caller must keep its HTTP handler alive until // Peer.Done; NewHandler implements that lifetime contract. -func Accept(w http.ResponseWriter, r *http.Request, options ServerOptions) (*Peer, error) { +func Accept(w http.ResponseWriter, r *http.Request, options ServerOptions) (*engine.Peer, error) { if err := options.validate(); err != nil { http.Error(w, "Invalid server configuration", http.StatusInternalServerError) return nil, err } if !options.CheckOrigin(r) { http.Error(w, "Origin denied", http.StatusForbidden) - return nil, errors.New("duplex origin denied") + return nil, errors.New("bitruntime: origin denied") } ctx, err := options.Authenticate(r) if err != nil || ctx == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) - return nil, errors.New("duplex authentication failed") + return nil, errors.New("bitruntime: authentication failed") } - o, _ := options.Options.normalized() + o, _ := options.Options.Normalized() accept := &websocket.AcceptOptions{InsecureSkipVerify: true, Subprotocols: options.Subprotocols} if options.SelectSubprotocol != nil { // The library selects the first offered that it is given; given the @@ -80,12 +82,11 @@ func Accept(w http.ResponseWriter, r *http.Request, options ServerOptions) (*Pee return nil, err } conn := ws.New(socket, o.MaxFrameBytes) - peer, err := newPeer(ctx, conn, ServerRole, options.Options, socket.Subprotocol()) + peer, err := engine.NewPeer(ctx, conn, engine.ServerRole, options.Options) if err != nil { - // Everything else newPeer refuses was refused by validate above, so + // Everything else NewPeer refuses was refused by validate above, so // this is Prepare's own error: the upgrade is answered and the - // profile will not be spoken over it, and a socket left open in - // silence behind a 101 is the one thing the client cannot read. + // protocol will not be spoken over it. refuseConnection(conn, o.WriteTimeout) return nil, err } @@ -93,18 +94,16 @@ func Accept(w http.ResponseWriter, r *http.Request, options ServerOptions) (*Pee } // refuseConnection ends a socket the handshake opened and the peer above it -// never took, with the code a policy refusal carries everywhere (1008) rather -// than the abort a live peer's failure would leave. -func refuseConnection(conn duplex.Conn, timeout time.Duration) { +// never took, with the code a policy refusal carries everywhere (1008). +func refuseConnection(conn transports.Conn, timeout time.Duration) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - _ = conn.Close(ctx, duplex.CodePolicyViolation, "the connection was refused before the profile began") + _ = conn.Close(ctx, transports.CodePolicyViolation, "the connection was refused before the protocol began") } -// NewHandler serves the profile at an HTTP endpoint: each request that -// passes CheckOrigin and Authenticate is upgraded to a WebSocket and becomes -// a server-role peer with the options given. The generated binding's -// NewHandler wraps this with the family's handlers installed. +// NewHandler serves bitwire/1 at an HTTP endpoint: each request that passes +// CheckOrigin and Authenticate is upgraded to a WebSocket and becomes a +// server-role peer with the options given. func NewHandler(options ServerOptions) (http.Handler, error) { if err := options.validate(); err != nil { return nil, err @@ -126,37 +125,33 @@ func NewHandler(options ServerOptions) (http.Handler, error) { } // DialOptions is what Dial opens a WebSocket with: the peer's Options, the -// headers and client of the HTTP upgrade, the bound on the handshake, and -// the subprotocols to offer. +// headers and client of the HTTP upgrade, the bound on the handshake, and the +// subprotocols to offer. type DialOptions struct { - Options Options + Options engine.Options HTTPHeader http.Header HTTPClient *http.Client - // ConnectTimeout bounds the handshake alone, as TypeScript's - // connectTimeoutMs does, and takes the same default of 30 seconds where - // it is zero. A dial that has not become a connection by then is refused - // with the code connect_timeout and nothing is opened; the connection's - // own lifetime is the context's, as it is without one. + // ConnectTimeout bounds the handshake alone, defaulting to 30 seconds. A + // dial that has not become a connection by then is refused with the code + // connect_timeout and nothing is opened. ConnectTimeout time.Duration // Subprotocols are offered to the server in order of preference; the - // default offers none. A server that selects none leaves the connection - // with none and the profile is spoken over it either way โ€” but a browser - // refuses a handshake whose offer went unselected, so a client that - // offers must be met by a server that selects (docs/wire/profile.md). + // default offers none. A browser refuses a handshake whose offer went + // unselected, so a client that offers must be met by a server that + // selects. Subprotocols []string } -// Dial opens a WebSocket and speaks the profile over it. It uses ctx for the -// handshake and the connection lifetime. Use a separate context for each -// Call; cancelling the dialing context disconnects the peer. -func Dial(ctx context.Context, url string, options DialOptions) (*Peer, *http.Response, error) { +// Dial opens a WebSocket and speaks bitwire/1 over it. It uses ctx for the +// handshake and the connection lifetime: cancelling it disconnects the peer. +func Dial(ctx context.Context, url string, options DialOptions) (*engine.Peer, *http.Response, error) { if ctx == nil { - return nil, nil, errors.New("duplex dial requires a context") + return nil, nil, errors.New("bitruntime: dial requires a context") } if options.ConnectTimeout < 0 { - return nil, nil, errors.New("duplex connect timeout must not be negative") + return nil, nil, errors.New("bitruntime: connect timeout must not be negative") } - o, err := options.Options.normalized() + o, err := options.Options.Normalized() if err != nil { return nil, nil, err } @@ -172,16 +167,15 @@ func Dial(ctx context.Context, url string, options DialOptions) (*Peer, *http.Re socket, response, err := websocket.Dial(dialing, url, &websocket.DialOptions{HTTPHeader: options.HTTPHeader, HTTPClient: options.HTTPClient, Subprotocols: options.Subprotocols}) if err != nil { if ctx.Err() == nil && errors.Is(dialing.Err(), context.DeadlineExceeded) { - return nil, response, &PublicError{Code: "connect_timeout", Message: "Connection timed out."} + return nil, response, &core.PublicError{Code: "connect_timeout", Message: "Connection timed out."} } return nil, response, err } conn := ws.New(socket, o.MaxFrameBytes) - peer, err := newPeer(ctx, conn, ClientRole, options.Options, socket.Subprotocol()) + peer, err := engine.NewPeer(ctx, conn, engine.ClientRole, options.Options) if err != nil { - // As in Accept: the only error left here is Prepare's, and the - // server is told the connection was refused rather than left with a - // socket this side will never speak over. + // As in Accept: the only error left here is Prepare's, and the server + // is told the connection was refused. refuseConnection(conn, o.WriteTimeout) return nil, response, err } diff --git a/internal/delivery/go/delivery.go b/internal/delivery/go/delivery.go new file mode 100644 index 0000000..e062d72 --- /dev/null +++ b/internal/delivery/go/delivery.go @@ -0,0 +1,152 @@ +// Package delivery is the received context bitruntime's own carriers establish +// and its own helpers recognize. Bitwire 0.3 lets Go keep that association +// private to the runtime; this package is internal so that nothing outside +// bitruntime can construct or claim it. A foreign return capability therefore +// carries no recognized context, and a message's visible fields never do. +package delivery + +import ( + "context" + "maps" + "strings" + "sync" + + wire "github.com/Bitspark/bitwire/wire/go" +) + +// Context is what a carrier established for one admitted request: the base +// context its handler runs under, the frame limit its reply must fit, where a +// handler panic is reported, the trace members it arrived with, and how a +// handler's own cancellation cause reaches a forwarded reply. +type Context struct { + Ctx context.Context + MaxFrameBytes int64 + Panic func(any) + Traceparent string + Tracestate string + Completion *Completion +} + +// Completion carries the cause of a local handler's cancellation. Only a +// local handler can supply it; a serialized public error, even one named +// cancelled, keeps its ordinary error outcome. +type Completion struct { + mu sync.Mutex + cancellation error +} + +// Set records the cause of a handler's cancellation. +func (c *Completion) Set(err error) { + c.mu.Lock() + c.cancellation = err + c.mu.Unlock() +} + +// Get is the recorded cause, or nil. +func (c *Completion) Get() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.cancellation +} + +// Source is implemented by return capabilities bitruntime minted. The method +// returns a type only this module can name, so no other package can claim it. +type Source interface { + BitruntimeDelivery() *Context +} + +// Of is the context a bitruntime carrier established for message, if its +// return capability is one of bitruntime's own. +func Of(message wire.Message) *Context { + if message.Return == nil || message.Return.Wire == nil { + return nil + } + if source, ok := message.Return.Wire.(Source); ok { + return source.BitruntimeDelivery() + } + return nil +} + +// Event is the local capability an event carries so that the context a +// receiving runtime established survives queued local composition. It is not +// a return address: an event has no reply and no request lifetime. +type Event struct{ Ctx context.Context } + +// Send refuses: an event context is not a return address. +func (*Event) Send([]string, wire.Message) error { + return errNotReturn +} + +type notReturn struct{} + +func (notReturn) Error() string { return "an event context is not a return address" } + +var errNotReturn error = notReturn{} + +// EventContext is the context a bitruntime carrier established for an event. +func EventContext(message wire.Message) (context.Context, bool) { + if message.Return != nil { + if held, ok := message.Return.Wire.(*Event); ok { + return held.Ctx, true + } + } + return nil, false +} + +// WithEventContext associates ctx with an event's message. +func WithEventContext(message wire.Message, ctx context.Context) wire.Message { + message.Return = &wire.ReturnAddress{Wire: &Event{Ctx: ctx}} + return message +} + +// A carriage travels one way at a time. The meta a frame arrived with and the +// meta the next frame sent from this context will carry are separate values +// under separate keys, so that a handler's outgoing call carries the caller's +// credential only where the handler said to. +type outgoingMetaKey struct{} +type incomingMetaKey struct{} + +// MetaReserved prefixes the keys bitwire/1 keeps for itself. +const MetaReserved = "nightseam." + +// WithOutgoingMeta says what the requests and events sent from ctx carry. +func WithOutgoingMeta(ctx context.Context, meta map[string]string) context.Context { + carried := make(map[string]string, len(meta)) + for key, value := range meta { + if !strings.HasPrefix(key, MetaReserved) { + carried[key] = value + } + } + if len(carried) == 0 { + return context.WithValue(ctx, outgoingMetaKey{}, map[string]string(nil)) + } + return context.WithValue(ctx, outgoingMetaKey{}, carried) +} + +// OutgoingMeta is what a frame sent from ctx carries, and nil where nothing +// said. It is read once per frame. +func OutgoingMeta(ctx context.Context) map[string]string { + meta, _ := ctx.Value(outgoingMetaKey{}).(map[string]string) + if len(meta) == 0 { + return nil + } + return meta +} + +// WithIncomingMeta places what a frame carried on its handler's context. +func WithIncomingMeta(ctx context.Context, meta map[string]string) context.Context { + if len(meta) == 0 { + return ctx + } + return context.WithValue(ctx, incomingMetaKey{}, meta) +} + +// IncomingMeta is a copy of the meta of the frame whose handler ctx runs +// under, and nil where the frame carried none. +func IncomingMeta(ctx context.Context) map[string]string { + meta, _ := ctx.Value(incomingMetaKey{}).(map[string]string) + if len(meta) == 0 { + return nil + } + return maps.Clone(meta) +} diff --git a/internal/request/go/request.go b/internal/request/go/request.go new file mode 100644 index 0000000..ab2e763 --- /dev/null +++ b/internal/request/go/request.go @@ -0,0 +1,187 @@ +// Package request is the request primitive shared by the public call helper +// and the protocol engine's inbound bridge: one request sent through addressed +// access with a fresh return capability that carries its invocation lifecycle, +// and the wait for its one response. +package request + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// DefaultTimeout is how long a call waits for its response when it states +// no deadline of its own. +const DefaultTimeout = 30 * time.Second + +// Options configures one call. A zero Timeout uses DefaultTimeout; a nil +// Propagator uses core.DefaultPropagator. +type Options struct { + Propagator core.Propagator + Timeout time.Duration +} + +// Result is one response as a waiter receives it. +type Result struct { + Value json.RawMessage + Err error +} + +// Reply is the return capability of one call. Its empty path takes the +// response; every other path is its invocation's lifecycle vocabulary. +type Reply struct { + id string + reply chan Result + done chan struct{} + once sync.Once + dispatch *delivery.Context + invocation *core.Invocation +} + +// BitruntimeDelivery is the context the carrier established, if any. +func (w *Reply) BitruntimeDelivery() *delivery.Context { return w.dispatch } + +// Invocation exposes this return capability's lifecycle to its owner. +func (w *Reply) Invocation() *core.Invocation { return w.invocation } + +func (w *Reply) Send(path []string, message wire.Message) error { + if len(path) != 0 { + return w.invocation.Deliver(path, message) + } + if message.Frame.Kind != wire.ProfileResponse || message.Frame.ID != w.id { + return errors.New("bitruntime: invalid wire response") + } + var limit int64 + if w.dispatch != nil { + limit = w.dispatch.MaxFrameBytes + } + if err := profile.Validate("", message.Frame, limit); err != nil { + return err + } + r := Result{Value: message.Frame.Result} + if f := message.Frame.Error; f != nil { + r.Err = &core.PublicError{Code: f.Code, Message: f.Message, Data: f.Data} + if f.Code == "cancelled" && w.dispatch != nil && w.dispatch.Ctx.Err() != nil && w.dispatch.Completion != nil { + if cause := w.dispatch.Completion.Get(); cause != nil { + r.Err = cause + } + } + } + select { + case <-w.done: + return transports.ErrClosed + default: + } + select { + case w.reply <- r: + w.invocation.Settle() + return nil + default: + return errors.New("bitruntime: duplicate wire response") + } +} + +func (w *Reply) finish() { + w.once.Do(func() { + close(w.done) + w.invocation.Settle() + w.invocation.DispatchDone() + }) +} + +// Await is the request primitive shared by carriers and addressed access: it +// waits for the response, the context's end or the carrier's end. The first +// result says whether the caller withdrew, so a cancellation is owed. +func Await(ctx context.Context, reply <-chan Result, done <-chan struct{}, ended func() error, result any) (bool, error) { + select { + case r := <-reply: + if r.Err != nil { + return false, r.Err + } + if result == nil { + return false, nil + } + if err := json.Unmarshal(r.Value, result); err != nil { + return false, fmt.Errorf("bitruntime: decode wire result: %w", err) + } + return false, nil + case <-ctx.Done(): + return true, ctx.Err() + case <-done: + return false, ended() + } +} + +// Call sends one request at path through access and waits for its response. +// dispatch is the context a carrier established when this call forwards a +// request it admitted; it is nil for an application's own call. +func Call(ctx context.Context, access wire.AddressedWire, path []string, params, result any, dispatch *delivery.Context, options Options) error { + if ctx == nil || access == nil { + return core.Unpublished(errors.New("bitruntime: a call requires a context and access")) + } + if err := ctx.Err(); err != nil { + return core.Unpublished(err) + } + if !profile.ValidPath(path) { + return core.Unpublished(errors.New("bitruntime: a call requires a valid operation path")) + } + encoded, err := profile.MarshalJSON(params) + if err != nil { + return core.Unpublished(err) + } + if options.Timeout < 0 { + return core.Unpublished(errors.New("bitruntime: request timeout must not be negative")) + } + // A forwarded request already has its carrier's admitted deadline. + if dispatch == nil { + timeout := options.Timeout + if timeout == 0 { + timeout = DefaultTimeout + } + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + if dispatch != nil { + copied := *dispatch + copied.Completion = &delivery.Completion{} + dispatch = &copied + } + returning := &Reply{id: "c:1", reply: make(chan Result, 1), done: make(chan struct{}), dispatch: dispatch, invocation: core.NewInvocation(core.DefaultInvocationLimits(), nil)} + defer returning.finish() + address := &wire.ReturnAddress{Wire: returning} + var trace core.Trace + if dispatch != nil { + trace = core.Trace{Parent: dispatch.Traceparent, State: dispatch.Tracestate} + } else { + propagator := options.Propagator + if propagator == nil { + propagator = core.DefaultPropagator + } + trace = propagator.Inject(ctx) + } + request := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: returning.id, Params: encoded, Traceparent: trace.Parent, Tracestate: trace.State, Meta: delivery.OutgoingMeta(ctx)}, Return: address} + if err := access.Send(path, request); err != nil { + return core.Unpublished(err) + } + cancelRemote, err := Await(ctx, returning.reply, returning.done, func() error { return transports.ErrClosed }, result) + if cancelRemote { + _ = access.Send(path, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: returning.id, Traceparent: trace.Parent, Tracestate: trace.State}, Return: address}) + if dispatch != nil { + // This waiter is the carrier's admitted handler, not the outgoing + // caller. Cancellation reaches the body immediately, but its slot + // remains occupied until the receiver actually finishes its work. + _, err = Await(context.WithoutCancel(ctx), returning.reply, returning.done, func() error { return transports.ErrClosed }, result) + } + } + return err +} diff --git a/transports/websocket/go/websocket.go b/transports/websocket/go/websocket.go index ddd3607..3097dd5 100644 --- a/transports/websocket/go/websocket.go +++ b/transports/websocket/go/websocket.go @@ -29,6 +29,9 @@ type connection struct { done bool } +// Subprotocol is what the WebSocket handshake selected, "" for none. +func (c *connection) Subprotocol() string { return c.conn.Subprotocol() } + func (c *connection) closed() bool { c.mu.Lock() defer c.mu.Unlock() From 467bc1214bba3853302baf458f20231c03232b5f Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:37:36 +0200 Subject: [PATCH 05/39] docs: record where each ported piece went and what changed Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/port-from-nightseam.md | 90 +++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/port-from-nightseam.md diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md new file mode 100644 index 0000000..93640c2 --- /dev/null +++ b/docs/port-from-nightseam.md @@ -0,0 +1,90 @@ +# Ported from Nightseam v0.6.0 + +The runtime path hand-written adapters use is ported from Nightseam +**v0.6.0**, commit `5cc9723a24646c40ed1861f892b2b23eb6d785d7` (tag `v0.6.0`). +None of the 41 unreleased commits after it is ported: the six that touch the +runtime rename types, fix a test's own race, or add the declared-composition API +that Bitwire decision 0012 supersedes. The verbatim import is its own commit, so +every adaptation is visible as a diff; `NOTICE` records the provenance. + +`bitwire/1` is the behavior of that release (Bitwire decision 0008). The engine +sends, accepts and refuses the same frames, with the same close codes, bounds and +serial rules. The envelope vectors in `vectors/bitwire-1/` are that release's +tables, byte for byte. + +## Where each piece went + +| Nightseam v0.6.0 | bitruntime (Go) | TypeScript | +| --- | --- | --- | +| `duplex/go` `Conn`, `Frame`, codes, `Pipe` | `transports/go` | `transports/ts` | +| `duplex/go/ws` | `transports/websocket/go` | `transports/ts` (`webSocketConnection`) | +| `duplex/go` `At`, `Mount`; `runtime/go` `ForwardWire` | `core/go` `At`, `Mount`, `Forward` | `core/ts` `at`, `mount`, `forward` | +| `runtime/go` `NewWirePair`, `Invocation`, publication evidence, meta, trace | `core/go` `NewPair`, `Invocation`, `Unpublished`, `WithMeta`, `Propagator` | `core/ts` | +| `runtime/go` `NewDispatcher`, `CallWire`, `EmitWire`, `HandleWire`, `RegisterWire` | `dispatch/go` `NewDispatcher`, `Call`, `Emit`, `Handle`, `Register` | `dispatch/ts` | +| `runtime/go` `Peer`, `NewPeer` | `engine/go` `Peer`, `NewPeer` | `engine/ts` `Peer` | +| `runtime/go` `Accept`, `NewHandler`, `Dial` | `engine/websocket/go` | `engine/ts` `Peer.connect`, `Peer.attach` | +| envelope codec, path encoding, Unicode guard | `internal/profile/go` (not public) | module-private | +| received context (private association) | `internal/delivery/go` (not public) | module-private | + +## Go names + +| v0.6.0 | bitruntime | +| --- | --- | +| `duplex.At`, `duplex.Mount`, `runtime.ForwardWire` | `core.At`, `core.Mount`, `core.Forward` | +| `duplex.ErrNoRoute`, `duplex.ErrPath`, `duplex.ErrReceiverExists` | `core.ErrMissingPath`, `core.ErrInvalidPath`, `core.ErrReceiverExists` | +| `duplex.ErrClosed`, `runtime.ErrClosed` | `transports.ErrClosed`, the one closed classification | +| `runtime.ErrBackpressure` | `core.ErrBackpressure` | +| `duplex.Code*`, `duplex.CodeDuplex` | `transports.Code*`, `transports.CodeProtocol` (4011) | +| `duplex.Pipe`, `duplex.Conn`, `duplex.Frame`, `duplex.CloseError` | `transports.Pipe`, `transports.Conn`, `transports.Frame`, `transports.CloseError` | +| `ws.New` | `websocket.New` (`transports/websocket/go`) | +| `runtime.NewWirePair(runtime.Options{โ€ฆ})` | `core.NewPair(core.PairOptions{โ€ฆ})` | +| `runtime.PublicError`, `Unpublished`, `UnpublishedError`, `WithoutUnpublishedProof` | `core.*` | +| `runtime.Invocation`, `CaptureInvocation`, `BeginInvocationBody`, `RelayInvocationControl`, limits and errors | `core.*` | +| `runtime.WithMeta`, `MetaFrom`, `Trace`, `Propagator`, `DefaultPropagator`, `TraceOf` | `core.*` | +| `runtime.NewDispatcher`, `Dispatcher`, `SelectedEndpoint`, `DispatcherOptions`, `HandlerRegistry` | `dispatch.NewDispatcher`, `Dispatcher`, `SelectedEndpoint`, `DispatcherOptions`, `Registry` | +| `runtime.CallWire(โ€ฆ, WireCallOptions{RequestTimeout})` | `dispatch.Call(โ€ฆ, CallOptions{Timeout})` | +| `runtime.EmitWire`, `WireEmitOptions` | `dispatch.Emit`, `EmitOptions` | +| `runtime.HandleWire`, `RegisterWire`, `WireHandler`, `WireEventHandler`, `WireHandlers` | `dispatch.Handle`, `Register`, `Handler`, `EventHandler`, `Handlers` | +| `runtime.NewPeer`, `ClientRole`, `ServerRole`, `Options` | `engine.NewPeer`, `ClientRole`, `ServerRole`, `Options` | +| `runtime.Accept`, `NewHandler`, `Dial`, `ServerOptions`, `DialOptions` | `websocket.Accept`, `NewHandler`, `Dial`, โ€ฆ (`engine/websocket/go`) | +| `peer.Wire()` | `peer.Wire()`, an `Endpoint` over `bitwire.AddressedWire` | + +## Behavior that changed + +Each change is a recorded defect or research verdict, fixed rather than ported. +None changes a `bitwire/1` frame. + +- **nightseam#722.** A closing local pair answers every refusal still queued + with the refusal it was admitted with; v0.6.0 dropped them, leaving callers to + their own deadlines. The peer's root likewise answers requests still queued + when the peer ends (research R28), with their refusal or `disconnected`. +- **nightseam#658.** A pair's response frees its call's pending slot before the + caller can hold the answer, so a caller at the limit can issue its next call + at once. A call whose cancellation is still queued keeps its slot until that + cancellation drains. +- **One closed classification (R26).** Every ended carrier reports an error for + which `errors.Is(err, transports.ErrClosed)` holds, keeping its cause + (`core.ErrBackpressure`, a remote `CloseError`, a context error). A forwarded + request whose destination closed or overflowed is answered `disconnected`, + not `internal`. +- **Observe-only close codes (R27).** `transports.Sendable` separates codes that + may be sent from 1005, 1006 and 1015. Transports refuse the latter with + `ErrUnsendableCode`; a peer asked to close with one aborts instead. +- **Receive limits.** A frame over the pipe's limit ends it with 1009 on both + sides, as a WebSocket does; a WebSocket past its read limit is ended promptly + instead of being left half closed. +- **Forwarding (research 0001, row 14).** A message the destination refuses + fails only that message; v0.6.0 detached both directions. +- **Removed:** the peer's raw method-name API (`Handle`, `HandleEvent`, + `OnEvent`, `Call`, `Emit`, `Options.Handlers`, `Options.Events`). Only the root + Endpoint presents the protocol (research R20); a request whose method is not + a canonical path encoding is answered `method_not_found`, as a v0.6.0 peer + without that handler answers. Observer hooks and family labels are removed + until the engine's observation hooks are designed with Bitwire's + received-context revision (charter ยง1). + +## Not ported in this milestone + +Live references, tunnels, the framed byte stream `bitwire-stream/1`, the +declaration identity check, authentication, observers and the generator. They +keep their own issues (nightseam#720, #721, #723, #724 and bitruntime#2). From 5ce1e0d430471ed4b917e4fef16d8417a03006a8 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:39:28 +0200 Subject: [PATCH 06/39] port: import Nightseam v0.6.0 TypeScript runtime and transport sources verbatim Byte-identical copies from github.com/Bitspark/nightseam at 5cc9723a24646c40ed1861f892b2b23eb6d785d7 (tag v0.6.0), placed at their bitruntime destinations. This commit does not build; the next commits adapt it to Bitwire 0.3.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/ts/src/addressed.ts | 164 +++++ core/ts/src/error.ts | 29 + core/ts/src/invocation.ts | 363 ++++++++++ core/ts/src/pair.ts | 409 +++++++++++ core/ts/src/trace.ts | 78 ++ core/ts/src/unicode.ts | 45 ++ dispatch/ts/src/dispatcher.ts | 221 ++++++ dispatch/ts/src/wire.ts | 896 +++++++++++++++++++++++ engine/ts/src/envelope.ts | 158 +++++ engine/ts/src/peer.ts | 1219 ++++++++++++++++++++++++++++++++ transports/ts/src/transport.ts | 247 +++++++ 11 files changed, 3829 insertions(+) create mode 100644 core/ts/src/addressed.ts create mode 100644 core/ts/src/error.ts create mode 100644 core/ts/src/invocation.ts create mode 100644 core/ts/src/pair.ts create mode 100644 core/ts/src/trace.ts create mode 100644 core/ts/src/unicode.ts create mode 100644 dispatch/ts/src/dispatcher.ts create mode 100644 dispatch/ts/src/wire.ts create mode 100644 engine/ts/src/envelope.ts create mode 100644 engine/ts/src/peer.ts create mode 100644 transports/ts/src/transport.ts diff --git a/core/ts/src/addressed.ts b/core/ts/src/addressed.ts new file mode 100644 index 0000000..e85e896 --- /dev/null +++ b/core/ts/src/addressed.ts @@ -0,0 +1,164 @@ +import type { Endpoint, Path, Receiver, Wire } from '@bitspark/bitwire'; + +// The public Nightseam names present the shared contract's actual declarations. +export type { + Path, + ProfileKind, + ProfileFrame, + ProfileError, + ReturnAddress, + Message, + Receiver, + Wire, + Endpoint, +} from '@bitspark/bitwire'; + +export class WireError extends Error { + readonly code: 'closed' | 'no_route' | 'receiver_exists' | 'invalid_path'; + constructor(code: WireError['code']) { + super(`Wire ${code}.`); + this.name = 'WireError'; + this.code = code; + } +} + +function scalar(value: string): void { + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const low = value.charCodeAt(++i); + if (!(low >= 0xdc00 && low <= 0xdfff)) throw new WireError('invalid_path'); + } else if (unit >= 0xdc00 && unit <= 0xdfff) throw new WireError('invalid_path'); + } +} + +/** UTF-8 byte-length-prefixed segments; [] is '', whereas [''] is '0:'. */ +export function encodePath(path: Path): string { + const encoder = new TextEncoder(); + return path + .map((segment) => { + scalar(segment); + return `${encoder.encode(segment).length}:${segment}`; + }) + .join(''); +} +/** Accepts only the canonical encoding, retaining dots, empty strings and BOMs. */ +export function decodePath(encoded: string): string[] { + scalar(encoded); + const bytes = new TextEncoder().encode(encoded); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + const path: string[] = []; + for (let offset = 0; offset < bytes.length;) { + const start = offset; + let length = 0; + while (offset < bytes.length && bytes[offset] !== 58) { + const digit = bytes[offset++]! - 48; + if (digit < 0 || digit > 9) throw new WireError('invalid_path'); + length = length * 10 + digit; + if (!Number.isSafeInteger(length)) throw new WireError('invalid_path'); + } + if (offset === start || offset === bytes.length || (offset - start > 1 && bytes[start] === 48)) + throw new WireError('invalid_path'); + offset++; + if (length > bytes.length - offset) throw new WireError('invalid_path'); + try { + path.push(decoder.decode(bytes.subarray(offset, offset + length))); + } catch { + throw new WireError('invalid_path'); + } + offset += length; + } + return path; +} + +/** Selects send access without granting receive attachment or closure authority. */ +export function at(root: Wire, path: Path): Wire { + const prefix = [...path]; + return { + send: (suffix, message) => root.send([...prefix, ...suffix], message), + }; +} + +interface ChildAttachment { + active: boolean; + detach?: () => void; +} +interface Attachment { + receiver: Receiver; + active: boolean; + children: ChildAttachment[]; +} +/** + * Consumes one path segment; [] has no leaf, and [''] can select an empty key. + * One owning receiver spans the borrowed children and sees their keys restored. + * Copies the map. Closing detaches this mount's attachment, never children. + */ +export function mount(children: ReadonlyMap): Endpoint { + const routes = new Map(children); + let attachment: Attachment | undefined; + let closed = false; + const destination = (path: Path): Endpoint => { + if (closed) throw new WireError('closed'); + encodePath(path); + const child = path.length ? routes.get(path[0]!) : undefined; + if (!child) throw new WireError('no_route'); + return child; + }; + const release = (child: ChildAttachment): void => { + child.active = false; + const detach = child.detach; + child.detach = undefined; + detach?.(); + }; + const remove = (held: Attachment, ending?: { code: number; reason: string }): void => { + if (!held.active) return; + held.active = false; + if (attachment === held) attachment = undefined; + for (const child of held.children) release(child); + if (ending) held.receiver.closed?.(ending.code, ending.reason); + }; + const receive = (receiver: Receiver): (() => void) => { + if (closed) throw new WireError('closed'); + if (attachment) throw new WireError('receiver_exists'); + const held: Attachment = { receiver, active: true, children: [] }; + attachment = held; + let remaining = routes.size; + try { + for (const [key, child] of routes) { + const slot: ChildAttachment = { active: true }; + held.children.push(slot); + const detach = child.receive({ + // The child's runtime owns admission and captured cancellation. Keep + // its captured receiver even after this attachment is detached. + message: (suffix, message) => receiver.message?.([key, ...suffix], message), + closed: (code, reason) => { + if (!held.active || !slot.active) return; + remaining--; + release(slot); + if (remaining === 0) remove(held, { code, reason }); + }, + }); + // A child may synchronously end or close this mount while receiving. + // Its returned disposer still belongs to this acquisition attempt. + if (!held.active || !slot.active) { + detach(); + throw new WireError('closed'); + } + slot.detach = detach; + } + } catch (error) { + remove(held); + throw error; + } + return () => remove(held); + }; + return { + send: (path, message) => destination(path).send(path.slice(1), message), + receive, + close: (code = 1000, reason = '') => { + if (closed) return; + closed = true; + if (attachment) remove(attachment, { code, reason }); + }, + }; +} diff --git a/core/ts/src/error.ts b/core/ts/src/error.ts new file mode 100644 index 0000000..0bfef65 --- /dev/null +++ b/core/ts/src/error.ts @@ -0,0 +1,29 @@ +/** Public application errors may cross the wire; other handler errors are hidden. */ +export class DuplexError extends Error { + /** The error's code as it travels on the wire: the profile's own, or a family's public error by name. */ + readonly code: string; + /** What a public error carries beside its message, validated as the family declares it. */ + readonly data?: unknown; + + constructor(code: string, message: string, data?: unknown) { + super(message); + this.name = 'DuplexError'; + this.code = code; + this.data = data; + } +} + +/** A local refusal before a frame entered the queue or a local implementation dispatched. */ +export class UnpublishedError extends DuplexError { + override readonly cause: unknown; + + constructor(cause: unknown) { + super( + cause instanceof DuplexError ? cause.code : 'send_failed', + cause instanceof Error ? cause.message : 'Duplex operation failed.', + cause instanceof DuplexError ? cause.data : undefined, + ); + this.name = 'UnpublishedError'; + this.cause = cause; + } +} diff --git a/core/ts/src/invocation.ts b/core/ts/src/invocation.ts new file mode 100644 index 0000000..236b8bb --- /dev/null +++ b/core/ts/src/invocation.ts @@ -0,0 +1,363 @@ +import type { Message, Path, Wire } from '@nightseam/duplex'; + +/** + * An admitted request's return capability is the invocation, presented as a + * Wire. The empty path carries its outcome, as it always has; these operations + * carry its lifecycle. They are ordinary events of the profile โ€” a layer's own + * vocabulary, as `channel.` is the tunnel's โ€” and a participant needs nothing + * of Nightseam's to speak them but the Wire it was already handed. + * + * The capture or body a verb is about is one opaque segment after the + * operation, because a path is what addresses a thing. The verbs never reach a + * peer root and never cross a physical hop, so they take no built-in family and + * reserve no namespace there; a return capability's path space is the + * invocation's alone. + */ +export const invocationCapture = 'invocation.capture'; +/** The captured request has been delivered; a latched control reaches it now. */ +export const invocationReady = 'invocation.ready'; +/** Drop a capture whose traversal wants no more controls. */ +export const invocationRelease = 'invocation.release'; +/** Take one execution lease. Admission is the lease. */ +export const invocationBegin = 'invocation.begin'; +/** Report that an executing body actually finished. */ +export const invocationDone = 'invocation.done'; +/** Relay a cancellation into the invocation, which latches and pushes it. */ +export const invocationControl = 'invocation.control'; + +/** Bounds the captures of one admitted invocation, over depth and fan-out. */ +export const defaultInvocationCaptures = 64; +/** Bounds the execution leases of one admitted invocation. */ +export const defaultInvocationBodies = 64; + +/** Why participation was refused. */ +export type InvocationRefusal = 'unsupported' | 'ended' | 'limit' | 'duplicate' | 'invalid'; + +export class InvocationError extends Error { + readonly code: InvocationRefusal; + constructor(code: InvocationRefusal) { + super(`Invocation ${code}`); + this.code = code; + } +} + +export interface InvocationLimits { + captures: number; + bodies: number; +} + +/** The bounds an admitting runtime uses when it states none of its own. */ +export function defaultInvocationLimits(): InvocationLimits { + return { captures: defaultInvocationCaptures, bodies: defaultInvocationBodies }; +} + +let identifiers = 0; +const nextIdentifier = (): string => String(++identifiers); +const event = (): Message => ({ frame: { version: 1, kind: 'event', data: null } }); + +interface Capture { + sink: Wire; + ready: boolean; + notified: boolean; +} + +/** + * The lifecycle an admitting runtime keeps for one admitted request, and the + * answer its return capability gives to the vocabulary above. A runtime that is + * not Nightseam's composes it โ€” or answers the same paths itself โ€” and the same + * participants work against either. + */ +export class Invocation { + readonly #limits: InvocationLimits; + #captures = new Map(); + #bodies = new Set(); + #taken = 0; + #begun = 0; + #unready = 1; + #controls = 0; + #control: Message | undefined; + #settled = false; + #dispatchDone = false; + #retired = false; + #onRetired: (() => void) | undefined; + + constructor(limits: InvocationLimits = defaultInvocationLimits(), onRetired?: () => void) { + const captures = + Number.isSafeInteger(limits.captures) && limits.captures > 0 ? limits.captures : defaultInvocationCaptures; + const bodies = Number.isSafeInteger(limits.bodies) && limits.bodies > 0 ? limits.bodies : defaultInvocationBodies; + this.#limits = { captures, bodies }; + this.#onRetired = onRetired; + } + + /** Whether the invocation has released its captures. */ + get retired(): boolean { + return this.#retired; + } + + /** + * Answers one operation of the invocation vocabulary. A return capability + * routes every nonempty path here; the empty path stays its own. + */ + deliver(path: Path, message: Message): void { + if (path.length === 0) throw new InvocationError('unsupported'); + if (path[0] === invocationControl) { + if (path.length !== 1 || message.frame.kind !== 'cancel') throw new InvocationError('unsupported'); + this.#latch(message); + return; + } + if (path.length !== 2 || message.frame.kind !== 'event') throw new InvocationError('unsupported'); + const identifier = path[1]!; + switch (path[0]) { + case invocationCapture: { + const sink = message.return?.wire; + if (!sink) throw new InvocationError('unsupported'); + this.#capture(identifier, sink); + return; + } + case invocationReady: + this.#ready(identifier); + return; + case invocationRelease: + this.#release(identifier); + return; + case invocationBegin: + this.#begin(identifier); + return; + case invocationDone: + this.#done(identifier); + return; + default: + throw new InvocationError('unsupported'); + } + } + + /** Fixes the outcome. It neither finishes a body nor drains a control. */ + settle(): void { + this.#settled = true; + this.#retire(); + } + + /** The admitted request's own delivery has returned. */ + dispatchDone(): void { + if (!this.#dispatchDone) { + this.#dispatchDone = true; + this.#unready--; + } + this.#retire(); + } + + #capture(identifier: string, sink: Wire): void { + if (this.#retired) throw new InvocationError('ended'); + if (this.#captures.has(identifier)) throw new InvocationError('duplicate'); + if (this.#taken >= this.#limits.captures) throw new InvocationError('limit'); + this.#taken++; + this.#unready++; + this.#captures.set(identifier, { sink, ready: false, notified: false }); + } + + #ready(identifier: string): void { + const capture = this.#captures.get(identifier); + if (!capture || capture.ready) return; + capture.ready = true; + this.#unready--; + if (this.#control && !capture.notified) { + capture.notified = true; + this.#controls++; + this.#push([capture.sink], this.#control); + return; + } + this.#retire(); + } + + #release(identifier: string): void { + const capture = this.#captures.get(identifier); + if (!capture) return; + if (!capture.ready) { + capture.ready = true; + this.#unready--; + } + this.#captures.delete(identifier); + this.#retire(); + } + + #begin(identifier: string): void { + if (this.#retired) throw new InvocationError('ended'); + if (this.#bodies.has(identifier)) throw new InvocationError('duplicate'); + if (this.#begun >= this.#limits.bodies) throw new InvocationError('limit'); + this.#begun++; + this.#bodies.add(identifier); + } + + #done(identifier: string): void { + if (!this.#bodies.delete(identifier)) return; + this.#retire(); + } + + /** + * Latches the first cancellation and pushes it to every capture already + * ready. A capture installed while it is latched receives it when its own + * request delivery becomes ready. Further controls coalesce. + */ + #latch(message: Message): void { + if (this.#retired || this.#control) return; + this.#control = message; + const sinks: Wire[] = []; + for (const capture of this.#captures.values()) { + if (!capture.ready || capture.notified) continue; + capture.notified = true; + sinks.push(capture.sink); + } + this.#controls++; + this.#push(sinks, message); + } + + /** + * Runs participant code while one control reservation is held, so that + * retirement cannot reclaim a capture a control is still reaching. + */ + #push(sinks: readonly Wire[], message: Message): void { + try { + for (const sink of sinks) { + try { + sink.send([], message); + } catch { + /* A failed participant cannot stop its siblings being told. */ + } + } + } finally { + this.#controls--; + this.#retire(); + } + } + + #retire(): void { + if (this.#retired || !this.#settled || this.#unready !== 0 || this.#bodies.size !== 0 || this.#controls !== 0) + return; + this.#retired = true; + this.#captures = new Map(); + this.#bodies = new Set(); + this.#control = undefined; + const notify = this.#onRetired; + this.#onRetired = undefined; + notify?.(); + } +} + +/** The Wire a capture is pushed its control through, and nothing else. */ +class InvocationSink implements Wire { + readonly #control: (message: Message) => void; + constructor(control: (message: Message) => void) { + this.#control = control; + } + send(path: Path, message: Message): void { + if (path.length !== 0 || message.frame.kind !== 'cancel') throw new InvocationError('invalid'); + this.#control(message); + } +} + +const invocationWire = (message: Message): Wire => { + const wire = message.return?.wire; + if (!wire) throw new InvocationError('unsupported'); + return wire; +}; + +/** + * One immutable routing decision a participant took for one traversal of one + * admitted invocation. Repeated traversal of the same dispatcher takes a fresh + * handle, so no two traversals share a slot. + */ +export class InvocationCaptureHandle { + readonly #wire: Wire; + readonly #identifier: string; + #ready = false; + #released = false; + constructor(wire: Wire, identifier: string) { + this.#wire = wire; + this.#identifier = identifier; + } + /** The captured request has been delivered; a latched control reaches it. */ + ready(): void { + if (this.#ready) return; + this.#ready = true; + try { + this.#wire.send([invocationReady, this.#identifier], event()); + } catch { + /* A refusing lifecycle simply keeps no capture to tell. */ + } + } + /** Drops the capture. Both operations are idempotent. */ + release(): void { + if (this.#released) return; + this.#released = true; + try { + this.#wire.send([invocationRelease, this.#identifier], event()); + } catch { + /* As above. */ + } + } +} + +/** + * Claims a routing decision for this traversal and supplies the sink the + * invocation pushes its cancellation to. The sink receives the control at most + * once, after ready() and never before. + * + * A return capability that does not speak the vocabulary refuses, and the + * refusal is the caller's to answer: routing an invocation-aware request with + * weaker cancellation guarantees is exactly what this reports instead. + */ +export function captureInvocation(message: Message, control: (message: Message) => void): InvocationCaptureHandle { + const wire = invocationWire(message); + const identifier = nextIdentifier(); + wire.send([invocationCapture, identifier], { + frame: { version: 1, kind: 'event', data: null }, + return: { wire: new InvocationSink(control) }, + }); + return new InvocationCaptureHandle(wire, identifier); +} + +/** One execution lease of one admitted invocation. */ +export class InvocationBodyHandle { + readonly #wire: Wire; + readonly #identifier: string; + #done = false; + constructor(wire: Wire, identifier: string) { + this.#wire = wire; + this.#identifier = identifier; + } + /** + * Reports that the body actually finished. It is idempotent and releases only + * the lease it took: a participant cannot finish another owner's work. + */ + done(): void { + if (this.#done) return; + this.#done = true; + try { + this.#wire.send([invocationDone, this.#identifier], event()); + } catch { + /* A refusing lifecycle holds no lease to release. */ + } + } +} + +/** + * Takes an execution lease for work this participant owns. The invocation does + * not retire while the lease is held, so an early answer to the caller โ€” a + * deadline, a withdrawal โ€” never retires an invocation whose body still runs. + */ +export function beginInvocationBody(message: Message): InvocationBodyHandle { + const wire = invocationWire(message); + const identifier = nextIdentifier(); + wire.send([invocationBegin, identifier], event()); + return new InvocationBodyHandle(wire, identifier); +} + +/** + * Hands a cancellation to the invocation it names, which latches it and pushes + * it to the traversals that captured it. A router that receives a control frame + * relays it here rather than resolving a route of its own: the capture, not the + * current registration, decides where it goes. + */ +export function relayInvocationControl(message: Message): void { + invocationWire(message).send([invocationControl], message); +} diff --git a/core/ts/src/pair.ts b/core/ts/src/pair.ts new file mode 100644 index 0000000..dafb9d3 --- /dev/null +++ b/core/ts/src/pair.ts @@ -0,0 +1,409 @@ +import { encodePath, WireError } from '@nightseam/duplex'; +import type { Message, Path, Receiver, ReturnAddress, Wire, Endpoint } from '@nightseam/duplex'; +import { Invocation, defaultInvocationLimits } from './invocation.ts'; +import { DUPLEX_DEFAULTS, positiveInteger } from './peer.ts'; +import type { PeerOptions } from './peer.ts'; +import { DuplexError } from './error.ts'; +import { defaultPropagator, traceOf } from './trace.ts'; +import type { ObserverEvent } from './observer.ts'; +import { + profileFrame, + publicError, + response, + wireContext, + setWireContext, + wireEventContext, + withWireEventContext, +} from './wire.ts'; +import type { WireDispatchContext, WireRequestContext, WireEventContext } from './wire.ts'; + +interface Registration { + receiver: Receiver; +} +interface LocalCall { + id: string; + original: Message; + path: Path; + returning: ReturnAddress; + invocation: Invocation; + source?: WireDispatchContext; + cleanup: () => void; + controller: AbortController; + registration?: Registration; + timer?: ReturnType; + completed: boolean; + responded: boolean; + active: boolean; + cancelQueued: boolean; + cancelled: boolean; +} +interface Delivery { + path: Path; + message: Message; + call?: LocalCall; + refusal?: DuplexError; +} +// One end of a bounded local pair: the Endpoint it presents, what it owes the +// other end, and the state its own admission keeps. +interface PairEnd { + wire: Endpoint; + other: PairEnd; + queue: Delivery[]; + queued: number; + retained: number; + active: number; + eventTimer?: ReturnType; + draining: boolean; + calls: Map>; + attachment?: Registration; +} + +/** + * A bounded local carrier. Sending on one endpoint delivers asynchronously to + * receivers on the other. No Peer, transport connection, or request protocol + * is constructed; the runtime's existing return capability carries responses. + */ +export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { + const limits = { ...DUPLEX_DEFAULTS }; + for (const key of Object.keys(DUPLEX_DEFAULTS) as (keyof typeof DUPLEX_DEFAULTS)[]) { + if (options[key] !== undefined) { + positiveInteger(options[key], key, true); + (limits as Record)[key] = options[key]!; + } + } + const propagator = options.propagator ?? defaultPropagator; + let closed = false; + const ends: PairEnd[] = []; + const disconnected = () => new DuplexError('disconnected', 'Connection ended; outcome may be unknown.'); + const observe = (event: ObserverEvent) => { + try { + options.observer?.observe(event); + } catch { + /* Observers own their failures. */ + } + }; + const end = (code = 1000, reason = '') => { + if (closed) return; + closed = true; + const receivers: Receiver[] = [], + requests: Message[] = []; + for (const endpoint of ends) { + clearTimeout(endpoint.eventTimer); + if (endpoint.attachment) receivers.push(endpoint.attachment.receiver); + for (const calls of endpoint.calls.values()) + for (const call of calls.values()) { + clearTimeout(call.timer); + call.controller.abort(); + call.cleanup(); + call.invocation.settle(); + call.invocation.dispatchDone(); + if (!call.responded) requests.push(call.original); + call.responded = call.completed = true; + } + delete endpoint.attachment; + endpoint.calls.clear(); + endpoint.queue.length = endpoint.queued = endpoint.retained = endpoint.active = 0; + } + observe({ type: 'connection.closed', at: new Date(), code, reason, local: true }); + queueMicrotask(() => { + for (const receiver of receivers) { + try { + receiver.closed?.(code, reason); + } catch { + /* Every receiver gets closure. */ + } + } + for (const request of requests) response(request, undefined, disconnected()); + }); + }; + const fail = (error: DuplexError) => { + try { + options.onError?.(error); + } catch { + /* Diagnostics do not interrupt closure. */ + } + end(4011, error.message); + }; + const retire = (endpoint: PairEnd, call: LocalCall) => { + if (!call.completed || call.cancelQueued) return; + const calls = endpoint.calls.get(call.original.return!); + if (calls?.get(call.id) !== call) return; + calls.delete(call.id); + if (!calls.size) endpoint.calls.delete(call.original.return!); + endpoint.retained--; + call.cleanup(); + call.invocation.settle(); + call.invocation.dispatchDone(); + }; + const complete = (endpoint: PairEnd, call: LocalCall) => { + if (!call.completed) { + call.completed = true; + clearTimeout(call.timer); + call.controller.abort(); + if (call.active) { + endpoint.active--; + call.active = false; + } + } + retire(endpoint, call); + }; + + const invoke = (registration: Registration, path: Path, message: Message): void | Promise => { + try { + return registration.receiver.message!(path, message); + } catch (error) { + if (message.frame.kind === 'request') response(message, undefined, publicError(error)); + else fail(publicError(error)); + } + }; + const deliver = async (endpoint: PairEnd) => { + try { + while (endpoint.queue.length && !closed) { + const { path, message, call, refusal } = endpoint.queue.shift()!; + const frame = message.frame; + if (frame.kind === 'cancel') { + call!.cancelQueued = false; + call!.cancelled = true; + call!.controller.abort(); + if (!call!.completed && call!.registration) { + const pending = invoke(call!.registration, path, message); + if (pending) void pending.catch((error: unknown) => fail(publicError(error))); + } + retire(endpoint, call!); + continue; + } + endpoint.queued--; + if (refusal) { + response(message, undefined, refusal); + continue; + } + const registration = endpoint.attachment?.receiver.message ? endpoint.attachment : undefined; + if (frame.kind === 'event') { + if (registration) { + let delivered = message; + if (!wireEventContext(message)) { + const context: WireEventContext = { wire: endpoint.wire }; + propagator.extract(context, traceOf(frame)); + delivered = withWireEventContext(message, context); + } + endpoint.eventTimer = setTimeout(() => { + observe({ + type: 'backpressure', + at: new Date(), + queued: endpoint.queued, + stalled: true, + deadlineMs: limits.writeTimeoutMs, + }); + fail(new DuplexError('stalled_consumer', 'Local wire event handler deadline exceeded.')); + }, limits.writeTimeoutMs); + try { + await invoke(registration, path, delivered); + } catch (error) { + fail(publicError(error)); + } finally { + clearTimeout(endpoint.eventTimer); + endpoint.eventTimer = undefined; + } + } + continue; + } + if (frame.kind !== 'request') continue; + if (!registration || endpoint.active >= limits.maxConcurrentHandlers) { + response( + message, + undefined, + new DuplexError( + registration ? 'busy' : 'method_not_found', + registration ? 'Incoming request limit reached.' : 'Unknown method.', + ), + ); + continue; + } + endpoint.active++; + call!.active = true; + call!.registration = registration; + const context = Object.create(call!.source?.context ?? null) as WireRequestContext; + Object.defineProperties(context, { + wire: { value: endpoint.wire, enumerable: true }, + signal: { + value: call!.source + ? AbortSignal.any([call!.controller.signal, call!.source.context.signal]) + : call!.controller.signal, + enumerable: true, + }, + requestId: { value: call!.source?.context.requestId ?? frame.id, enumerable: true }, + ...(frame.meta ? { meta: { value: { ...frame.meta }, enumerable: true } } : {}), + }); + if (!call!.source) propagator.extract(context, traceOf(frame)); + call!.cleanup = setWireContext(call!.returning, { + context, + completion: call!.source?.completion, + maxFrameBytes: limits.maxFrameBytes, + panic: + call!.source?.panic ?? + ((error) => + observe({ + type: 'handler.panic', + at: new Date(), + method: encodePath(path), + value: String(error), + trace: traceOf(frame), + family: options.families?.[encodePath(path)] ?? '', + })), + }); + call!.timer = setTimeout(() => { + if (closed || call!.completed) return; + call!.controller.abort(); + if (!call!.cancelQueued && !call!.cancelled) { + call!.cancelQueued = true; + endpoint.queue.push({ + path, + message: { frame: { version: 1, kind: 'cancel', id: frame.id }, return: call!.returning }, + call, + }); + schedule(endpoint); + } + // A timeout answers once but retains the handler slot until its + // actual response, bounding applications that ignore cancellation. + if (!call!.responded) { + call!.responded = true; + response(call!.original, undefined, new DuplexError('cancelled', 'Request deadline exceeded.')); + } + }, limits.requestTimeoutMs); + const pending = invoke(registration, path, message); + if (pending) void pending.catch((error: unknown) => response(message, undefined, publicError(error))); + } + } finally { + endpoint.draining = false; + if (endpoint.queue.length && !closed) schedule(endpoint); + } + }; + const schedule = (endpoint: PairEnd) => { + if (endpoint.draining || closed) return; + endpoint.draining = true; + queueMicrotask(() => { + void deliver(endpoint); + }); + }; + const admit = (endpoint: PairEnd, path: Path, original: Message) => { + if (closed) throw disconnected(); + const name = encodePath(path), + frame = profileFrame(original.frame, name, limits.maxFrameBytes); + if (frame.kind !== 'request' && frame.kind !== 'event' && frame.kind !== 'cancel') + throw new DuplexError('invalid_message', "A response is sent to its request's return address."); + if (frame.kind !== 'event' && !original.return?.wire) + throw new DuplexError('invalid_message', 'A wire request or cancellation requires a return address.'); + const message: Message = { frame, return: original.return }; + let call: LocalCall | undefined, refusal: DuplexError | undefined; + if (frame.kind === 'cancel') { + call = endpoint.calls.get(message.return!)?.get(frame.id); + if (!call || call.completed || call.cancelQueued || call.cancelled) return; + call.cancelQueued = true; + } else { + if (endpoint.queued >= limits.queueCapacity) { + const error = new DuplexError('busy', 'Local wire queue limit reached.'); + observe({ + type: 'backpressure', + at: new Date(), + queued: endpoint.queued, + stalled: true, + deadlineMs: limits.writeTimeoutMs, + }); + fail(error); + throw error; + } + endpoint.queued++; + if (frame.kind === 'request') { + let calls = endpoint.calls.get(message.return!); + if (calls?.has(frame.id)) refusal = new DuplexError('invalid_message', 'Duplicate active request identifier.'); + else if (endpoint.retained >= limits.maxPendingRequests) + refusal = new DuplexError('busy', 'Outstanding call limit reached.'); + else { + const invocation = new Invocation(defaultInvocationLimits()); + const returning: ReturnAddress = { + wire: { + send: (suffix, reply) => { + if (suffix.length) { + invocation.deliver(suffix, reply); + return; + } + if (reply.frame.kind !== 'response' || reply.frame.id !== frame.id) + throw new DuplexError('invalid_message', 'Invalid wire response.'); + // A refused encoding may be retried as the shared bounded + // internal-error fallback; only an admitted response completes. + const checked = profileFrame(reply.frame, '', limits.maxFrameBytes); + try { + if (call!.responded || call!.completed) throw disconnected(); + call!.responded = true; + try { + message.return!.wire.send([], { frame: checked }); + } catch (error) { + throw error instanceof DuplexError ? publicError(error) : error; + } + invocation.settle(); + } finally { + complete(endpoint, call!); + } + }, + }, + }; + call = { + id: frame.id, + original: message, + path: [...path], + returning, + invocation, + source: wireContext(message.return!), + cleanup: () => {}, + controller: new AbortController(), + completed: false, + responded: false, + active: false, + cancelQueued: false, + cancelled: false, + }; + if (!calls) { + calls = new Map(); + endpoint.calls.set(message.return!, calls); + } + calls.set(frame.id, call); + endpoint.retained++; + } + } + } + endpoint.queue.push({ + path: [...path], + message: call ? { frame, return: call.returning } : message, + call, + refusal, + }); + schedule(endpoint); + }; + for (let i = 0; i < 2; i++) { + const endpoint = { + queue: [], + queued: 0, + retained: 0, + active: 0, + draining: false, + calls: new Map(), + } as unknown as PairEnd; + endpoint.wire = { + send: (path, message) => admit(endpoint.other, path, message), + receive: (receiver) => { + if (closed) throw disconnected(); + if (endpoint.attachment) throw new WireError('receiver_exists'); + const registration = { receiver }; + endpoint.attachment = registration; + return () => { + if (endpoint.attachment === registration) delete endpoint.attachment; + }; + }, + close: end, + }; + ends.push(endpoint); + } + ends[0]!.other = ends[1]!; + ends[1]!.other = ends[0]!; + return [ends[0]!.wire, ends[1]!.wire]; +} diff --git a/core/ts/src/trace.ts b/core/ts/src/trace.ts new file mode 100644 index 0000000..1fa6b02 --- /dev/null +++ b/core/ts/src/trace.ts @@ -0,0 +1,78 @@ +/** + * W3C Trace Context as the wire carries it: the two members verbatim, nothing + * invented and nothing normalised. An absent member is the empty string, which + * a frame never carries; a traceparent is the one of the two the peer holds to + * a form, and the propagator decides what either of them means. + */ +export interface Trace { + traceparent: string; + tracestate?: string; +} + +/** The trace-bearing part shared by physical-peer and opaque-wire contexts. */ +export interface TraceContext { + trace?: Trace; +} + +/** + * Where an incoming trace goes and what an outgoing frame carries. The runtime + * imports no tracing library: an adapter for one replaces this hook, and the + * default below correlates without it. + */ +export interface Propagator { + /** Places an incoming frame's trace on the context its handler runs with. */ + extract(context: TraceContext, trace: Trace | undefined): void; + /** What an outgoing frame carries: a child of the context's trace, or a new trace. */ + inject(context: TraceContext | undefined): Trace; +} + +/** `version-traceid-spanid-flags`, the one form the profile accepts, in its parts. */ +const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-[0-9a-f]{16}-([0-9a-f]{2})$/; + +/** + * Correlation with no tracing library installed: a new trace is a random + * 16-byte trace id and 8-byte span id, sampled; a child keeps its parent's + * version, trace id and flags and mints a span id of its own, and carries the + * parent's tracestate verbatim. + */ +export const defaultPropagator: Propagator = { + extract(context, trace) { + // A tracestate that arrives without a traceparent continues no trace, as + // the specification says; the response to its frame still carries it back. + if (trace?.traceparent) context.trace = trace; + }, + inject(context) { + const trace = context?.trace; + if (trace) { + const parent = TRACEPARENT.exec(trace.traceparent); + if (parent) { + const child: Trace = { traceparent: `${parent[1]}-${parent[2]}-${randomHex(8)}-${parent[3]}` }; + if (trace.tracestate !== undefined) child.tracestate = trace.tracestate; + return child; + } + } + return { traceparent: `00-${randomHex(16)}-${randomHex(8)}-01` }; + }, +}; + +/** The members an incoming frame carries, verbatim: what a propagator extracts. */ +export function traceOf(frame: { readonly traceparent?: unknown; readonly tracestate?: unknown }): Trace | undefined { + const traceparent = typeof frame.traceparent === 'string' ? frame.traceparent : ''; + const tracestate = typeof frame.tracestate === 'string' ? frame.tracestate : ''; + if (!traceparent && !tracestate) return undefined; + return tracestate ? { traceparent, tracestate } : { traceparent }; +} + +/** Stamps onto an outgoing frame what a propagator minted; an empty member is none. */ +export function traced(envelope: Record, trace: Trace | undefined): Record { + if (trace?.traceparent) envelope.traceparent = trace.traceparent; + if (trace?.tracestate) envelope.tracestate = trace.tracestate; + return envelope; +} + +/** Web Crypto is the only source; the runtime takes no dependency for it. */ +function randomHex(bytes: number): string { + return Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} diff --git a/core/ts/src/unicode.ts b/core/ts/src/unicode.ts new file mode 100644 index 0000000..7b2cf50 --- /dev/null +++ b/core/ts/src/unicode.ts @@ -0,0 +1,45 @@ +/** Unicode is checked before parsing or serialization can discard a string. */ +const refusal = 'invalid Unicode: expected Unicode scalar strings'; + +export function scalarString(value: string): void { + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xdc00 && unit <= 0xdfff) throw new Error(refusal); + if (unit < 0xd800 || unit > 0xdbff) continue; + const low = value.charCodeAt(++i); + if (!(low >= 0xdc00 && low <= 0xdfff)) throw new Error(refusal); + } +} + +export function scalarValue(value: unknown, seen = new Set()): void { + if (typeof value === 'string') { + scalarString(value); + return; + } + if (value === null || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + for (const key of Object.keys(value)) { + scalarString(key); + scalarValue((value as Record)[key], seen); + } + seen.delete(value); +} + +/** Checks Unicode in original JSON text, including duplicate members a + * decoder would discard. The caller still validates syntax and envelope. */ +export function scalarJSON(text: string): void { + scalarString(text); + let start = -1; + for (let i = 0; i < text.length; i++) { + if (start >= 0 && text[i] === '\\') { + i++; + continue; + } + if (text[i] !== '"') continue; + if (start < 0) start = i; + else { + scalarString(JSON.parse(text.slice(start, i + 1)) as string); + start = -1; + } + } +} diff --git a/dispatch/ts/src/dispatcher.ts b/dispatch/ts/src/dispatcher.ts new file mode 100644 index 0000000..59c8719 --- /dev/null +++ b/dispatch/ts/src/dispatcher.ts @@ -0,0 +1,221 @@ +import { encodePath, WireError } from '@nightseam/duplex'; +import type { Endpoint, Message, Path, Receiver, Wire } from '@nightseam/duplex'; +import { DuplexError } from './error.ts'; +import { InvocationError, captureInvocation, relayInvocationControl } from './invocation.ts'; +import { response } from './wire.ts'; + +/** Registration authority; close releases this registry, never its borrowed carrier. */ +export interface HandlerRegistry extends Wire { + register(path: Path, receiver: Receiver): () => void; + close(code?: number, reason?: string): void; +} +interface Registration { + path: Path; + receiver: Receiver; +} + +/** One attachment's options. */ +export interface DispatcherOptions { + /** Explicit closure authority over an endpoint owned by the caller. */ + ownEndpoint?: boolean; +} +/** + * One attachment, with explicit exact-before-longest-prefix dispatch. Each + * request's traversal is captured on the invocation its return capability + * carries, through the public vocabulary alone; a request whose return + * capability carries none is refused rather than routed with weaker detach and + * cancellation guarantees. An opaque wrapper is therefore as good as a native + * endpoint: the lifecycle travels with the unchanged return capability, and + * nothing here recognizes a concrete type. + */ +export class WireDispatcher implements HandlerRegistry { + private readonly exact = new Map(); + private readonly prefixes = new Map(); + private ended = false; + private detach: (() => void) | undefined; + private readonly root: Endpoint; + private readonly ownEndpoint: boolean; + + constructor(root: Endpoint, options: DispatcherOptions = {}) { + this.root = root; + this.ownEndpoint = options.ownEndpoint ?? false; + const detach = root.receive({ + message: (path, message) => this.deliver(path, message), + closed: (code, reason) => this.close(code, reason), + }); + if (this.ended) { + detach(); + throw new WireError('closed'); + } + this.detach = detach; + } + send(path: Path, message: Message): void { + if (this.ended) throw new WireError('closed'); + this.root.send(path, message); + } + register(path: Path, receiver: Receiver): () => void { + return this.install(path, receiver, this.exact); + } + registerPrefix(path: Path, receiver: Receiver): () => void { + return this.install(path, receiver, this.prefixes); + } + private install(path: Path, receiver: Receiver, routes: Map): () => void { + const name = encodePath(path); + if (this.ended) throw new WireError('closed'); + if (routes.has(name)) throw new WireError('receiver_exists'); + const registration = { path: [...path], receiver }; + routes.set(name, registration); + return () => { + if (routes.get(name) === registration) routes.delete(name); + }; + } + private match(path: Path, name: string): Registration | undefined { + const exact = this.exact.get(name); + if (exact) return exact; + let selected: Registration | undefined; + for (const candidate of this.prefixes.values()) { + if ( + candidate.path.length <= path.length && + (!selected || candidate.path.length > selected.path.length) && + candidate.path.every((part, index) => part === path[index]) + ) + selected = candidate; + } + return selected; + } + private deliver(path: Path, message: Message): void | Promise { + const name = encodePath(path); + // A control belongs to the traversal that captured it, never to the + // registration in force now. Handing it to the invocation is what keeps a + // detach or a rebind from retargeting an admitted request. + if (message.frame.kind === 'cancel') { + try { + relayInvocationControl(message); + } catch { + /* A carrier with no lifecycle has nothing to route it to. */ + } + return; + } + const registration = this.ended ? undefined : this.match(path, name); + if (!registration?.receiver.message) { + if (message.frame.kind === 'request') + response(message, undefined, new DuplexError('method_not_found', 'Unknown method.')); + return; + } + const delivered = [...path]; + if (message.frame.kind !== 'request') return registration.receiver.message(delivered, message); + let capture; + try { + capture = captureInvocation(message, (control) => { + void registration.receiver.message?.([...delivered], control); + }); + } catch (error) { + // A bound reached is a refusal to try again at; a capability that + // carries no lifecycle is a request this dispatcher cannot route with + // the guarantees it advertises. + response( + message, + undefined, + error instanceof InvocationError && error.code === 'limit' + ? new DuplexError('busy', 'Invocation participation limit reached.') + : new DuplexError('invalid_message', 'Invocation requires the lifecycle its return capability carries.'), + ); + return; + } + let pending: void | Promise; + try { + pending = registration.receiver.message(delivered, message); + } catch (error) { + capture.ready(); + throw error; + } + if (!pending) { + capture.ready(); + return; + } + return pending.finally(() => capture.ready()); + } + + select(path: Path): SelectedEndpoint { + return new SelectedEndpoint(this, [...path]); + } + close(code = 1000, reason = ''): void { + if (this.ended) return; + this.ended = true; + const detach = this.detach; + this.detach = undefined; + const ending = [...this.exact.values(), ...this.prefixes.values()]; + this.exact.clear(); + this.prefixes.clear(); + detach?.(); + for (const registration of ending) { + try { + registration.receiver.closed?.(code, reason); + } catch { + /* Each owner receives its end. */ + } + } + if (this.ownEndpoint) this.root.close(code, reason); + } +} +export function createDispatcher(endpoint: Endpoint, options: DispatcherOptions = {}): WireDispatcher { + return new WireDispatcher(endpoint, options); +} + +interface Attachment { + receiver: Receiver; + detach?: () => void; +} +/** A route owned by one dispatcher, with no borrowed-root closure authority. */ +export class SelectedEndpoint implements Endpoint { + private ended = false; + private attachment: Attachment | undefined; + private readonly owner: WireDispatcher; + private readonly prefix: Path; + constructor(owner: WireDispatcher, prefix: Path) { + this.owner = owner; + this.prefix = prefix; + } + select(path: Path): SelectedEndpoint { + return this.owner.select([...this.prefix, ...path]); + } + send(path: Path, message: Message): void { + if (this.ended) throw new WireError('closed'); + this.owner.send([...this.prefix, ...path], message); + } + receive(receiver: Receiver): () => void { + if (this.ended) throw new WireError('closed'); + if (this.attachment) throw new WireError('receiver_exists'); + const attachment: Attachment = { receiver }; + this.attachment = attachment; + try { + const detach = this.owner.registerPrefix(this.prefix, { + message: (path, message) => { + if (receiver.message) return receiver.message(path.slice(this.prefix.length), message); + if (message.frame.kind === 'request') + response(message, undefined, new DuplexError('method_not_found', 'Unknown method.')); + }, + closed: (code, reason) => this.remove(attachment, { code, reason }), + }); + if (this.attachment !== attachment) { + detach(); + throw new WireError('closed'); + } + attachment.detach = detach; + } catch (error) { + if (this.attachment === attachment) this.attachment = undefined; + throw error; + } + return () => this.remove(attachment); + } + private remove(attachment: Attachment, ending?: { code: number; reason: string }): void { + if (this.attachment !== attachment) return; + this.attachment = undefined; + attachment.detach?.(); + if (ending) attachment.receiver.closed?.(ending.code, ending.reason); + } + close(code = 1000, reason = ''): void { + this.ended = true; + if (this.attachment) this.remove(this.attachment, { code, reason }); + } +} diff --git a/dispatch/ts/src/wire.ts b/dispatch/ts/src/wire.ts new file mode 100644 index 0000000..474bee9 --- /dev/null +++ b/dispatch/ts/src/wire.ts @@ -0,0 +1,896 @@ +import { decodePath, encodePath, WireError } from '@nightseam/duplex'; +import type { Message, Path, ProfileFrame, Receiver, ReturnAddress, Wire, Endpoint } from '@nightseam/duplex'; +import { type HandlerRegistry } from './dispatcher.ts'; +import { Invocation, InvocationError, beginInvocationBody, defaultInvocationLimits } from './invocation.ts'; +import { DuplexError, UnpublishedError } from './error.ts'; +import { carrying, decodeEnvelope, isObject } from './envelope.ts'; +import { scalarJSON } from './unicode.ts'; +import { defaultPropagator, traceOf } from './trace.ts'; +import type { ValueEnvironment } from './value-adapter.ts'; +import type { Propagator, Trace, TraceContext } from './trace.ts'; +import type { Observer } from './observer.ts'; +import { observeWire, observeWireRequest } from './wire-observer.ts'; +import type { + CallOptions, + DuplexPeer, + EmitOptions, + EventContext, + EventListener, + Meta, + PeerOptions, + RequestContext, + RequestHandler, +} from './peer.ts'; + +// Outgoing propagators may privately associate their trace object with an +// active consumer context. Keep that identity across local frame snapshots; +// only the two public trace strings cross a physical connection. +const outgoingTraces = new WeakMap(); +function outgoingTrace(frame: ProfileFrame): Trace | undefined { + return outgoingTraces.get(frame) ?? traceOf(frame); +} + +/** @internal Received values retained beside a local return capability. */ +export interface WireDispatchContext { + context: RequestContext | WireRequestContext; + panic: (error: unknown) => void; + maxFrameBytes: number; + completion?: { cancelled: boolean }; +} +// This is a local capability association, never a field a caller can serialize +// or supply as ambient outgoing metadata. Keep non-enumerable verified values. +const dispatchContexts = new WeakMap(); + +interface WireEventDispatchContext { + context: EventContext | WireEventContext; + panic?: (error: unknown) => void; +} +// An event's local context capability has no waiter, id or callable return. +// Weak ownership lets queued deliveries outlive the source receiver's return. +const eventContexts = new WeakMap(); +const receivedEventTraces = new WeakMap(); +/** @internal Preserve the received frame independently of a custom propagator's context. */ +export function setReceivedEventTrace(context: EventContext, trace: Trace | undefined): void { + receivedEventTraces.set(context, trace); +} +const eventContextCarrier: Wire = Object.freeze({ + send: () => { + throw new DuplexError('invalid_message', 'An event context is not a return address.'); + }, +}); +/** @internal Inspect only runtime-associated event context, never caller data. */ +export function wireEventContext(message: Message): WireEventDispatchContext | undefined { + return message.return ? eventContexts.get(message.return) : undefined; +} +/** @internal Carry received event context across local asynchronous composition. */ +export function withWireEventContext( + message: Message, + context: EventContext | WireEventContext, + panic?: (error: unknown) => void, +): Message { + const address: ReturnAddress = { wire: eventContextCarrier }; + eventContexts.set(address, { context, panic }); + return { frame: message.frame, return: address }; +} + +/** @internal Read the verified context associated with a local return capability. */ +export function wireContext(address: ReturnAddress): WireDispatchContext | undefined { + return dispatchContexts.get(address); +} + +/** @internal Associate received context without adding it to serialized frame data. */ +export function setWireContext(address: ReturnAddress, context: WireDispatchContext): () => void { + dispatchContexts.set(address, context); + return () => { + if (dispatchContexts.get(address) === context) dispatchContexts.delete(address); + }; +} + +/** @internal Preserve authenticated receive context across a local root's return remapping. */ +export function inheritWireContext(source: ReturnAddress, target: ReturnAddress): () => void { + const context = dispatchContexts.get(source); + return context ? setWireContext(target, context) : () => {}; +} + +/** Context beside a wire request, independent of its concrete carrier. */ +export interface WireModelContext extends TraceContext { + signal?: AbortSignal; + timeoutMs?: number; + readonly meta?: Meta; + outgoingMeta?: Meta; + requestId?: string; +} +/** Runtime construction options shared by derived family adapters. */ +export interface AdapterContext { + options?: PeerOptions; + valueEnvironment?: ValueEnvironment; +} +/** Context beside a wire request, independent of its concrete carrier. */ +export interface WireRequestContext extends WireModelContext { + wire: Wire; + signal: AbortSignal; + requestId: string; + meta?: Meta; +} +export interface WireCallOptions { + propagator?: Propagator; + observer?: Observer; + family?: string; + signal?: AbortSignal; + timeoutMs?: number; + context?: TraceContext; + meta?: Meta; +} +export interface WireEmitOptions { + propagator?: Propagator; + observer?: Observer; + family?: string; + context?: TraceContext; + meta?: Meta; +} +export interface WireEventContext extends TraceContext { + wire: Wire; + meta?: Meta; +} +export type WireHandler = (params: unknown, context: WireRequestContext) => unknown | Promise; +export type WireEventListener = (data: unknown, context: WireEventContext) => void | Promise; +export interface WireHandlers { + request?: WireHandler; + event?: WireEventListener; + observer?: Observer; + family?: string; +} + +/** @internal The completion/deadline primitive shared by Peer.call and CallWire. */ +export function requestCompletion() { + let settled = false; + let timer: ReturnType | undefined; + let detach: (() => void) | undefined; + let resolve!: (value: T) => void; + let reject!: (error: DuplexError) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + const cleanup = () => { + clearTimeout(timer); + detach?.(); + detach = undefined; + }; + return { + promise, + get settled() { + return settled; + }, + cleanup, + resolve: (value: T) => { + if (!settled) { + settled = true; + cleanup(); + resolve(value); + } + }, + reject: (error: DuplexError) => { + if (!settled) { + settled = true; + cleanup(); + reject(error); + } + }, + wait: ( + signal: AbortSignal | undefined, + timeoutMs: number, + method: string, + cancel: (error: DuplexError, outcome: 'timeout' | 'cancelled') => void, + ) => { + if (settled) return; + timer = setTimeout( + () => + cancel( + new DuplexError('request_timeout', `Call ${method} timed out; its outcome may be unknown.`), + 'timeout', + ), + timeoutMs, + ); + if (signal) { + const abort = () => + cancel(new DuplexError('cancelled', 'Call was cancelled; its outcome may be unknown.'), 'cancelled'); + signal.addEventListener('abort', abort, { once: true }); + detach = () => signal.removeEventListener('abort', abort); + if (signal.aborted) abort(); + } + }, + }; +} + +function snapshot(value: T): T { + try { + const encoded = JSON.stringify(value, (_key, member: unknown) => { + if ( + typeof member === 'function' || + typeof member === 'symbol' || + (typeof member === 'number' && !Number.isFinite(member)) + ) + throw new Error('Not JSON.'); + return member; + }); + scalarJSON(encoded); + return JSON.parse(encoded) as T; + } catch { + throw new DuplexError('invalid_message', 'Frame must contain serializable JSON values.'); + } +} + +/** @internal Reuse the physical profile validator at structured root admission. */ +export function profileFrame(value: ProfileFrame, name: string, maxFrameBytes?: number): ProfileFrame { + const saved: unknown = snapshot(value); + if (!isObject(saved) || Object.hasOwn(saved, 'method') || Object.hasOwn(saved, 'event')) + throw new DuplexError('invalid_message', 'Invalid structured profile frame.'); + // The path is the sole operation name. Reuse the physical profile validator + // after translating that name, without silently replacing an extra member. + const envelope = { + ...saved, + ...(saved.kind === 'request' ? { method: name } : saved.kind === 'event' ? { event: name } : {}), + }; + const text = JSON.stringify(envelope); + if (maxFrameBytes !== undefined && new TextEncoder().encode(text).byteLength > maxFrameBytes) + throw new DuplexError('frame_too_large', 'Outgoing frame exceeds the size limit.'); + // Logical request ids belong to local return addresses, not physical roles. + // Both accepted prefixes still use the existing canonical numeric grammar. + const prefix = typeof saved.id === 'string' && saved.id.startsWith('s:') ? 's:' : 'c:'; + try { + decodeEnvelope(text, prefix, prefix); + } catch { + throw new DuplexError('invalid_message', 'Invalid structured profile frame.'); + } + const frame = saved as unknown as ProfileFrame; + const associated = outgoingTraces.get(value); + if (associated) outgoingTraces.set(frame, associated); + return frame; +} + +/** @internal Remove local publication proof from a dispatched refusal. */ +export function publicError(error: unknown): DuplexError { + // Reconstructing public data strips local publication proof after dispatch. + return error instanceof DuplexError && + typeof error.code === 'string' && + error.code.length > 0 && + typeof error.message === 'string' && + error.message.length > 0 + ? new DuplexError(error.code, error.message, error.data) + : new DuplexError('internal', 'Request handler failed.'); +} +/** @internal Return a validated public result or a bounded internal-error fallback. */ +export function response(request: Message, result?: unknown, error?: unknown): DuplexError | undefined { + if (request.frame.kind !== 'request' || !request.return) + return new DuplexError('disconnected', 'The request has no return address.'); + let outcome: DuplexError | undefined; + let payload: { result: unknown } | { error: { code: string; message: string; data?: unknown } }; + try { + if (error !== undefined) { + const refused = publicError(error); + // Retain a valid local cancellation identity for the helper's outcome; + // only normalized public fields below enter the response frame. + outcome = + error instanceof DuplexError && error.code === refused.code && error.message === refused.message + ? error + : refused; + payload = snapshot({ + error: { + code: refused.code, + message: refused.message, + ...(refused.data === undefined ? {} : { data: refused.data }), + }, + }); + } else payload = { result: snapshot(result === undefined ? null : result) }; + } catch { + outcome = new DuplexError('internal', 'Response could not be encoded'); + payload = { error: { code: 'internal', message: 'Response could not be encoded' } }; + } + const frame: ProfileFrame = { + version: 1, + kind: 'response', + id: request.frame.id, + ...payload, + ...traceOf(request.frame), + }; + try { + request.return.wire.send([], { frame }); + } catch (error) { + if (error instanceof DuplexError && ['invalid_message', 'frame_too_large'].includes(error.code)) { + outcome = new DuplexError('internal', 'Response could not be encoded'); + try { + request.return.wire.send([], { + frame: { + version: 1, + kind: 'response', + id: request.frame.id, + error: { code: 'internal', message: 'Response could not be encoded' }, + ...traceOf(request.frame), + }, + }); + } catch { + /* The return address cannot admit even the bounded error response. */ + } + } else outcome ??= publicError(error); + /* The caller may already have cancelled or ended. */ + } + return outcome; +} + +/** Calls through the shared request primitive; no new peer or channel is made. */ +export function callWire( + wire: Wire, + path: Path, + params: unknown = {}, + options: WireCallOptions = {}, +): Promise { + return callWireTraced(wire, path, params, options, (options.propagator ?? defaultPropagator).inject(options.context)); +} +function callWireTraced( + wire: Wire, + path: Path, + params: unknown, + options: WireCallOptions, + trace?: Trace, + dispatch?: WireDispatchContext, +): Promise { + let name: string; + let frame: ProfileFrame; + try { + name = encodePath(path); + if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) + throw new DuplexError('invalid_options', 'timeoutMs must be a positive safe integer.'); + if (options.signal?.aborted) throw new DuplexError('cancelled', 'Call was cancelled before sending.'); + frame = snapshot( + carrying({ version: 1, kind: 'request', id: 'c:1', params, ...trace }, options.meta), + ) as unknown as ProfileFrame; + } catch (error) { + return Promise.reject(new UnpublishedError(error)); + } + if (!dispatch && trace) outgoingTraces.set(frame, trace); + const completion = requestCompletion(); + if (dispatch) dispatch = { ...dispatch, completion: { cancelled: false } }; + const invocation = new Invocation(defaultInvocationLimits()); + const finish = observeWireRequest(options.observer, options.family, name, false, trace); + let localOutcome: 'cancelled' | 'timeout' | undefined; + void completion.promise.then( + () => finish(), + (error: unknown) => finish(error, localOutcome ?? 'error'), + ); + const returning: Wire = { + send: (suffix, message) => { + if (suffix.length) { + invocation.deliver(suffix, message); + return; + } + const frame = profileFrame(message.frame, '', dispatch?.maxFrameBytes); + if (frame.kind !== 'response' || frame.id !== 'c:1') + throw new DuplexError('invalid_message', 'Invalid wire response.'); + if (completion.settled) throw new WireError('closed'); + if (frame.error?.code === 'cancelled' && dispatch?.context.signal.aborted && dispatch.completion?.cancelled) + completion.resolve(undefined as T); + else if (frame.error) completion.reject(new DuplexError(frame.error.code, frame.error.message, frame.error.data)); + else completion.resolve(frame.result as T); + invocation.settle(); + }, + }; + const address: ReturnAddress = { wire: returning }; + const retire = () => { + dispatchContexts.delete(address); + invocation.settle(); + invocation.dispatchDone(); + }; + if (dispatch) dispatchContexts.set(address, dispatch); + void completion.promise.then(retire, retire); + try { + wire.send(path, { frame, return: address }); + } catch (error) { + completion.reject(new UnpublishedError(error)); + } + completion.wait(options.signal, options.timeoutMs ?? 30_000, name, (error, outcome) => { + if (completion.settled) return; + completion.cleanup(); + if (!dispatch) { + localOutcome = outcome; + completion.reject(error); + finish(error, outcome); + } + // An incoming dispatch occupies the carrier's handler budget until the + // receiver replies. Its cancellation ends the caller's wait elsewhere; + // settling this promise here would release a still-executing body. + try { + wire.send(path, { frame: { version: 1, kind: 'cancel', id: 'c:1', ...trace }, return: address }); + } catch { + /* Cancellation is best effort and never extends the caller's wait. */ + } + }); + return completion.promise; +} + +/** Registers one operation; application code runs after the delivering turn. */ +export function handleWire(wire: HandlerRegistry, path: Path, handler: WireHandler): () => void { + return registerWire(wire, path, { request: handler }); +} + +/** Registers request and event facets at one operation with one cancellation map. */ +export function registerWire(wire: HandlerRegistry, path: Path, handlers: WireHandlers): () => void { + const incoming = new Map>(); + const stop = () => { + for (const calls of incoming.values()) for (const controller of calls.values()) controller.abort(); + }; + const detach = wire.register(path, { + closed: stop, + message: (_path, message) => { + const frame = message.frame; + if (frame.kind === 'event') { + if (!handlers.event) return; + if (handlers.observer) + observeWire(handlers.observer, { + type: 'event.delivered', + at: new Date(), + name: encodePath(path), + bytes: new TextEncoder().encode(JSON.stringify(frame.data)).byteLength, + trace: traceOf(frame), + family: handlers.family ?? '', + }); + const dispatch = wireEventContext(message); + const context = (dispatch ? Object.create(dispatch.context) : {}) as WireEventContext; + Object.defineProperties(context, { + wire: { value: wire, enumerable: true }, + meta: { value: frame.meta ? { ...frame.meta } : undefined, enumerable: true }, + }); + if (!dispatch) defaultPropagator.extract(context, traceOf(frame)); + const failed = (error: unknown) => { + if (!(error instanceof DuplexError)) dispatch?.panic?.(error); + wire.close(1002, 'wire event rejected'); + }; + try { + const pending = handlers.event(frame.data, context); + if (pending) return pending.catch(failed); + } catch (error) { + failed(error); + } + return; + } + if ((frame.kind !== 'request' && frame.kind !== 'cancel') || !message.return) return; + let calls = incoming.get(message.return); + if (frame.kind === 'cancel') { + calls?.get(frame.id)?.abort(); + return; + } + const finish = observeWireRequest(handlers.observer, handlers.family, encodePath(path), true, traceOf(frame)); + if (!handlers.request) { + const error = new DuplexError('method_not_found', 'An event has no request handler.'); + finish(response(message, undefined, error)); + return; + } + if (calls?.has(frame.id)) { + const error = new DuplexError('invalid_message', 'Duplicate active request identifier.'); + finish(response(message, undefined, error)); + return; + } + if (!calls) { + calls = new Map(); + incoming.set(message.return, calls); + } + const controller = new AbortController(); + calls.set(frame.id, controller); + const dispatch = dispatchContexts.get(message.return); + const context: WireRequestContext = dispatch + ? (Object.create(dispatch.context) as WireRequestContext) + : ({ + ...(frame.meta ? { meta: { ...frame.meta } } : {}), + } as WireRequestContext); + Object.defineProperties(context, { + wire: { value: wire, enumerable: true }, + signal: { + value: dispatch ? AbortSignal.any([controller.signal, dispatch.context.signal]) : controller.signal, + enumerable: true, + }, + requestId: { value: dispatch?.context.requestId ?? frame.id, enumerable: true }, + }); + if (!dispatch) defaultPropagator.extract(context, traceOf(frame)); + // The body runs after this receiver returns, so returning is not + // completion. The lease says so to whoever admitted the request: an + // early answer to the caller cannot retire an invocation whose body is + // still running. A return capability that carries no lifecycle still + // gets ordinary addressed delivery. + // A bound reached is a refusal; any other refusal means this return + // capability carries no lifecycle, and ordinary addressed delivery goes + // on without one. + let body: { done(): void } | undefined; + try { + body = beginInvocationBody(message); + } catch (error) { + if (error instanceof InvocationError && error.code === 'limit') { + calls.delete(frame.id); + if (!calls.size) incoming.delete(message.return); + controller.abort(); + finish(response(message, undefined, new DuplexError('busy', 'Invocation participation limit reached.'))); + return; + } + } + let cancelledBeforeHandler = false; + void Promise.resolve() + .then(() => { + if (context.signal.aborted) { + cancelledBeforeHandler = true; + throw new DuplexError('cancelled', 'Request was cancelled.'); + } + return handlers.request!(frame.params, context); + }) + .then( + (result) => { + const error = context.signal.aborted ? new DuplexError('cancelled', 'Request was cancelled.') : undefined; + if (error && dispatch?.completion) dispatch.completion.cancelled = true; + const outcome = response(message, result, error); + finish(outcome, error && outcome === error ? 'cancelled' : undefined); + }, + (error: unknown) => { + if (!(error instanceof DuplexError)) dispatch?.panic(error); + // A public handler refusal stays an error even if cancellation + // raced its completion. Only this helper's withdrawal is local. + if (cancelledBeforeHandler && dispatch?.completion) dispatch.completion.cancelled = true; + const outcome = response(message, undefined, error); + finish(outcome, cancelledBeforeHandler && outcome === error ? 'cancelled' : 'error'); + }, + ) + .finally(() => { + body?.done(); + calls!.delete(frame.id); + if (!calls!.size) incoming.delete(message.return!); + }); + }, + }); + return () => { + detach(); + stop(); + }; +} + +/** Emits a relative event; return means accepted, never consumed. */ +export function emitWire(wire: Wire, path: Path, data: unknown = null, options: WireEmitOptions = {}): void { + try { + const name = encodePath(path); + const trace = (options.propagator ?? defaultPropagator).inject(options.context); + const frame = snapshot( + carrying({ version: 1, kind: 'event', data, ...trace }, options.meta), + ) as unknown as ProfileFrame; + outgoingTraces.set(frame, trace); + if (options.observer) + observeWire(options.observer, { + type: 'event.emitted', + at: new Date(), + name, + bytes: new TextEncoder().encode(JSON.stringify(frame.kind === 'event' ? frame.data : null)).byteLength, + trace, + family: options.family ?? '', + }); + wire.send(path, { frame }); + } catch (error) { + throw new UnpublishedError(error); + } +} + +/** The root's existing serial event dispatcher awaits an async listener. */ +export function onWireEvent(wire: HandlerRegistry, path: Path, listener: WireEventListener): () => void { + return registerWire(wire, path, { event: listener }); +} + +/** Forwards both relative origins without owning either endpoint. */ +export function forwardWire(a: Endpoint, b: Endpoint): () => void { + const removals: (() => void)[] = []; + let detached = false; + const stop = () => { + if (detached) return; + detached = true; + for (const remove of removals) remove(); + }; + const receiver = (destination: Wire): Receiver => ({ + closed: stop, + message: (path, message) => { + // Detach stops new dispatch, not controls for an already captured call. + try { + destination.send(path, message); + } catch (error) { + stop(); + response(message, undefined, publicError(error)); + } + }, + }); + try { + for (const [source, destination] of [ + [a, b], + [b, a], + ] as const) { + const remove = source.receive(receiver(destination)); + if (detached) remove(); + else removals.push(remove); + } + } catch (error) { + stop(); + throw error; + } + return stop; +} + +/** @internal Hooks retain all carrier ownership in the peer. */ +export interface PeerWireOptions { + queueCapacity: number; + maxPendingRequests: number; + maxFrameBytes: number; + requestTimeoutMs: number; + call: (method: string, params: unknown, options: CallOptions, trace?: Trace) => Promise; + emit: (name: string, data: unknown, options: EmitOptions, trace?: Trace) => Promise; + dispatch: ( + request: (method: string) => RequestHandler | undefined, + event: (name: string) => EventListener | undefined, + ) => void; + fail: (error: DuplexError) => void; + pressure: (waiting: number) => void; + panic: (method: string, error: unknown, trace?: Trace) => void; + close: (code: number, reason: string) => void; +} + +interface RoutedCall { + address: ReturnAddress; + id: string; + controller: AbortController; + completed: boolean; + cancelQueued: boolean; + cancelled: boolean; +} +interface RoutedDelivery { + path: Path; + message: Message; + call?: RoutedCall; + refusal?: DuplexError; +} + +interface WireRegistration { + receiver: Receiver; + request: (path: Path, params: unknown, context: RequestContext) => Promise; + detach: () => void; +} + +/** @internal One bridge per peer; selection never constructs another. */ +export function peerWire(peer: DuplexPeer, options: PeerWireOptions): Endpoint { + const queued: RoutedDelivery[] = []; + const incoming = new Map>(); + let attachment: WireRegistration | undefined; + const lookup = (name: string): { path: Path; registration: WireRegistration } | undefined => { + let path: Path; + try { + path = decodePath(name); + } catch { + return; + } + return attachment ? { path, registration: attachment } : undefined; + }; + options.dispatch( + (name) => { + const found = lookup(name); + return found ? (params, context) => found.registration.request(found.path, params, context) : undefined; + }, + (name) => { + const found = lookup(name); + return found + ? (_name, data, context) => { + const receivedTrace = receivedEventTraces.has(context) ? receivedEventTraces.get(context) : context.trace; + return found.registration.receiver.message?.( + found.path, + withWireEventContext( + { + frame: { + version: 1, + kind: 'event', + data, + ...receivedTrace, + ...(context.meta ? { meta: context.meta } : {}), + }, + }, + context, + (error) => options.panic(name, error, receivedTrace), + ), + ); + } + : undefined; + }, + ); + let retained = 0, + dataQueued = 0, + scheduled = false, + ended = false; + const endError = () => new DuplexError('disconnected', 'Connection ended; outcome may be unknown.'); + const retire = (call: RoutedCall) => { + // A completed call still owns a queued control slot. Reusing its budget + // early would let fast completions accumulate unbounded stale cancels. + if (!call.completed || call.cancelQueued) return; + const calls = incoming.get(call.address); + if (calls?.get(call.id) !== call) return; + calls.delete(call.id); + if (!calls.size) incoming.delete(call.address); + retained--; + }; + peer.onClose(() => { + ended = true; + for (const delivery of queued.splice(0)) response(delivery.message, undefined, endError()); + for (const calls of incoming.values()) for (const call of calls.values()) call.controller.abort(); + incoming.clear(); + retained = 0; + dataQueued = 0; + const ending = attachment ? [attachment] : []; + for (const { detach } of ending) detach(); + for (const { receiver } of ending) { + try { + receiver.closed?.(1001, 'peer ended'); + } catch { + /* One receiver cannot interrupt another's cleanup. */ + } + } + }); + const drain = () => { + scheduled = false; + while (queued.length && !ended) { + const { path, message, call, refusal } = queued.shift()!; + const frame = message.frame; + if (frame.kind === 'cancel') { + call!.cancelQueued = false; + call!.cancelled = true; + if (!call!.completed) call!.controller.abort(); + retire(call!); + continue; + } + dataQueued--; + if (refusal) { + response(message, undefined, refusal); + continue; + } + const name = encodePath(path); + if (frame.kind === 'event') { + // Invoke admission now, in wire order; only completion is asynchronous. + void options + .emit( + name, + frame.data, + { + meta: frame.meta ? { ...frame.meta } : undefined, + }, + outgoingTrace(frame), + ) + .catch((error: unknown) => options.fail(publicError(error))); + continue; + } + if (frame.kind !== 'request') continue; + // Peer.call allocates the carrier id and enqueues before it returns. + const pending = options.call( + name, + frame.params, + { + signal: call!.controller.signal, + meta: frame.meta ? { ...frame.meta } : undefined, + }, + outgoingTrace(frame), + ); + const finish = (value?: unknown, error?: unknown) => { + call!.completed = true; + retire(call!); + response(message, value, error); + }; + void pending.then( + (value) => finish(value), + (error: unknown) => finish(undefined, error), + ); + } + }; + const wire: Endpoint = { + send: (path, message) => { + const name = encodePath(path); + if (ended || peer.status !== 'connected') throw endError(); + const frame = profileFrame(message.frame, name, options.maxFrameBytes); + if (!name && (frame.kind === 'request' || frame.kind === 'event')) + throw new DuplexError('invalid_message', 'A root wire operation requires a nonempty path.'); + if ((frame.kind === 'request' || frame.kind === 'cancel') && !message.return?.wire) + throw new DuplexError('invalid_message', 'A wire request or cancellation requires a return address.'); + if (frame.kind !== 'request' && frame.kind !== 'event' && frame.kind !== 'cancel') + throw new DuplexError('invalid_message', "A response is sent to its request's return address."); + let call: RoutedCall | undefined; + if (frame.kind === 'cancel') { + call = incoming.get(message.return!)?.get(frame.id); + // Cancellation belongs to an already admitted request. Its one control + // reservation is bounded by the existing pending-request budget. + if (!call || call.completed || call.cancelQueued || call.cancelled) return; + } + if (ended || peer.status !== 'connected') throw endError(); + if (frame.kind !== 'cancel' && dataQueued >= options.queueCapacity) { + const error = new DuplexError('busy', 'Output consumer is stalled; queue limit reached.'); + options.pressure(dataQueued); + options.fail(error); + throw error; + } + let refusal: DuplexError | undefined; + if (frame.kind === 'cancel') call!.cancelQueued = true; + else { + dataQueued++; + if (frame.kind === 'request') { + let calls = incoming.get(message.return!); + if (calls?.has(frame.id) || retained >= options.maxPendingRequests) { + refusal = new DuplexError( + calls?.has(frame.id) ? 'invalid_message' : 'busy', + 'Outstanding wire call refused.', + ); + } else { + if (!calls) { + calls = new Map(); + incoming.set(message.return!, calls); + } + call = { + address: message.return!, + id: frame.id, + controller: new AbortController(), + completed: false, + cancelQueued: false, + cancelled: false, + }; + calls.set(frame.id, call); + retained++; + } + } + } + // Data and reserved control entries share one FIFO. A cancel cannot jump + // ahead of an earlier event, request, or cancellation on this wire. + queued.push({ path: [...path], message: { frame, return: message.return }, call, refusal }); + if (!scheduled) { + scheduled = true; + queueMicrotask(drain); + } + }, + receive: (receiver) => { + if (ended) throw endError(); + if (attachment) throw new WireError('receiver_exists'); + const target: Wire = { + send: (suffix, message) => { + try { + if (!receiver.message) { + response(message, undefined, new DuplexError('method_not_found', 'Unknown method.')); + return; + } + const result = receiver.message(suffix, message); + if (result) void result.catch((error: unknown) => response(message, undefined, publicError(error))); + } catch (error) { + response(message, undefined, publicError(error)); + } + }, + }; + const request = (received: Path, params: unknown, context: RequestContext) => + callWireTraced( + target, + received, + params, + { + signal: context.signal, + timeoutMs: options.requestTimeoutMs, + meta: context.meta, + }, + context.trace, + { + context, + panic: (error) => options.panic(encodePath(received), error, context.trace), + maxFrameBytes: options.maxFrameBytes, + }, + ); + const registration: WireRegistration = { + receiver, + request, + detach: () => { + if (attachment === registration) attachment = undefined; + }, + }; + attachment = registration; + return registration.detach; + }, + close: (code = 1000, reason = '') => options.close(code, reason), + }; + return wire; +} diff --git a/engine/ts/src/envelope.ts b/engine/ts/src/envelope.ts new file mode 100644 index 0000000..2dc42b8 --- /dev/null +++ b/engine/ts/src/envelope.ts @@ -0,0 +1,158 @@ +/** Internal frame validation and carriage shared by the peer and its conformance tests. */ +import { DuplexError } from './error.ts'; +import type { Meta } from './peer.ts'; +import { scalarJSON } from './unicode.ts'; + +/** A decoded JSON envelope; the connection beneath carries it as a text frame. */ +export type Envelope = Record; + +/** + * One frame of the profile, validated by kind. Every kind may carry W3C trace + * context, and a request and an event a `meta` of strings; the members are + * kept on the envelope for a caller that propagates them, and the peer itself + * reads none of them. Not part of the package surface. + */ +export function decodeEnvelope(data: string, localPrefix: string, remotePrefix: string): Envelope { + scalarJSON(data); + const value: unknown = JSON.parse(data); + if (!isObject(value) || value.version !== 1) throw new Error(); + // JSON.parse keeps the last of two members of one name; a frame that spells + // a member twice is ambiguous and refused, as the Go peer refuses it. + if (topLevelMembers(data) !== Object.keys(value).length) throw new Error(); + const frame: Envelope = value; + switch (frame.kind) { + case 'request': + keys(frame, ['version', 'kind', 'id', 'method', 'params', 'meta', ...TRACE]); + requestID(frame.id, remotePrefix); + requireName(frame.method, 'method'); + if (!Object.hasOwn(frame, 'params')) throw new Error(); + break; + case 'response': + keys(frame, ['version', 'kind', 'id', 'result', 'error', ...TRACE]); + requestID(frame.id, localPrefix); + if (Object.hasOwn(frame, 'result') === Object.hasOwn(frame, 'error')) throw new Error(); + if (Object.hasOwn(frame, 'error')) { + if (!isObject(frame.error)) throw new Error(); + keys(frame.error, ['code', 'message', 'data']); + requireName(frame.error.code, 'code'); + // code and message are both non-empty, as the Go peer refuses them. + requireName(frame.error.message, 'message'); + } + break; + case 'cancel': + keys(frame, ['version', 'kind', 'id', ...TRACE]); + requestID(frame.id, remotePrefix); + break; + case 'event': + keys(frame, ['version', 'kind', 'event', 'data', 'meta', ...TRACE]); + requireName(frame.event, 'event'); + if (!Object.hasOwn(frame, 'data')) throw new Error(); + break; + default: + throw new Error(); + } + trace(frame); + carriage(frame); + return frame; +} + +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +/** How many members the text spells at the top level, duplicates counted. */ +function topLevelMembers(text: string): number { + let depth = 0; + let inString = false; + let members = 0; + let expectKey = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inString) { + if (c === '\\') i++; + else if (c === '"') inString = false; + continue; + } + switch (c) { + case '"': + inString = true; + if (depth === 1 && expectKey) { + members++; + expectKey = false; + } + break; + case '{': + case '[': + depth++; + if (depth === 1) expectKey = true; + break; + case '}': + case ']': + depth--; + break; + case ',': + if (depth === 1) expectKey = true; + break; + } + } + return members; +} +function keys(frame: Envelope, allowed: string[]): void { + if (Object.keys(frame).some((key) => !allowed.includes(key))) throw new Error('Unknown frame property.'); +} +/** + * The meta keys the profile and its components keep for themselves โ€” a + * deadline, a cause โ€” so that a consumer's key and one defined later never + * collide. This version defines none, so every key under it is refused. + */ +const META_RESERVED = 'nightseam.'; +/** W3C Trace Context, verbatim: an optional member of every kind, never of an error. */ +const TRACE = ['traceparent', 'tracestate']; +const TRACEPARENT = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/; +function trace(frame: Envelope): void { + if ( + Object.hasOwn(frame, 'traceparent') && + (typeof frame.traceparent !== 'string' || !TRACEPARENT.test(frame.traceparent)) + ) { + throw new Error('Invalid traceparent.'); + } + if (Object.hasOwn(frame, 'tracestate') && typeof frame.tracestate !== 'string') + throw new Error('Invalid tracestate.'); +} +/** + * What a frame sent from here carries. The map is copied, so a later write to + * the caller's does not reach a frame already sent; keys under META_RESERVED + * are the profile's and are dropped rather than sent, since the peer at the + * far end refuses a frame carrying one, and a carriage left with nothing in it + * is not sent at all. + */ +export function carrying(envelope: Envelope, meta: Meta | undefined): Envelope { + if (!meta) return envelope; + const carried: Meta = {}; + for (const [key, value] of Object.entries(meta)) { + if (!key.startsWith(META_RESERVED)) carried[key] = value; + } + if (Object.keys(carried).length > 0) envelope.meta = carried; + return envelope; +} +/** + * A call's metadata, verbatim: `meta` maps names to strings and may be empty, + * and nothing here reads a value of it. Keys under META_RESERVED are the + * profile's to define and it defines none in this version, so a frame carrying + * one is refused rather than read as a consumer's. + */ +function carriage(frame: Envelope): void { + if (!Object.hasOwn(frame, 'meta')) return; + const meta = frame.meta; + if (!isObject(meta)) throw new Error('Invalid meta.'); + for (const [key, value] of Object.entries(meta)) { + if (typeof value !== 'string' || key.startsWith(META_RESERVED)) throw new Error('Invalid meta.'); + } +} +export function requireName(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) + throw new DuplexError('invalid_message', `${field} must be a nonempty string.`); +} +function requestID(value: unknown, prefix: string): void { + if (typeof value !== 'string' || !value.startsWith(prefix) || !/^[1-9][0-9]{0,19}$/.test(value.slice(prefix.length))) + throw new Error('Invalid request ID.'); +} diff --git a/engine/ts/src/peer.ts b/engine/ts/src/peer.ts new file mode 100644 index 0000000..55eceb2 --- /dev/null +++ b/engine/ts/src/peer.ts @@ -0,0 +1,1219 @@ +import { DuplexError, UnpublishedError } from './error.ts'; +export { DuplexError, UnpublishedError } from './error.ts'; +import { decodeEnvelope, carrying, requireName, isObject, type Envelope } from './envelope.ts'; +import { webSocketConnection } from '@nightseam/duplex'; +import type { Frame, FrameConnection, WebSocketLike, Endpoint } from '@nightseam/duplex'; +import { defaultPropagator, traceOf, traced } from './trace.ts'; +import type { Propagator, Trace, TraceContext } from './trace.ts'; +import { peerWire, requestCompletion, setReceivedEventTrace } from './wire.ts'; +import type { Observer, ObserverEvent } from './observer.ts'; +import { scalarJSON } from './unicode.ts'; + +export type { WebSocketLike } from '@nightseam/duplex'; + +/** The endpoint selects this profile; it is offered as no subprotocol by default. */ +export const DUPLEX_PROFILE = 'nightseam.duplex/1'; +/** The limits a peer runs with unless its options say otherwise; docs/runtime/peer.md tables them. */ +export const DUPLEX_DEFAULTS = Object.freeze({ + maxConcurrentHandlers: 64, + maxPendingRequests: 128, + queueCapacity: 128, + maxFrameBytes: 1_048_576, + requestTimeoutMs: 30_000, + writeTimeoutMs: 10_000, + connectTimeoutMs: 30_000, +}); + +/** Where a peer is between construction and its end; `connected` is the only state that carries frames. */ +export type PeerStatus = 'disconnected' | 'connecting' | 'connected'; +/** + * What a frame carries about a call rather than of it: a flat map of strings โ€” + * a tenant, an idempotency key, a credential that is per request โ€” which the + * profile carries verbatim and reads nothing into. + */ +export type Meta = Record; +/** What a call may carry: a signal that withdraws it, a deadline of its own, the context it is made under (so its trace parents on the request being served), and its meta. */ +export interface CallOptions { + signal?: AbortSignal; + timeoutMs?: number; + context?: TraceContext; + meta?: Meta; +} +/** What an emit may carry: the context it is made under, and its meta. */ +export interface EmitOptions { + context?: TraceContext; + meta?: Meta; +} +/** What a handler is given beside the params: a signal that fires when the caller withdraws the request or its deadline passes, the peer it arrived on, the request's id, and the trace and meta the frame brought. */ +export interface RequestContext { + signal: AbortSignal; + peer: DuplexPeer; + requestId: string; + /** What the propagator read from the frame that started this request. */ + trace?: Trace; + /** + * What the frame that started this request carried, and absent where it + * carried none. Passing it on is the handler's to say โ€” `{ meta: context.meta }` + * โ€” since a trace is the peer's to propagate and a credential is not. + */ + meta?: Meta; +} +/** What an event's listeners are told about the frame that carried it. */ +export interface EventContext { + peer: DuplexPeer; + trace?: Trace; + meta?: Meta; +} +/** Answers one request: the result, or a thrown DuplexError that crosses the wire with its code โ€” any other error reaches the caller as `internal`. */ +export type RequestHandler = (params: unknown, context: RequestContext) => unknown | Promise; +/** Answers every request the peer has no handler for, by method name; the generated binding installs one. */ +export type Dispatcher = (method: string, params: unknown, context: RequestContext) => unknown | Promise; +/** Takes one event's data; events have no answer, and listeners run one at a time in arrival order. */ +export type EventListener = (event: string, data: unknown, context: EventContext) => void | Promise; +/** How a peer is made: its role, its dispatcher, the socket factory, its limits, its propagator, its observer and the family each name belongs to. Every member is optional and takes DUPLEX_DEFAULTS. */ +export interface PeerOptions { + /** Installs handlers once after validation, before any connection can read. Must be synchronous. */ + prepare?: (peer: DuplexPeer) => void; + role?: 'client' | 'server'; + dispatch?: Dispatcher; + webSocketFactory?: (url: string, protocols?: string[]) => WebSocketLike; + /** + * Offered to the server at the handshake, in order of preference; none by + * default. A server that selects none leaves the connection with none and + * the profile is spoken over it either way โ€” but a browser refuses a + * handshake whose offer went unselected, so a client that offers must be + * met by a server that selects (docs/wire/profile.md). + */ + subprotocols?: string[]; + maxConcurrentHandlers?: number; + maxPendingRequests?: number; + queueCapacity?: number; + maxFrameBytes?: number; + requestTimeoutMs?: number; + writeTimeoutMs?: number; + connectTimeoutMs?: number; + /** Absent, the default mints W3C ids; an adapter for a tracing library replaces it. */ + propagator?: Propagator; + onError?: (error: DuplexError) => void; + observer?: Observer; + /** Method or event name to family label; the generated install fills it. */ + families?: Record; +} + +type Timer = ReturnType; +/** How a request ended, as the observer's event spells it. */ +type Outcome = Extract['outcome']; +interface Pending { + resolve: (value: unknown) => void; + reject: (error: DuplexError) => void; + /** What an observer is told the call was, wherever and whenever it ends. */ + method: string; + started: number; + trace?: Trace; + cleanup: () => void; +} +interface Incoming { + controller: AbortController; + timer: Timer; + responded: boolean; + /** The request's trace; its response, and nothing else, carries it back. */ + trace?: Trace; + method: string; + started: number; +} +interface Outgoing { + text: string; + started: number; + sent: boolean; + waited: boolean; + /** What the observer is told of this frame, called by the writer just before the bytes leave. */ + observeSent?: () => void; +} +interface QueuedEvent { + name: string; + data: unknown; + bytes: number; + trace?: Trace; + meta?: Meta; +} + +/** + * A bounded full-duplex peer. Message routing never awaits application handlers. + * Calls are not retried, and connections are never reopened automatically. + * The caller owns endpoint authentication and authorization of incoming methods. + */ +export class DuplexPeer { + private readonly options: PeerOptions; + private readonly limits: typeof DUPLEX_DEFAULTS; + private readonly propagator: Propagator; + private readonly observer?: Observer; + private readonly localPrefix: string; + private readonly remotePrefix: string; + private connection?: FrameConnection; + private detach?: () => void; + private state: PeerStatus = 'disconnected'; + private nextID = 0; + // Only a request advances the mark: a response or a control names a serial + // that was taken before it. + private admittedSerial = 0; + // Requests publish in the order they were reserved. A request takes its + // place here when it enters the outgoing queue's gate and leaves when it has + // published or given up, so a sender that arrives while others are waiting + // for room takes its turn behind them rather than jumping in. + private readonly publishing: number[] = []; + private nextTicket = 0; + private opening?: { resolve: () => void; reject: (error: DuplexError) => void; timer: Timer }; + private readonly pending = new Map(); + private readonly incoming = new Map(); + private readonly handlers = new Map(); + private readonly listeners = new Set(); + private readonly closedListeners = new Set<(error: DuplexError) => void>(); + private readonly outgoing: Outgoing[] = []; + private writeTimer?: Timer; + /** Public senders paced by a full output queue; wire handoffs never join it. */ + private readonly waitingForRoom = new Set<(room: boolean) => void>(); + private readonly events: QueuedEvent[] = []; + private eventActive = false; + private eventTimer?: Timer; + /** Set while the event queue is over capacity: the deadline it has to drain in. */ + private stallTimer?: Timer; + private generation = 0; + private negotiated = ''; + private relativeWire?: Endpoint; + private wireRequest?: (method: string) => RequestHandler | undefined; + private wireEvent?: (name: string) => EventListener | undefined; + + constructor(options: PeerOptions = {}) { + this.options = options; + if (options.role !== undefined && options.role !== 'client' && options.role !== 'server') { + throw new DuplexError('invalid_options', 'Peer role must be client or server.'); + } + this.limits = { ...DUPLEX_DEFAULTS }; + for (const key of Object.keys(DUPLEX_DEFAULTS) as (keyof typeof DUPLEX_DEFAULTS)[]) { + const value = options[key]; + if (value !== undefined) { + positiveInteger(value, key, true); + // Values deliberately remain configurable without introducing unbounded queues. + (this.limits as Record)[key] = value; + } + } + this.propagator = options.propagator ?? defaultPropagator; + this.observer = options.observer; + this.localPrefix = options.role === 'server' ? 's:' : 'c:'; + this.remotePrefix = options.role === 'server' ? 'c:' : 's:'; + try { + const preparation: unknown = options.prepare?.(this); + if (preparation && typeof (preparation as PromiseLike).then === 'function') { + void Promise.resolve(preparation).catch(() => {}); + throw new DuplexError('invalid_options', 'prepare must complete synchronously.'); + } + } catch (error) { + // Preparation owns no transport yet, but it can already own wire + // registrations and scopes. Notify their existing close hooks once. + this.handlers.clear(); + this.listeners.clear(); + for (const listener of [...this.closedListeners]) { + try { + listener(asError(error)); + } catch { + /* Cleanup cannot replace the construction error. */ + } + } + this.closedListeners.clear(); + throw error; + } + } + + /** Where the peer is now; `connected` is the only status in which a call or an event travels. */ + get status(): PeerStatus { + return this.state; + } + + /** This peer's relative origin; selections share its existing carrier. */ + wire(): Endpoint { + return (this.relativeWire ??= peerWire(this, { + queueCapacity: this.limits.queueCapacity, + maxPendingRequests: this.limits.maxPendingRequests, + maxFrameBytes: this.limits.maxFrameBytes, + requestTimeoutMs: this.limits.requestTimeoutMs, + call: (method, params, options, trace) => this.callWithTrace(method, params, options, () => trace, true), + emit: (name, data, options, trace) => this.emitWithTrace(name, data, options, trace, true), + dispatch: (request, event) => { + this.wireRequest = request; + this.wireEvent = event; + }, + fail: (error) => this.fail(error), + pressure: (waiting) => { + if (this.observer) this.pressure(waiting, true); + }, + panic: (method, error, trace) => { + if (this.observer) + this.observe({ + type: 'handler.panic', + at: new Date(), + method, + value: describe(error), + trace, + family: this.family(method), + }); + }, + close: (code, reason) => + this.fail(new DuplexError('disconnected', 'Connection closed by caller.'), true, code, reason), + })); + } + + /** The side of the connection this peer is; a tunnel over it chooses channel ids by it. */ + get role(): 'client' | 'server' { + return this.options.role ?? 'client'; + } + + /** + * What the WebSocket handshake beneath this peer selected, and '' when it + * selected none or the peer does not run over a WebSocket. The profile + * reads nothing into it. + */ + get subprotocol(): string { + return this.negotiated; + } + + /** Absolute ws/wss URLs are required. Factories may supply platform-specific auth. */ + connect(url: string): Promise { + if (this.connection) return Promise.reject(new DuplexError('already_connected', 'Peer already has a connection.')); + let endpoint: URL; + try { + endpoint = new URL(url); + } catch { + return Promise.reject(new DuplexError('invalid_url', 'An absolute WebSocket URL is required.')); + } + if (!['ws:', 'wss:'].includes(endpoint.protocol) || endpoint.hash || endpoint.username || endpoint.password) { + return Promise.reject( + new DuplexError('invalid_url', 'Use an absolute ws/wss URL without credentials or a fragment.'), + ); + } + let socket: WebSocketLike; + const protocols = this.options.subprotocols; + try { + socket = + this.options.webSocketFactory?.(endpoint.href, protocols) ?? + (protocols ? new WebSocket(endpoint.href, protocols) : new WebSocket(endpoint.href)); + } catch { + return Promise.reject(new DuplexError('connection_failed', 'Unable to create WebSocket.')); + } + return this.attach(socket); + } + + /** + * Attach an externally authenticated, connecting or open connection. A + * WebSocket is wrapped by the adapter; the peer itself never touches one. + */ + attach(connection: FrameConnection | WebSocketLike): Promise { + if (this.connection) return Promise.reject(new DuplexError('already_connected', 'Peer already has a connection.')); + const socket = isWebSocketLike(connection) ? connection : undefined; + const frames = socket === undefined ? (connection as FrameConnection) : webSocketConnection(socket); + if (frames.state !== 'connecting' && frames.state !== 'open') { + return Promise.reject(new DuplexError('disconnected', 'Cannot attach a closing or closed WebSocket.')); + } + this.connection = frames; + this.generation++; + this.state = frames.state === 'open' ? 'connected' : 'connecting'; + // The handshake has selected by the time the socket opens, and not before. + this.negotiated = this.state === 'connected' ? subprotocolOf(socket) : ''; + if (this.observer && this.state === 'connected') + this.observe({ type: 'connection.opened', at: new Date(), role: this.role }); + const current = () => this.connection === frames; + this.detach = frames.listen({ + open: () => { + if (!current()) return; + this.state = 'connected'; + this.negotiated = subprotocolOf(socket); + if (this.observer) this.observe({ type: 'connection.opened', at: new Date(), role: this.role }); + if (this.opening) { + clearTimeout(this.opening.timer); + this.opening.resolve(); + this.opening = undefined; + } + }, + frame: (frame) => { + if (current()) this.receive(frame); + }, + close: (code, reason) => { + if (current()) + this.fail( + new DuplexError('disconnected', 'Connection closed; outstanding call outcomes may be unknown.'), + false, + code, + reason, + ); + }, + error: () => { + if (current()) this.fail(new DuplexError('connection_failed', 'WebSocket connection failed.')); + }, + }); + if (this.state === 'connected') return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => this.fail(new DuplexError('connect_timeout', 'Connection timed out.')), + this.limits.connectTimeoutMs, + ); + this.opening = { resolve, reject, timer }; + }); + } + + /** Ends the connection with a normal close; every pending call rejects with `disconnected`. */ + close(): void { + this.fail(new DuplexError('disconnected', 'Connection closed by caller.'), true, 1000); + } + + /** Tells the listener once, when the peer ends, why it ended; returns what removes the listener. */ + onClose(listener: (error: DuplexError) => void): () => void { + this.closedListeners.add(listener); + return () => { + this.closedListeners.delete(listener); + }; + } + + /** + * Serves a method: one handler per name, given the params and a request context, its return the + * result and a thrown DuplexError the error the caller receives. Returns what unregisters it. + */ + handle(method: string, handler: RequestHandler): () => void { + requireName(method, 'method'); + if (this.handlers.has(method)) + throw new DuplexError('duplicate_handler', `Handler already registered for ${method}.`); + this.handlers.set(method, handler); + return () => { + if (this.handlers.get(method) === handler) this.handlers.delete(method); + }; + } + + /** Listens to every event the remote emits, or to one by name; returns what removes the listener. */ + onEvent(listener: EventListener): () => void; + onEvent(event: string, listener: (data: unknown, context: EventContext) => void | Promise): () => void; + onEvent( + eventOrListener: string | EventListener, + listener?: (data: unknown, context: EventContext) => void | Promise, + ): () => void { + let callback: EventListener; + if (typeof eventOrListener === 'string') { + requireName(eventOrListener, 'event'); + if (!listener) throw new DuplexError('invalid_listener', 'An event listener is required.'); + callback = (event, data, context) => { + if (event === eventOrListener) return listener(data, context); + }; + } else { + callback = eventOrListener; + } + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + /** + * Calls a method on the remote and resolves with its result, or rejects with the DuplexError the + * remote answered โ€” or the peer's own: `request_timeout` past the deadline, `cancelled` when the + * caller's signal fired, `busy` when too many calls are outstanding, `disconnected` when the + * connection ended first. + */ + call(method: string, params: unknown = {}, options: CallOptions = {}): Promise { + return this.callWithTrace(method, params, options, () => this.propagator.inject(options.context)); + } + + private callWithTrace( + method: string, + params: unknown, + options: CallOptions, + traceSource: () => Trace | undefined, + immediate = false, + ): Promise { + try { + requireName(method, 'method'); + if (options.timeoutMs !== undefined) positiveInteger(options.timeoutMs, 'timeoutMs', true); + } catch (error) { + return Promise.reject(new UnpublishedError(error)); + } + if (!this.isOpen()) + return Promise.reject(new UnpublishedError(new DuplexError('not_connected', 'Peer is not connected.'))); + if (options.signal?.aborted) + return Promise.reject(new UnpublishedError(new DuplexError('cancelled', 'Call was cancelled before sending.'))); + if (this.pending.size >= this.limits.maxPendingRequests) { + return Promise.reject(new UnpublishedError(new DuplexError('busy', 'Outstanding call limit reached.'))); + } + if (this.nextID >= Number.MAX_SAFE_INTEGER) { + return Promise.reject( + new UnpublishedError( + new DuplexError('identifier_exhausted', 'Create a new peer before issuing further calls.'), + ), + ); + } + const id = this.localPrefix + (++this.nextID).toString(10); + // One trace for the exchange: the request carries it and its cancel repeats it. + const trace = traceSource(); + let request: Envelope; + try { + request = carrying(traced({ version: 1, kind: 'request', id, method, params }, trace), options.meta); + } catch (error) { + return Promise.reject(new UnpublishedError(error)); + } + const completion = requestCompletion(); + const admission = new AbortController(); + let accepted = false; + const pending: Pending = { + resolve: (value) => completion.resolve(value as T), + reject: completion.reject, + cleanup: () => { + completion.cleanup(); + admission.abort(); + }, + method, + started: Date.now(), + trace, + }; + this.pending.set(id, pending); + const cancel = (error: DuplexError, outcome: Outcome) => { + if (!this.takePending(id)) return; + this.ended(id, pending, outcome, error.code); + completion.reject(accepted ? error : new UnpublishedError(error)); + // Cancellation is best effort, as in Go. It never waits for room and + // an already cancelled caller cannot end a healthy carrier merely + // because its cancellation frame has no room in the output queue. + if (accepted && this.outgoing.length < this.limits.queueCapacity) + void this.send(traced({ version: 1, kind: 'cancel', id }, trace), method).catch(() => {}); + }; + if (this.observer) + this.observe({ + type: 'request.started', + at: new Date(), + id, + method, + incoming: false, + trace, + family: this.family(method), + }); + completion.wait(options.signal, options.timeoutMs ?? this.limits.requestTimeoutMs, method, cancel); + const refused = (failure: unknown) => { + const unsent = this.takePending(id); + if (!unsent) return; + const error = asError(failure, 'send_failed'); + this.ended(id, unsent, 'error', error.code); + unsent.reject(error); + }; + if (!completion.settled) + void this.send(request, method, refused, undefined, immediate, admission.signal, () => { + accepted = true; + }).catch(refused); + return completion.promise; + } + + /** + * Emits one event and resolves when its frame was accepted for sending, which is queued for this + * connection and no more: an event says nothing about receipt, and a caller that wants delivery + * has a call. The queue's own deadline continues behind it and ends a connection that never drains. + */ + emit(event: string, data: unknown = null, options: EmitOptions = {}): Promise { + try { + return this.emitWithTrace(event, data, options, this.propagator.inject(options.context)); + } catch (error) { + return Promise.reject(new UnpublishedError(error)); + } + } + + private emitWithTrace( + event: string, + data: unknown, + options: EmitOptions, + trace?: Trace, + immediate = false, + ): Promise { + try { + requireName(event, 'event'); + return this.send( + carrying(traced({ version: 1, kind: 'event', event, data }, trace), options.meta), + event, + undefined, + trace, + immediate, + ); + } catch (error) { + return Promise.reject(new UnpublishedError(error)); + } + } + + /** Gives up this request's place in the publication order, once. Others + * waiting for room re-check their turn when the queue next drains. */ + private release(ticket: number | undefined): void { + if (ticket === undefined) return; + const at = this.publishing.indexOf(ticket); + if (at < 0) return; + this.publishing.splice(at, 1); + if (at === 0) for (const wake of [...this.waitingForRoom]) wake(true); + } + + private isOpen(): boolean { + return this.state === 'connected' && this.connection?.state === 'open'; + } + + private takePending(id: string): Pending | undefined { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + pending.cleanup(); + return pending; + } + + private async send( + envelope: Envelope, + name = '', + refused?: (error: UnpublishedError) => void, + carriedTrace?: Trace, + immediate = false, + abandoned?: AbortSignal, + accepted?: () => void, + ): Promise { + let queued = false; + let endOnRefusal = false; + // A response, a control or an event takes no serial and waits behind no + // request: only a request's publication order is a promise. + const ticket = envelope.kind === 'request' ? this.nextTicket++ : undefined; + if (ticket !== undefined) this.publishing.push(ticket); + try { + if (!this.isOpen()) throw new DuplexError('not_connected', 'Peer is not connected.'); + let text: string; + try { + text = JSON.stringify(envelope, (_key, value: unknown) => { + if ( + typeof value === 'function' || + typeof value === 'symbol' || + (typeof value === 'number' && !Number.isFinite(value)) + ) { + throw new Error('Not a JSON value.'); + } + return value; + }); + scalarJSON(text); + } catch { + throw new DuplexError('invalid_message', 'Frame must contain serializable JSON values.'); + } + const bytes = new TextEncoder().encode(text).byteLength; + if (bytes > this.limits.maxFrameBytes) { + throw new DuplexError('frame_too_large', 'Outgoing frame exceeds the size limit.'); + } + // A wire handoff must decide bounded admission immediately. Public peer + // sends instead pace transient bursts for at most one write deadline. + const connection = this.connection; + const deadline = Date.now() + this.limits.writeTimeoutMs; + let paced = false; + for (;;) { + if (abandoned?.aborted) return; + if (!this.isOpen() || this.connection !== connection) + throw new DuplexError('not_connected', 'Peer is not connected.'); + // The queue is the one ordering gate: room alone is not enough, the + // sender must also be the one whose turn it is. That is what keeps + // publication in the order senders reserved, which the request serial + // is a promise about. + if (this.outgoing.length < this.limits.queueCapacity && (ticket === undefined || this.publishing[0] === ticket)) + break; + if (immediate || Date.now() >= deadline) { + if (this.observer) this.pressure(this.outgoing.length, true); + endOnRefusal = true; + throw new DuplexError('busy', 'Output consumer is stalled; queue limit reached.'); + } + if (!paced) { + paced = true; + if (this.observer) this.pressure(this.outgoing.length, false); + } + // An observer may synchronously withdraw the call or close the peer. + if (abandoned?.aborted || !this.isOpen() || this.connection !== connection) continue; + const room = await new Promise((resolve) => { + const wake = (available: boolean) => { + clearTimeout(timer); + this.waitingForRoom.delete(wake); + abandoned?.removeEventListener('abort', cancel); + resolve(available); + }; + const cancel = () => wake(true); + const timer = setTimeout(() => wake(false), Math.max(0, deadline - Date.now())); + this.waitingForRoom.add(wake); + abandoned?.addEventListener('abort', cancel, { once: true }); + }); + if (!room) { + if (abandoned?.aborted) return; + if (!this.isOpen() || this.connection !== connection) + throw new DuplexError('not_connected', 'Peer is not connected.'); + if (this.observer) this.pressure(this.outgoing.length, true); + endOnRefusal = true; + throw new DuplexError('busy', 'Output consumer is stalled; queue limit reached.'); + } + } + const kind = envelope.kind as string; + const trace = carriedTrace ?? traceOf(envelope); + const family = this.family(name); + // What the peer did comes before the frame that carried it, as the Go + // peer tells it: an event is emitted, then its frame is sent. The frame + // itself is observed by the writer, immediately before the bytes leave โ€” + // one serialization point per peer, so that nothing a frame draws can be + // observed received ahead of it (docs/runtime/observer.md). + if (this.observer && kind === 'event') + this.observe({ type: 'event.emitted', at: new Date(), name, bytes, trace, family }); + const observeSent = this.observer + ? () => + this.observe({ + type: 'frame.sent', + at: new Date(), + kind, + name, + bytes, + id: envelope.id as string | undefined, + trace, + family, + }) + : undefined; + // Accepted for sending is queued, as the profile says and as the Go peer + // returns: what the transport does with the frame after that is the + // transport's, held to the write deadline the flush keeps, and a sender + // that waited on the drain would hold a composition to this consumer. + this.outgoing.push({ text, started: Date.now(), sent: false, waited: false, observeSent }); + queued = true; + accepted?.(); + this.release(ticket); + this.flush(); + } catch (error) { + this.release(ticket); + if (!queued) { + const proof = new UnpublishedError(error); + // Settle this unqueued attempt before a terminal admission failure + // broadcasts an uncertain outcome to unrelated accepted requests. + refused?.(proof); + if (endOnRefusal) this.fail(asError(error)); + throw proof; + } + if (error instanceof UnpublishedError) { + const dispatched = new DuplexError(error.code, error.message, error.data); + Object.defineProperty(dispatched, 'cause', { value: error }); + throw dispatched; + } + throw error; + } + } + + private flush(): void { + if (this.writeTimer || !this.isOpen()) return; + const connection = this.connection!; + while (this.outgoing.length) { + const item = this.outgoing[0]; + if (Date.now() - item.started >= this.limits.writeTimeoutMs) { + if (this.observer) this.pressure(this.outgoing.length, true); + this.fail(new DuplexError('write_timeout', 'Socket output did not drain before the write deadline.')); + return; + } + if (!item.sent && connection.buffered === 0) { + item.observeSent?.(); + try { + connection.send({ kind: 'text', data: item.text }); + item.sent = true; + } catch { + this.fail(new DuplexError('send_failed', 'WebSocket send failed.')); + return; + } + } + if (item.sent && connection.buffered === 0) { + this.outgoing.shift(); + for (const wake of [...this.waitingForRoom]) wake(true); + } else { + item.waited = true; + this.writeTimer = setTimeout(() => { + this.writeTimer = undefined; + this.flush(); + }, 5); + return; + } + } + } + + private receive(incoming: Frame): void { + if (!this.isOpen()) return; + if (incoming.kind !== 'text') { + this.fail(new DuplexError('invalid_message', 'Only JSON text frames are supported.')); + return; + } + const data = incoming.data; + const bytes = new TextEncoder().encode(data).byteLength; + if (bytes > this.limits.maxFrameBytes) { + this.fail(new DuplexError('frame_too_large', 'Incoming frame exceeds the size limit.')); + return; + } + let frame: Envelope; + try { + frame = decodeEnvelope(data, this.localPrefix, this.remotePrefix); + } catch { + this.fail(new DuplexError('invalid_message', 'Invalid duplex frame.')); + return; + } + const trace = traceOf(frame); + if (this.observer) { + const name = this.nameOf(frame); + this.observe({ + type: 'frame.received', + at: new Date(), + kind: frame.kind as string, + name, + bytes, + id: frame.id as string | undefined, + trace, + family: this.family(name), + }); + } + // Within one connection instance and one direction, each request's serial + // is greater than every request's published before it. Gaps are allowed; a + // serial that does not increase is a protocol violation, as a malformed + // frame is, because the peer could not then say which invocation a later + // control names. + if (frame.kind === 'request') { + const serial = Number((frame.id as string).slice(this.remotePrefix.length)); + if (!Number.isSafeInteger(serial) || serial <= this.admittedSerial) { + this.fail(new DuplexError('invalid_message', 'Duplex request serial did not increase.')); + return; + } + this.admittedSerial = serial; + } + switch (frame.kind) { + case 'response': { + const id = frame.id as string; + const pending = this.takePending(id); + if (!pending) return; // A cancellation or deadline may precede a late response. + if (isObject(frame.error)) { + const error = new DuplexError(frame.error.code as string, frame.error.message as string, frame.error.data); + this.ended(id, pending, 'error', error.code); + pending.reject(error); + } else { + this.ended(id, pending, 'ok'); + pending.resolve(frame.result); + } + break; + } + case 'cancel': { + // A cancel withdraws the request and answers nothing itself: the + // receiver aborts the handler's signal, and the response โ€” cancelled, + // whatever the handler goes on to return โ€” is the handler's return, + // as the profile says and the Go peer does. What frees the + // correlation is the work ending, not the asking to end it. + this.incoming.get(frame.id as string)?.controller.abort(); + break; + } + case 'request': + this.request(frame.id as string, frame.method as string, frame.params, trace, frame.meta as Meta | undefined); + break; + case 'event': + this.event(frame.event as string, frame.data, bytes, trace, frame.meta as Meta | undefined); + break; + } + } + + private request(id: string, method: string, params: unknown, trace?: Trace, meta?: Meta): void { + if (this.incoming.has(id)) { + this.fail(new DuplexError('invalid_message', 'An incoming request ID is already active.')); + return; + } + if (this.incoming.size >= this.limits.maxConcurrentHandlers) { + void this.send( + traced( + { version: 1, kind: 'response', id, error: { code: 'busy', message: 'Incoming request limit reached.' } }, + trace, + ), + method, + ).catch((error) => this.fail(asError(error))); + return; + } + const controller = new AbortController(); + const incoming: Incoming = { + controller, + responded: false, + trace, + method, + started: Date.now(), + timer: setTimeout(() => { + controller.abort(); + // What crosses the wire when a receiver's own deadline passes is + // `cancelled`: the request was abandoned, which is what the caller can + // act on, and is what the profile and the Go peer both answer. + // `request_timeout` is a caller's own error and never a frame. + this.respond(id, incoming, undefined, new DuplexError('cancelled', 'Request deadline exceeded.'), 'timeout'); + }, this.limits.requestTimeoutMs), + }; + this.incoming.set(id, incoming); + if (this.observer) + this.observe({ + type: 'request.started', + at: new Date(), + id, + method, + incoming: true, + trace, + family: this.family(method), + }); + const context: RequestContext = { peer: this, signal: controller.signal, requestId: id }; + if (meta) context.meta = meta; + this.propagator.extract(context, trace); + const attachedHandler = this.wireRequest?.(method); + void Promise.resolve() + .then(() => { + // The peer can close or cancel before the handler's first microtask. + if (controller.signal.aborted) { + this.respond(id, incoming, undefined, new DuplexError('cancelled', 'Request was cancelled.'), 'cancelled'); + return; + } + if (attachedHandler) return attachedHandler(params, context); + const handler = this.handlers.get(method); + if (handler) return handler(params, context); + if (this.options.dispatch) return this.options.dispatch(method, params, context); + throw new DuplexError('method_not_found', `Unknown method ${method}.`); + }) + .then( + (result) => this.respond(id, incoming, result === undefined ? null : result), + (error: unknown) => { + // Anything a handler threw but a public error is this runtime's panic. + if (this.observer && !(error instanceof DuplexError)) { + this.observe({ + type: 'handler.panic', + at: new Date(), + method, + value: describe(error), + trace, + family: this.family(method), + }); + } + this.respond( + id, + incoming, + undefined, + error instanceof DuplexError ? error : new DuplexError('internal', 'Request handler failed.'), + ); + }, + ) + .finally(() => { + clearTimeout(incoming.timer); + if (this.incoming.get(id) === incoming) this.incoming.delete(id); + }); + } + + private respond(id: string, incoming: Incoming, result?: unknown, error?: DuplexError, outcome?: Outcome): void { + if (incoming.responded || this.incoming.get(id) !== incoming || !this.isOpen()) return; + // A result returned after withdrawal becomes a local cancellation. A + // handler's public refusal stays an error, whatever its code says. + if (!error && incoming.controller.signal.aborted) { + error = new DuplexError('cancelled', 'Request was cancelled.'); + outcome ??= 'cancelled'; + } + outcome ??= error ? 'error' : 'ok'; + incoming.responded = true; + clearTimeout(incoming.timer); + const frame: Envelope = { version: 1, kind: 'response', id }; + if (error) + frame.error = { + code: error.code, + message: error.message, + ...(error.data === undefined ? {} : { data: error.data }), + }; + else frame.result = result; + // The request ends before its response is sent, as the Go peer tells it; + // a response carries its request's trace, mints none of its own, and is + // named by nothing โ€” its id says which request it answers. + if (this.observer) { + this.observe({ + type: 'request.ended', + at: new Date(), + id, + method: incoming.method, + incoming: true, + durationMs: Date.now() - incoming.started, + outcome, + // The observer names this peer's deadline; the response still says cancelled. + errorCode: outcome === 'timeout' ? 'request_timeout' : error?.code, + trace: incoming.trace, + family: this.family(incoming.method), + }); + } + void this.send(traced(frame, incoming.trace), '').catch(async (error: unknown) => { + // An unencodable response must settle the call, as the Go peer does, + // without publishing a replacement for malformed handler output. + if (error instanceof DuplexError && ['invalid_message', 'frame_too_large'].includes(error.code)) { + try { + await this.send( + traced( + { + version: 1, + kind: 'response', + id, + error: { code: 'internal', message: 'Response could not be encoded' }, + }, + incoming.trace, + ), + ); + return; + } catch (fallbackError) { + this.fail(asError(fallbackError)); + return; + } + } + this.fail(asError(error)); + }); + } + + private event(name: string, data: unknown, bytes: number, trace?: Trace, meta?: Meta): void { + const queued = this.events.length + Number(this.eventActive); + if (queued >= this.limits.queueCapacity && !this.stallTimer) { + // A full queue can be a healthy transient burst, so the producer is paced + // for one write deadline before the consumer is declared stalled, as the + // Go peer paces it. The producer is the remote, and a peer here cannot + // pause what it is handed โ€” a socket delivers when it delivers โ€” so the + // events are held rather than the reading stopped. The deadline is the + // same, and so is what happens at it. + if (this.observer) this.pressure(queued, false); + this.stallTimer = setTimeout(() => { + this.stallTimer = undefined; + if (this.observer) this.pressure(this.events.length + Number(this.eventActive), true); + this.fail(new DuplexError('busy', 'Event consumer is stalled; queue limit reached.')); + }, this.limits.writeTimeoutMs); + } + this.events.push({ name, data, bytes, trace, meta }); + this.drainEvents(); + } + + /** The queue came back under capacity within its deadline: the burst drained. */ + private drained(): void { + if (!this.stallTimer || this.events.length + Number(this.eventActive) >= this.limits.queueCapacity) return; + clearTimeout(this.stallTimer); + this.stallTimer = undefined; + } + + private drainEvents(): void { + if (this.eventActive || !this.isOpen()) return; + const event = this.events.shift(); + if (!event) return; + this.eventActive = true; + // Delivered when it reaches the listeners, not when its frame arrived. + if (this.observer) + this.observe({ + type: 'event.delivered', + at: new Date(), + name: event.name, + bytes: event.bytes, + trace: event.trace, + family: this.family(event.name), + }); + const generation = this.generation; + this.eventTimer = setTimeout(() => { + if (this.observer) this.pressure(this.events.length, true); + this.fail(new DuplexError('stalled_consumer', 'Event handler deadline exceeded.')); + }, this.limits.writeTimeoutMs); + const listeners = [...this.listeners]; + const wireListener = this.wireEvent?.(event.name); + if (wireListener) listeners.push(wireListener); + const context: EventContext = { peer: this }; + setReceivedEventTrace(context, event.trace); + this.propagator.extract(context, event.trace); + if (event.meta) context.meta = event.meta; + let index = 0; + const finish = () => { + if (generation !== this.generation) return; + clearTimeout(this.eventTimer); + this.eventTimer = undefined; + this.eventActive = false; + // Where the backlog is judged: an event finishing is the only thing that + // brings the queue under capacity, so a burst that drained within its + // deadline stops being one here. + this.drained(); + this.drainEvents(); + }; + const next = () => { + while (index < listeners.length) { + if (!this.isOpen() || generation !== this.generation) return; + try { + const result = listeners[index++](event.name, event.data, context); + if (result && typeof result.then === 'function') { + void result.then(next, () => { + this.notifyError(new DuplexError('event_handler_failed', 'An event handler failed.')); + next(); + }); + return; + } + } catch { + this.notifyError(new DuplexError('event_handler_failed', 'An event handler failed.')); + } + } + finish(); + }; + // A synchronous listener must finish synchronously: a native WebSocket can + // deliver a large replay burst within one turn, before any microtask runs. + next(); + } + + private fail(error: DuplexError, closeConnection = true, code = 4011, reason = CLOSE_REASON): void { + // Ending a connection settles unrelated, possibly delivered requests too. + // A failed reply's local proof must not be broadcast as their send outcome. + if (error instanceof UnpublishedError) { + const cause = error; + error = new DuplexError(cause.code, cause.message, cause.data); + Object.defineProperty(error, 'cause', { value: cause }); + } + const connection = this.connection; + if (!connection) return; + this.connection = undefined; + this.state = 'disconnected'; + this.generation++; + this.negotiated = ''; + this.detach?.(); + this.detach = undefined; + clearTimeout(this.writeTimer); + this.writeTimer = undefined; + clearTimeout(this.eventTimer); + this.eventTimer = undefined; + clearTimeout(this.stallTimer); + this.stallTimer = undefined; + this.eventActive = false; + this.events.length = 0; + for (const wake of [...this.waitingForRoom]) wake(true); + if (this.opening) { + clearTimeout(this.opening.timer); + this.opening.reject(error); + this.opening = undefined; + } + for (const id of [...this.pending.keys()]) { + const pending = this.takePending(id); + if (!pending) continue; + this.ended(id, pending, 'error', error.code); + pending.reject(error); + } + for (const [id, request] of this.incoming) { + clearTimeout(request.timer); + request.controller.abort(); + if (this.observer && !request.responded) { + this.observe({ + type: 'request.ended', + at: new Date(), + id, + method: request.method, + incoming: true, + durationMs: Date.now() - request.started, + outcome: 'error', + errorCode: error.code, + trace: request.trace, + family: this.family(request.method), + }); + } + } + this.incoming.clear(); + // Nothing here is a caller's promise: a send resolved when it was queued. + this.outgoing.length = 0; + if (closeConnection) { + // Browser close() restricts application codes to 3000โ€“4999 (or 1000). + try { + connection.close(code, reason); + } catch { + /* Already closed. */ + } + } + // Reported once everything it ended has been. The peer closes the + // connection exactly when the close is its own. + if (this.observer) + this.observe({ type: 'connection.closed', at: new Date(), code, reason, local: closeConnection }); + this.notifyError(error); + for (const listener of this.closedListeners) { + try { + listener(error); + } catch { + /* Observers cannot interrupt cleanup. */ + } + } + } + + /** + * Tells this peer's observer one event, the runtime's own or one of a layer + * running over the peer, which is how a tunnel and a live scope observe โ€” through + * the peer they run over, rather than through an observer of their own. An + * observer that is absent costs nothing, and one that throws interrupts nothing. + */ + observe(event: ObserverEvent): void { + try { + this.observer?.observe(event); + } catch { + /* Observers cannot interrupt routing. */ + } + } + + private pressure(queued: number, stalled: boolean): void { + this.observe({ type: 'backpressure', at: new Date(), queued, stalled, deadlineMs: this.limits.writeTimeoutMs }); + } + + /** Every outgoing call ends once, wherever it settles. */ + private ended(id: string, pending: Pending, outcome: Outcome, errorCode?: string): void { + if (!this.observer) return; + this.observe({ + type: 'request.ended', + at: new Date(), + id, + method: pending.method, + incoming: false, + durationMs: Date.now() - pending.started, + outcome, + errorCode, + trace: pending.trace, + family: this.family(pending.method), + }); + } + + /** What a frame is named on the wire: a request its method, an event its event; a response or a cancel nothing, its id says which request it concerns. */ + private nameOf(frame: Envelope): string { + switch (frame.kind) { + case 'request': + return frame.method as string; + case 'event': + return frame.event as string; + default: + return ''; + } + } + + /** The label the caller gave this method or event name; an unlabelled name has none. */ + private family(name: string): string { + const label = this.options.families?.[name]; + return typeof label === 'string' ? label : ''; + } + + private notifyError(error: DuplexError): void { + try { + this.options.onError?.(error); + } catch { + /* Observers cannot interrupt routing. */ + } + } +} + +function isWebSocketLike(value: FrameConnection | WebSocketLike): value is WebSocketLike { + return typeof (value as WebSocketLike).readyState === 'number'; +} +/** What the handshake selected, as WebSocket.protocol spells it; '' when none. */ +function subprotocolOf(socket: WebSocketLike | undefined): string { + const selected = (socket as { protocol?: unknown } | undefined)?.protocol; + return typeof selected === 'string' ? selected : ''; +} +/** The reason a peer gives for a close of its own. */ +const CLOSE_REASON = 'Duplex connection closed'; +/** What a handler threw, as a string: never its params, and never a payload. */ +function describe(value: unknown): string { + try { + return String(value); + } catch { + return '[unprintable value]'; + } +} +/** Validates a component limit, returning it or throwing invalid_options; safe also requires exact integer representation. */ +export function positiveInteger(value: unknown, name: string, safe = false): number { + if (typeof value !== 'number' || !(safe ? Number.isSafeInteger(value) : Number.isInteger(value)) || value <= 0) { + throw new DuplexError('invalid_options', `${name} must be a positive ${safe ? 'safe ' : ''}integer.`); + } + return value; +} +function asError(error: unknown, code = 'internal'): DuplexError { + return error instanceof DuplexError ? error : new DuplexError(code, 'Duplex operation failed.'); +} diff --git a/transports/ts/src/transport.ts b/transports/ts/src/transport.ts new file mode 100644 index 0000000..77cb61f --- /dev/null +++ b/transports/ts/src/transport.ts @@ -0,0 +1,247 @@ +export { at, mount, encodePath, decodePath, WireError } from './wire.ts'; +export type { + Path, + ProfileKind, + ProfileFrame, + ProfileError, + ReturnAddress, + Message, + Receiver, + Wire, + Endpoint, +} from './wire.ts'; +export { record, MemoryWireLog, RecordError } from './record.ts'; +export type { WireRecord, WireLog, RecordOptions, RecordedWire, Follower } from './record.ts'; + +/** + * A frames duplex connection: ordered, message-framed, bidirectional, with an + * explicit close carrying a code and a reason. Nothing about JSON, requests, + * correlation or events belongs here; those stay in the peer. + */ +export type Frame = { kind: 'text'; data: string } | { kind: 'binary'; data: ArrayBuffer | Uint8Array }; +/** Where a connection is in its life; frames flow only while `open`. */ +export type ConnectionState = 'connecting' | 'open' | 'closing' | 'closed'; +/** What a connection tells the one listening: it opened, a frame arrived, it closed with a code and a reason, or it failed. */ +export interface ConnectionHandlers { + open?: () => void; + frame?: (frame: Frame) => void; + close?: (code: number, reason: string) => void; + error?: () => void; +} + +/** + * Close codes are the WebSocket registry's numbers (1000 normal, 1008 policy, + * 1009 too big, 1011 internal, 4000โ€“4999 application) on every transport, so + * that close semantics travel with the peer. + */ +export interface FrameConnection { + readonly state: ConnectionState; + /** + * What a send left with the connection and the transport has not taken yet, + * in whatever the transport counts: a WebSocket's bytes, a pipe's or a + * tunnel channel's frames. The peer paces on it and reads only whether it is + * zero, so a transport that can take no more says so by counting what waits + * rather than by taking without bound. + */ + readonly buffered: number; + /** Throws when the connection is not open. */ + send(frame: Frame): void; + close(code?: number, reason?: string): void; + /** Registers handlers; returns a function that detaches all of them. */ + listen(handlers: ConnectionHandlers): () => void; +} + +/** The browser WebSocket surface, also implemented by Node's native WebSocket. */ +export interface WebSocketLike { + readonly readyState: number; + readonly bufferedAmount: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: string, listener: globalThis.EventListener): void; + removeEventListener(type: string, listener: globalThis.EventListener): void; +} + +const STATES: readonly ConnectionState[] = ['connecting', 'open', 'closing', 'closed']; +/** The registry's "no status code present": what a side reads when the other closed with no code, never sent. */ +export const NO_STATUS = 1005; + +/** + * Adapts a WebSocket to a frames duplex connection: readyState maps to state, + * bufferedAmount to buffered, a string message to a text frame, an ArrayBuffer, + * Uint8Array or Blob to a binary frame, and the close event's code and reason + * to the close handler. Text frames are delivered synchronously, in the turn + * the socket delivers them. + */ +export function webSocketConnection(socket: WebSocketLike): FrameConnection { + const listeners = new Set(); + const each = ( + name: K, + ...args: Parameters> + ) => { + for (const handlers of [...listeners]) { + (handlers[name] as ((...args: unknown[]) => void) | undefined)?.(...args); + } + }; + // A Blob is read asynchronously; frames after it wait behind it so order holds. + let tail: Promise | undefined; + const after = (task: () => void | Promise) => { + const next: Promise = (tail ?? Promise.resolve()) + .then(task) + .catch(() => {}) + .then(() => { + if (tail === next) tail = undefined; + }); + tail = next; + }; + const deliver = (frame: Frame) => { + if (tail) after(() => each('frame', frame)); + else each('frame', frame); + }; + const open = () => each('open'); + const message = (event: globalThis.Event) => { + const data: unknown = (event as MessageEvent).data; + if (typeof data === 'string') deliver({ kind: 'text', data }); + else if (data instanceof ArrayBuffer || data instanceof Uint8Array) deliver({ kind: 'binary', data }); + else if (isBlob(data)) { + after(() => + data.arrayBuffer().then( + (buffer) => each('frame', { kind: 'binary', data: buffer }), + () => each('error'), + ), + ); + } else each('error'); + }; + const close = (event: globalThis.Event) => { + const { code, reason } = event as Partial; + detach(); + each('close', typeof code === 'number' ? code : NO_STATUS, typeof reason === 'string' ? reason : ''); + }; + const error = () => each('error'); + socket.addEventListener('open', open); + socket.addEventListener('message', message); + socket.addEventListener('close', close); + socket.addEventListener('error', error); + const detach = () => { + socket.removeEventListener('open', open); + socket.removeEventListener('message', message); + socket.removeEventListener('close', close); + socket.removeEventListener('error', error); + }; + // Browsers deliver binary as Blob unless told otherwise; ArrayBuffer keeps delivery synchronous. + if ('binaryType' in socket) { + try { + (socket as { binaryType: string }).binaryType = 'arraybuffer'; + } catch { + /* Not settable here. */ + } + } + return { + get state() { + return STATES[socket.readyState] ?? 'closed'; + }, + get buffered() { + return socket.bufferedAmount; + }, + send(frame) { + if (socket.readyState !== 1) throw new Error('Connection is not open.'); + // WebSocketLike declares the text surface the peer needs; real sockets also accept binary. + (socket.send as (data: string | ArrayBuffer | Uint8Array) => void)(frame.data); + }, + close(code = 1000, reason = '') { + socket.close(code, reason); + }, + listen(handlers) { + listeners.add(handlers); + return () => { + listeners.delete(handlers); + }; + }, + }; +} + +/** + * How many frames a pipe holds in flight per direction before a send is held: + * the bound the Go pipe has, as a socket holds some bytes and no more. + */ +const IN_FLIGHT = 8; + +/** + * Two connected ends in memory: what one sends, the other receives, in order, + * in a later microtask; a close on one end is the close on the other, with its + * code and reason. Each direction holds at most eight frames in flight, as the + * Go pipe does: a send past the bound is held until the far end takes a frame, + * `buffered` counts what is held and reads zero once the far end took it, and + * a peer over the pipe paces on it exactly as it paces on a WebSocket's + * `bufferedAmount`. The far end takes a frame when the frame is handed to a + * listener, so an end nobody listens to holds what was sent rather than losing + * it, and is given it in order once someone listens. It carries the profile in + * tests without a socket, as the Go pipe does. + */ +export function pipe(): [FrameConnection, FrameConnection] { + class End implements FrameConnection { + state: ConnectionState = 'open'; + partner!: End; + /** What the transport took and the far end has not been handed: at most IN_FLIGHT. */ + private readonly inFlight: Frame[] = []; + /** What a send left past the bound, waiting for room; what buffered counts. */ + private readonly held: Frame[] = []; + private draining = false; + private readonly listeners = new Set(); + get buffered(): number { + return this.held.length; + } + send(frame: Frame): void { + if (this.state !== 'open') throw new Error('Connection is not open.'); + (this.inFlight.length < IN_FLIGHT ? this.inFlight : this.held).push(frame); + this.drainLater(); + } + close(code = 1000, reason = ''): void { + if (this.state === 'closed') return; + this.state = 'closed'; + // What neither end has been handed goes nowhere, so nothing is buffered + // on a connection that has ended. + this.inFlight.length = 0; + this.held.length = 0; + for (const handlers of [...this.listeners]) handlers.close?.(code, reason); + this.listeners.clear(); + this.partner.close(code, reason); + } + listen(handlers: ConnectionHandlers): () => void { + this.listeners.add(handlers); + // What the partner sent while nobody listened has a taker now. + this.partner.drainLater(); + return () => { + this.listeners.delete(handlers); + }; + } + /** Hands on what the far end can take, in a later turn and never inside a send. */ + private drainLater(): void { + if (this.draining) return; + this.draining = true; + queueMicrotask(() => { + this.draining = false; + this.drain(); + }); + } + private drain(): void { + while (this.inFlight.length > 0) { + const partner = this.partner; + // A frame is taken when a listener is handed it; until there is one it + // waits, and so does everything a send left behind it. + if (this.state !== 'open' || partner.state !== 'open' || partner.listeners.size === 0) return; + const frame = this.inFlight.shift()!; + if (this.held.length > 0) this.inFlight.push(this.held.shift()!); + for (const handlers of [...partner.listeners]) handlers.frame?.(frame); + } + } + } + const left = new End(); + const right = new End(); + left.partner = right; + right.partner = left; + return [left, right]; +} + +function isBlob(value: unknown): value is Blob { + return typeof Blob !== 'undefined' && value instanceof Blob; +} From e3237a7b79b881d2280111a0933f386db87174ac Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:48:13 +0200 Subject: [PATCH 07/39] conformance: interoperate with Nightseam v0.6.0 peers over real WebSockets A scenario every implementation serves and calls (conformance/interop): echo of awkward JSON, slash and empty segments, method_not_found, public errors with data, events both ways, cancellation reaching the server, meta, a reverse call, a 3 MiB frame under a 4 MiB budget and twenty concurrent calls. Programs for bitruntime Go and for Nightseam v0.6.0 Go and TypeScript, each pinned in its own test-only module; the Nightseam ones compile against Bitwire 0.2.0 and never enter a published package. A raw recorder replays fixed envelopes and captures what each server sends. bitruntime Go's transcript equals Nightseam v0.6.0 Go's byte for byte, masking only random span identifiers. node scripts/interop.mjs: all nine pairings pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/interop/README.md | 71 +++++ conformance/interop/bitruntime/go/main.go | 248 ++++++++++++++++++ conformance/interop/nightseam/go/go.mod | 13 + conformance/interop/nightseam/go/go.sum | 8 + conformance/interop/nightseam/go/main.go | 246 +++++++++++++++++ conformance/interop/nightseam/ts/.npmrc | 3 + conformance/interop/nightseam/ts/main.mjs | 95 +++++++ .../interop/nightseam/ts/package-lock.json | 68 +++++ conformance/interop/nightseam/ts/package.json | 13 + conformance/interop/recorder/go/main.go | 132 ++++++++++ scripts/interop.mjs | 133 ++++++++++ 11 files changed, 1030 insertions(+) create mode 100644 conformance/interop/README.md create mode 100644 conformance/interop/bitruntime/go/main.go create mode 100644 conformance/interop/nightseam/go/go.mod create mode 100644 conformance/interop/nightseam/go/go.sum create mode 100644 conformance/interop/nightseam/go/main.go create mode 100644 conformance/interop/nightseam/ts/.npmrc create mode 100644 conformance/interop/nightseam/ts/main.mjs create mode 100644 conformance/interop/nightseam/ts/package-lock.json create mode 100644 conformance/interop/nightseam/ts/package.json create mode 100644 conformance/interop/recorder/go/main.go create mode 100644 scripts/interop.mjs diff --git a/conformance/interop/README.md b/conformance/interop/README.md new file mode 100644 index 0000000..16f8682 --- /dev/null +++ b/conformance/interop/README.md @@ -0,0 +1,71 @@ +# Interoperability with Nightseam v0.6.0 + +Test-only evidence that bitruntime speaks `bitwire/1` as Nightseam v0.6.0 does. +Bitwire decision 0008 defines `bitwire/1` as that release's behavior, so a +bitruntime peer and a Nightseam v0.6.0 peer must serve each other in both roles, +in both languages, and send the same bytes for the same exchange. + +Nightseam v0.6.0 compiles against Bitwire 0.2.0, whose `Wire` is path-taking, +and bitruntime against Bitwire 0.3.0, so the two cannot share one Go binary. Each +implementation is its own program, and they meet only over real WebSockets. +The Nightseam programs are isolated in their own test-only modules and are never +a dependency of a published package. + +| Program | Implementation | +| --- | --- | +| `bitruntime/go` | this repository's Go runtime | +| `bitruntime/ts` | this repository's TypeScript runtime | +| `nightseam/go` | Nightseam v0.6.0 Go (`github.com/Bitspark/nightseam v0.6.0`) | +| `nightseam/ts` | Nightseam v0.6.0 TypeScript (`@nightseam/runtime` and `@nightseam/duplex` 0.6.0) | + +## The scenario + +Every program has two roles. `server ` listens on `127.0.0.1:` and +prints `LISTEN ws://127.0.0.1:/wire` once it accepts connections. `client +` connects, runs the calls below and prints one JSON object of observations. +Both sides use a 4 MiB frame budget, as bitsystem3 does. + +The server serves, at its connection's root: + +| Path | Kind | Behavior | +| --- | --- | --- | +| `["echo"]` | request | returns its params | +| `["spaces", "a/b", "echo"]` | request | returns `{"space": "a/b", "params": params}` | +| `["spaces", "รฉ", ""]` | request | returns `"unicode-empty"` | +| `["fail"]` | request | refuses with public error `bad_request`, `refused on purpose`, data `{"n": 1}` | +| `["wait"]` | request | waits until cancelled, then records the cancellation | +| `["cancelled"]` | request | returns whether a `wait` was cancelled, waiting up to 2 s | +| `["meta"]` | request | returns the meta its request carried, `{}` for none | +| `["reverse"]` | request | calls the client's `["whoami"]` over the same connection and returns its result | +| `["big"]` | request | returns a string of `params.n` `x` characters | +| `["ping"]` | event | emits event `["pong"]` with `{"echo": data}` | + +The client serves `["whoami"]`, returning `"-client"` where `` is its +program's name, and listens for `["pong"]`. It observes: + +| Observation | Expected | +| --- | --- | +| `echo` | the params `{"a":[1,"x",null,true],"n":1000,"u":"๐Ÿ˜€"}`, as a JSON value | +| `nested` | `{"space":"a/b","params":{"x":1}}` | +| `unicodeEmpty` | `"unicode-empty"` | +| `missing` | error code `method_not_found` for `["missing"]` | +| `fail` | `{"code":"bad_request","message":"refused on purpose","data":{"n":1}}` | +| `pong` | `{"echo":7}` after emitting `["ping"]` with `7` | +| `withdrawn` | `true`: a `wait` call withdrawn after 300 ms ended without a response | +| `serverSawCancel` | `true` | +| `meta` | `{"tenant":"t1"}` for a `meta` call carrying that meta | +| `reverse` | `"-client"` of the client program | +| `bigLength` | `3145728` | +| `concurrent` | `20`: twenty concurrent `echo` calls each returned its own params | + +`scripts/interop.mjs` builds every program, starts each server and runs each +client against it, and compares the observations with this table. + +## Byte-level identity + +`recorder/go` is a raw WebSocket client that sends fixed envelopes to a server +and records every frame the server sends back, answering the server's reverse +request itself. The runner records each server and requires the bitruntime +transcripts to equal the Nightseam transcripts of the same language byte for +byte, after masking only the random span identifiers a peer mints for its own +requests. diff --git a/conformance/interop/bitruntime/go/main.go b/conformance/interop/bitruntime/go/main.go new file mode 100644 index 0000000..576cc4c --- /dev/null +++ b/conformance/interop/bitruntime/go/main.go @@ -0,0 +1,248 @@ +// Command bitruntime-interop is bitruntime's Go program of the interoperability +// scenario in conformance/interop/README.md. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + websocket "github.com/Bitspark/bitruntime/engine/websocket/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +const name = "bitruntime-go" +const maxFrameBytes = 4 << 20 + +func main() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: server | client ") + os.Exit(2) + } + var err error + switch os.Args[1] { + case "server": + err = serve(os.Args[2]) + case "client": + err = client(os.Args[2]) + default: + err = fmt.Errorf("unknown role %q", os.Args[1]) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func serve(port string) error { + var mu sync.Mutex + sawCancel := make(chan struct{}) + handler, err := websocket.NewHandler(websocket.ServerOptions{ + Authenticate: func(r *http.Request) (context.Context, error) { return context.Background(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + Options: engine.Options{MaxFrameBytes: maxFrameBytes, Prepare: func(peer *engine.Peer) error { + d, err := dispatch.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + handle := func(path []string, h dispatch.Handler) error { + _, err := dispatch.Handle(d, path, h) + return err + } + return errors.Join( + handle([]string{"echo"}, func(ctx context.Context, raw json.RawMessage) (any, error) { return raw, nil }), + handle([]string{"spaces", "a/b", "echo"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + return map[string]any{"space": "a/b", "params": raw}, nil + }), + handle([]string{"spaces", "รฉ", ""}, func(context.Context, json.RawMessage) (any, error) { return "unicode-empty", nil }), + handle([]string{"fail"}, func(context.Context, json.RawMessage) (any, error) { + return nil, &core.PublicError{Code: "bad_request", Message: "refused on purpose", Data: json.RawMessage(`{"n":1}`)} + }), + handle([]string{"wait"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + <-ctx.Done() + mu.Lock() + select { + case <-sawCancel: + default: + close(sawCancel) + } + mu.Unlock() + return nil, ctx.Err() + }), + handle([]string{"cancelled"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + select { + case <-sawCancel: + return true, nil + case <-time.After(2 * time.Second): + return false, nil + } + }), + handle([]string{"meta"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + meta := core.MetaFrom(ctx) + if meta == nil { + meta = core.Meta{} + } + return meta, nil + }), + handle([]string{"reverse"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var who string + err := dispatch.Call(ctx, peer.Wire(), []string{"whoami"}, nil, &who) + return who, err + }), + handle([]string{"big"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var args struct{ N int } + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + return strings.Repeat("x", args.N), nil + }), + func() error { + _, err := dispatch.Register(d, []string{"ping"}, dispatch.Handlers{Event: func(ctx context.Context, raw json.RawMessage) error { + return dispatch.Emit(context.Background(), peer.Wire(), []string{"pong"}, map[string]json.RawMessage{"echo": raw}) + }}) + return err + }(), + ) + }}, + }) + if err != nil { + return err + } + listener, err := net.Listen("tcp", "127.0.0.1:"+port) + if err != nil { + return err + } + mux := http.NewServeMux() + mux.Handle("/wire", handler) + fmt.Printf("LISTEN ws://%s/wire\n", listener.Addr()) + return http.Serve(listener, mux) +} + +func client(url string) error { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + pongs := make(chan json.RawMessage, 1) + peer, _, err := websocket.Dial(ctx, url, websocket.DialOptions{Options: engine.Options{MaxFrameBytes: maxFrameBytes, Prepare: func(peer *engine.Peer) error { + d, err := dispatch.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + if _, err := dispatch.Handle(d, []string{"whoami"}, func(context.Context, json.RawMessage) (any, error) { return name + "-client", nil }); err != nil { + return err + } + _, err = dispatch.Register(d, []string{"pong"}, dispatch.Handlers{Event: func(ctx context.Context, raw json.RawMessage) error { + pongs <- raw + return nil + }}) + return err + }}}) + if err != nil { + return err + } + defer peer.Close() + root := peer.Wire() + observed := map[string]any{} + call := func(path []string, params any) (json.RawMessage, error) { + var result json.RawMessage + err := dispatch.Call(ctx, root, path, params, &result) + return result, err + } + code := func(err error) any { + var public *core.PublicError + if errors.As(err, &public) { + return map[string]any{"code": public.Code, "message": public.Message, "data": public.Data} + } + if err != nil { + return err.Error() + } + return nil + } + if result, err := call([]string{"echo"}, json.RawMessage(`{"a":[1,"x",null,true],"n":1e3,"u":"๐Ÿ˜€"}`)); err != nil { + observed["echo"] = code(err) + } else { + observed["echo"] = result + } + if result, err := call([]string{"spaces", "a/b", "echo"}, map[string]int{"x": 1}); err == nil { + observed["nested"] = result + } else { + observed["nested"] = code(err) + } + if result, err := call([]string{"spaces", "รฉ", ""}, nil); err == nil { + observed["unicodeEmpty"] = result + } else { + observed["unicodeEmpty"] = code(err) + } + _, err = call([]string{"missing"}, nil) + var public *core.PublicError + if errors.As(err, &public) { + observed["missing"] = public.Code + } else { + observed["missing"] = code(err) + } + _, err = call([]string{"fail"}, nil) + observed["fail"] = code(err) + if err := dispatch.Emit(ctx, root, []string{"ping"}, 7); err != nil { + observed["pong"] = code(err) + } else { + select { + case pong := <-pongs: + observed["pong"] = pong + case <-time.After(5 * time.Second): + observed["pong"] = "no pong" + } + } + withdrawn, withdraw := context.WithTimeout(ctx, 300*time.Millisecond) + err = dispatch.Call(withdrawn, root, []string{"wait"}, nil, nil) + withdraw() + observed["withdrawn"] = err != nil && errors.Is(err, context.DeadlineExceeded) + if result, err := call([]string{"cancelled"}, nil); err == nil { + observed["serverSawCancel"] = result + } else { + observed["serverSawCancel"] = code(err) + } + var meta json.RawMessage + if err := dispatch.Call(core.WithMeta(ctx, core.Meta{"tenant": "t1"}), root, []string{"meta"}, nil, &meta); err == nil { + observed["meta"] = meta + } else { + observed["meta"] = code(err) + } + if result, err := call([]string{"reverse"}, nil); err == nil { + observed["reverse"] = result + } else { + observed["reverse"] = code(err) + } + var big string + if err := dispatch.Call(ctx, root, []string{"big"}, map[string]int{"n": 3 << 20}, &big); err == nil { + observed["bigLength"] = len(big) + } else { + observed["bigLength"] = code(err) + } + var wg sync.WaitGroup + var matched sync.Map + for i := range 20 { + wg.Add(1) + go func() { + defer wg.Done() + var got map[string]int + if err := dispatch.Call(ctx, root, []string{"echo"}, map[string]int{"i": i}, &got); err == nil && got["i"] == i { + matched.Store(i, true) + } + }() + } + wg.Wait() + concurrent := 0 + matched.Range(func(any, any) bool { concurrent++; return true }) + observed["concurrent"] = concurrent + var _ wire.AddressedWire = root + return json.NewEncoder(os.Stdout).Encode(observed) +} diff --git a/conformance/interop/nightseam/go/go.mod b/conformance/interop/nightseam/go/go.mod new file mode 100644 index 0000000..132130a --- /dev/null +++ b/conformance/interop/nightseam/go/go.mod @@ -0,0 +1,13 @@ +module bitruntime.conformance/interop/nightseam + +go 1.26.0 + +require ( + github.com/Bitspark/bitwire v0.2.0 + github.com/Bitspark/nightseam v0.6.0 +) + +require ( + github.com/coder/websocket v1.8.15 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect +) diff --git a/conformance/interop/nightseam/go/go.sum b/conformance/interop/nightseam/go/go.sum new file mode 100644 index 0000000..5e3f25b --- /dev/null +++ b/conformance/interop/nightseam/go/go.sum @@ -0,0 +1,8 @@ +github.com/Bitspark/bitwire v0.2.0 h1:gGlNYgfzAHqZtxorw6HkCTWOoInil//uchOWhTA/d3k= +github.com/Bitspark/bitwire v0.2.0/go.mod h1:RCsIrMm1o0hg/SlyG2LkXXSzj2CMLEhoOIuuw8XrePk= +github.com/Bitspark/nightseam v0.6.0 h1:5IZQN8dKMAGrLHMXaXrZ4rTS2syIL/9EgNGod1IdTCM= +github.com/Bitspark/nightseam v0.6.0/go.mod h1:3l4w/n5bWtSGBpcDtdztUagnq9NHjqMsBLNmn2mgOcc= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= diff --git a/conformance/interop/nightseam/go/main.go b/conformance/interop/nightseam/go/main.go new file mode 100644 index 0000000..22aa291 --- /dev/null +++ b/conformance/interop/nightseam/go/main.go @@ -0,0 +1,246 @@ +// Command nightseam-interop is Nightseam v0.6.0's Go program of the +// interoperability scenario in conformance/interop/README.md. It is test-only +// and pinned to the released v0.6.0 in its own module. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + wire "github.com/Bitspark/bitwire/wire/go" + runtime "github.com/Bitspark/nightseam/runtime/go" +) + +const name = "nightseam-go" +const maxFrameBytes = 4 << 20 + +func main() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: server | client ") + os.Exit(2) + } + var err error + switch os.Args[1] { + case "server": + err = serve(os.Args[2]) + case "client": + err = client(os.Args[2]) + default: + err = fmt.Errorf("unknown role %q", os.Args[1]) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func serve(port string) error { + var mu sync.Mutex + sawCancel := make(chan struct{}) + handler, err := runtime.NewHandler(runtime.ServerOptions{ + Authenticate: func(r *http.Request) (context.Context, error) { return context.Background(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + Options: runtime.Options{MaxFrameBytes: maxFrameBytes, Prepare: func(peer *runtime.Peer) error { + d, err := runtime.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + handle := func(path []string, h runtime.WireHandler) error { + _, err := runtime.HandleWire(d, path, h) + return err + } + return errors.Join( + handle([]string{"echo"}, func(ctx context.Context, raw json.RawMessage) (any, error) { return raw, nil }), + handle([]string{"spaces", "a/b", "echo"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + return map[string]any{"space": "a/b", "params": raw}, nil + }), + handle([]string{"spaces", "รฉ", ""}, func(context.Context, json.RawMessage) (any, error) { return "unicode-empty", nil }), + handle([]string{"fail"}, func(context.Context, json.RawMessage) (any, error) { + return nil, &runtime.PublicError{Code: "bad_request", Message: "refused on purpose", Data: json.RawMessage(`{"n":1}`)} + }), + handle([]string{"wait"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + <-ctx.Done() + mu.Lock() + select { + case <-sawCancel: + default: + close(sawCancel) + } + mu.Unlock() + return nil, ctx.Err() + }), + handle([]string{"cancelled"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + select { + case <-sawCancel: + return true, nil + case <-time.After(2 * time.Second): + return false, nil + } + }), + handle([]string{"meta"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + meta := runtime.MetaFrom(ctx) + if meta == nil { + meta = runtime.Meta{} + } + return meta, nil + }), + handle([]string{"reverse"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var who string + err := runtime.CallWire(ctx, peer.Wire(), []string{"whoami"}, nil, &who) + return who, err + }), + handle([]string{"big"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var args struct{ N int } + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + return strings.Repeat("x", args.N), nil + }), + func() error { + _, err := runtime.RegisterWire(d, []string{"ping"}, runtime.WireHandlers{Event: func(ctx context.Context, raw json.RawMessage) error { + return runtime.EmitWire(context.Background(), peer.Wire(), []string{"pong"}, map[string]json.RawMessage{"echo": raw}) + }}) + return err + }(), + ) + }}, + }) + if err != nil { + return err + } + listener, err := net.Listen("tcp", "127.0.0.1:"+port) + if err != nil { + return err + } + mux := http.NewServeMux() + mux.Handle("/wire", handler) + fmt.Printf("LISTEN ws://%s/wire\n", listener.Addr()) + return http.Serve(listener, mux) +} + +func client(url string) error { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + pongs := make(chan json.RawMessage, 1) + peer, _, err := runtime.Dial(ctx, url, runtime.DialOptions{Options: runtime.Options{MaxFrameBytes: maxFrameBytes, Prepare: func(peer *runtime.Peer) error { + d, err := runtime.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + if _, err := runtime.HandleWire(d, []string{"whoami"}, func(context.Context, json.RawMessage) (any, error) { return name + "-client", nil }); err != nil { + return err + } + _, err = runtime.RegisterWire(d, []string{"pong"}, runtime.WireHandlers{Event: func(ctx context.Context, raw json.RawMessage) error { + pongs <- raw + return nil + }}) + return err + }}}) + if err != nil { + return err + } + defer peer.Close() + root := peer.Wire() + observed := map[string]any{} + call := func(path []string, params any) (json.RawMessage, error) { + var result json.RawMessage + err := runtime.CallWire(ctx, root, path, params, &result) + return result, err + } + code := func(err error) any { + var public *runtime.PublicError + if errors.As(err, &public) { + return map[string]any{"code": public.Code, "message": public.Message, "data": public.Data} + } + if err != nil { + return err.Error() + } + return nil + } + if result, err := call([]string{"echo"}, json.RawMessage(`{"a":[1,"x",null,true],"n":1e3,"u":"๐Ÿ˜€"}`)); err != nil { + observed["echo"] = code(err) + } else { + observed["echo"] = result + } + if result, err := call([]string{"spaces", "a/b", "echo"}, map[string]int{"x": 1}); err == nil { + observed["nested"] = result + } else { + observed["nested"] = code(err) + } + if result, err := call([]string{"spaces", "รฉ", ""}, nil); err == nil { + observed["unicodeEmpty"] = result + } else { + observed["unicodeEmpty"] = code(err) + } + _, err = call([]string{"missing"}, nil) + var public *runtime.PublicError + if errors.As(err, &public) { + observed["missing"] = public.Code + } else { + observed["missing"] = code(err) + } + _, err = call([]string{"fail"}, nil) + observed["fail"] = code(err) + if err := runtime.EmitWire(ctx, root, []string{"ping"}, 7); err != nil { + observed["pong"] = code(err) + } else { + select { + case pong := <-pongs: + observed["pong"] = pong + case <-time.After(5 * time.Second): + observed["pong"] = "no pong" + } + } + withdrawn, withdraw := context.WithTimeout(ctx, 300*time.Millisecond) + err = runtime.CallWire(withdrawn, root, []string{"wait"}, nil, nil) + withdraw() + observed["withdrawn"] = err != nil && errors.Is(err, context.DeadlineExceeded) + if result, err := call([]string{"cancelled"}, nil); err == nil { + observed["serverSawCancel"] = result + } else { + observed["serverSawCancel"] = code(err) + } + var meta json.RawMessage + if err := runtime.CallWire(runtime.WithMeta(ctx, runtime.Meta{"tenant": "t1"}), root, []string{"meta"}, nil, &meta); err == nil { + observed["meta"] = meta + } else { + observed["meta"] = code(err) + } + if result, err := call([]string{"reverse"}, nil); err == nil { + observed["reverse"] = result + } else { + observed["reverse"] = code(err) + } + var big string + if err := runtime.CallWire(ctx, root, []string{"big"}, map[string]int{"n": 3 << 20}, &big); err == nil { + observed["bigLength"] = len(big) + } else { + observed["bigLength"] = code(err) + } + var wg sync.WaitGroup + var matched sync.Map + for i := range 20 { + wg.Add(1) + go func() { + defer wg.Done() + var got map[string]int + if err := runtime.CallWire(ctx, root, []string{"echo"}, map[string]int{"i": i}, &got); err == nil && got["i"] == i { + matched.Store(i, true) + } + }() + } + wg.Wait() + concurrent := 0 + matched.Range(func(any, any) bool { concurrent++; return true }) + observed["concurrent"] = concurrent + var _ wire.Wire = root + return json.NewEncoder(os.Stdout).Encode(observed) +} diff --git a/conformance/interop/nightseam/ts/.npmrc b/conformance/interop/nightseam/ts/.npmrc new file mode 100644 index 0000000..198e520 --- /dev/null +++ b/conformance/interop/nightseam/ts/.npmrc @@ -0,0 +1,3 @@ +registry=https://registry.npmjs.org/ +@bitspark:registry=https://registry.npmjs.org/ +@nightseam:registry=https://registry.npmjs.org/ diff --git a/conformance/interop/nightseam/ts/main.mjs b/conformance/interop/nightseam/ts/main.mjs new file mode 100644 index 0000000..87b9a2b --- /dev/null +++ b/conformance/interop/nightseam/ts/main.mjs @@ -0,0 +1,95 @@ +// Nightseam v0.6.0's TypeScript program of the interoperability scenario in +// conformance/interop/README.md. Test-only, pinned to the released packages. +import {WebSocketServer} from 'ws'; +import {at} from '@nightseam/duplex'; +import {DuplexError, DuplexPeer, callWire, createDispatcher, emitWire, handleWire, onWireEvent} from '@nightseam/runtime'; + +const name = 'nightseam-ts'; +const maxFrameBytes = 4 * 1024 * 1024; +const [role, where] = process.argv.slice(2); + +function serve(port) { + const server = new WebSocketServer({host: '127.0.0.1', port: Number(port), path: '/wire', maxPayload: maxFrameBytes}); + let sawCancel = false; + const waiting = new Set(); + server.on('connection', (socket) => { + const peer = new DuplexPeer({ + role: 'server', + maxFrameBytes, + prepare(peer) { + const d = createDispatcher(peer.wire()); + handleWire(d, ['echo'], (params) => params); + handleWire(d, ['spaces', 'a/b', 'echo'], (params) => ({space: 'a/b', params})); + handleWire(d, ['spaces', 'รฉ', ''], () => 'unicode-empty'); + handleWire(d, ['fail'], () => { + throw new DuplexError('bad_request', 'refused on purpose', {n: 1}); + }); + handleWire(d, ['wait'], (_params, context) => new Promise((_resolve, reject) => { + context.signal.addEventListener('abort', () => { + sawCancel = true; + for (const wake of waiting) wake(true); + reject(new DuplexError('cancelled', 'Request cancelled')); + }, {once: true}); + })); + handleWire(d, ['cancelled'], () => sawCancel || new Promise((resolve) => { + waiting.add(resolve); + setTimeout(() => resolve(false), 2000); + })); + handleWire(d, ['meta'], (_params, context) => context.meta ?? {}); + handleWire(d, ['reverse'], (_params, context) => callWire(peer.wire(), ['whoami'], null, {context})); + handleWire(d, ['big'], (params) => 'x'.repeat(params.n)); + onWireEvent(d, ['ping'], (data) => emitWire(peer.wire(), ['pong'], {echo: data})); + }, + }); + void peer.attach(socket); + }); + server.on('listening', () => console.log(`LISTEN ws://127.0.0.1:${server.address().port}/wire`)); +} + +async function client(url) { + let pong; + const pongs = new Promise((resolve) => { pong = resolve; }); + const peer = new DuplexPeer({ + maxFrameBytes, + prepare(peer) { + const d = createDispatcher(peer.wire()); + handleWire(d, ['whoami'], () => `${name}-client`); + onWireEvent(d, ['pong'], (data) => pong(data)); + }, + }); + await peer.connect(url); + const root = peer.wire(); + const observed = {}; + const outcome = async (promise) => { + try { + return await promise; + } catch (error) { + return {code: error.code, message: error.message, ...(error.data === undefined ? {} : {data: error.data})}; + } + }; + observed.echo = await outcome(callWire(root, ['echo'], {a: [1, 'x', null, true], n: 1e3, u: '๐Ÿ˜€'})); + observed.nested = await outcome(callWire(at(root, ['spaces', 'a/b']), ['echo'], {x: 1})); + observed.unicodeEmpty = await outcome(callWire(root, ['spaces', 'รฉ', ''], null)); + observed.missing = (await outcome(callWire(root, ['missing'], null))).code; + observed.fail = await outcome(callWire(root, ['fail'], null)); + emitWire(root, ['ping'], 7); + observed.pong = await Promise.race([pongs, new Promise((resolve) => setTimeout(() => resolve('no pong'), 5000))]); + const withdrawn = await outcome(callWire(root, ['wait'], null, {timeoutMs: 300})); + observed.withdrawn = typeof withdrawn === 'object' && withdrawn !== null && typeof withdrawn.code === 'string'; + observed.serverSawCancel = await outcome(callWire(root, ['cancelled'], null)); + observed.meta = await outcome(callWire(root, ['meta'], null, {meta: {tenant: 't1'}})); + observed.reverse = await outcome(callWire(root, ['reverse'], null)); + const big = await outcome(callWire(root, ['big'], {n: 3 * 1024 * 1024})); + observed.bigLength = typeof big === 'string' ? big.length : big; + const results = await Promise.all(Array.from({length: 20}, (_, i) => outcome(callWire(root, ['echo'], {i})))); + observed.concurrent = results.filter((result, i) => result?.i === i).length; + console.log(JSON.stringify(observed)); + peer.close(); +} + +if (role === 'server') serve(where); +else if (role === 'client') await client(where); +else { + console.error('usage: server | client '); + process.exit(2); +} diff --git a/conformance/interop/nightseam/ts/package-lock.json b/conformance/interop/nightseam/ts/package-lock.json new file mode 100644 index 0000000..1527157 --- /dev/null +++ b/conformance/interop/nightseam/ts/package-lock.json @@ -0,0 +1,68 @@ +{ + "name": "bitruntime-interop-nightseam", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bitruntime-interop-nightseam", + "license": "Apache-2.0", + "dependencies": { + "@bitspark/bitwire": "0.2.0", + "@nightseam/duplex": "0.6.0", + "@nightseam/runtime": "0.6.0", + "ws": "8.21.3" + } + }, + "node_modules/@bitspark/bitwire": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.2.0.tgz", + "integrity": "sha512-1vKXTpkzzrrStbMOH1EetDngd957DQYIT2MtAhkfgQiym6HY7RHURDGY+9h2fBjAFdGq1Izg71FyumkumKUb5Q==", + "license": "Apache-2.0" + }, + "node_modules/@nightseam/duplex": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@nightseam/duplex/-/duplex-0.6.0.tgz", + "integrity": "sha512-w0IPkX12m20Jk6oDqL8qm81Jb8Kiakwh9uyKyQC+jfzHnXlA5cEKaeoD0t6FZ6Nf7NQU0salC+LhI353ymlPtA==", + "license": "Apache-2.0", + "dependencies": { + "@bitspark/bitwire": "0.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@nightseam/runtime": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@nightseam/runtime/-/runtime-0.6.0.tgz", + "integrity": "sha512-m4S42Wxqd0Z1brJfY6H2O3Gpi1LDLTJC6tFo9eKS4BTNOSFrLCRNsSyAmdOyRCt2eWtOSYUGSob4mOSaYrhNng==", + "license": "Apache-2.0", + "dependencies": { + "@nightseam/duplex": "0.6.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/conformance/interop/nightseam/ts/package.json b/conformance/interop/nightseam/ts/package.json new file mode 100644 index 0000000..0af0fb8 --- /dev/null +++ b/conformance/interop/nightseam/ts/package.json @@ -0,0 +1,13 @@ +{ + "name": "bitruntime-interop-nightseam", + "private": true, + "type": "module", + "description": "Test-only Nightseam v0.6.0 TypeScript program of the interoperability scenario.", + "license": "Apache-2.0", + "dependencies": { + "@bitspark/bitwire": "0.2.0", + "@nightseam/duplex": "0.6.0", + "@nightseam/runtime": "0.6.0", + "ws": "8.21.3" + } +} diff --git a/conformance/interop/recorder/go/main.go b/conformance/interop/recorder/go/main.go new file mode 100644 index 0000000..cf4a0ae --- /dev/null +++ b/conformance/interop/recorder/go/main.go @@ -0,0 +1,132 @@ +// Command interop-recorder speaks bitwire/1 frames by hand to a server of the +// interoperability scenario and prints, one per line, every frame the server +// sent back and how the connection ended. Two servers speak the same revision +// byte for byte when their transcripts are equal. +// +// The recorder sends one request at a time and waits for its answer, so the +// transcript does not depend on handler scheduling. The only bytes it masks are +// trace identifiers the server minted for its own frames, which are random by +// design. +package main + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/coder/websocket" +) + +const trace = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" + +// exchange is one frame the recorder sends and how many server frames it waits +// for before sending the next. +type exchange struct { + send string + expect int +} + +var script = []exchange{ + {`{"version":1,"kind":"request","id":"c:1","method":"4:echo","params":{"z":1,"a":[1e3,"รฉ","รฉ",null]},"traceparent":"` + trace + `","tracestate":"k=v"}`, 1}, + {`{"version":1,"kind":"request","id":"c:2","method":"6:spaces3:a/b4:echo","params":{"x":1}}`, 1}, + {`{"version":1,"kind":"request","id":"c:3","method":"6:spaces2:รฉ0:","params":null}`, 1}, + {`{"version":1,"kind":"request","id":"c:4","method":"7:missing","params":null}`, 1}, + {`{"version":1,"kind":"request","id":"c:5","method":"4:fail","params":{},"traceparent":"` + trace + `"}`, 1}, + {`{"version":1,"kind":"event","event":"4:ping","data":7,"traceparent":"` + trace + `"}`, 1}, + {`{"version":1,"kind":"request","id":"c:6","method":"4:meta","params":null,"meta":{"tenant":"t1","b":"2"}}`, 1}, + {`{"version":1,"kind":"request","id":"c:7","method":"7:reverse","params":null,"traceparent":"` + trace + `"}`, 2}, + {`{"version":1,"kind":"request","id":"c:9","method":"4:wait","params":null}`, 0}, + {`{"version":1,"kind":"cancel","id":"c:9"}`, 1}, + {`{"version":1,"kind":"request","id":"c:10","method":"echo","params":1}`, 1}, + {`{"version":1,"kind":"request","id":"c:11","method":"3:big","params":{"n":5}}`, 1}, + {`{"version":1,"kind":"request","id":"c:12","method":"4:echo","params":"x","meta":{"nightseam.reserved":"no"}}`, 0}, +} + +// minted matches trace members the server generated for frames of its own. +var minted = regexp.MustCompile(`"traceparent":"00-([0-9a-f]{32})-([0-9a-f]{16})-01"`) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: interop-recorder ") + os.Exit(2) + } + if err := record(os.Args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func record(url string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, url, nil) + if err != nil { + return err + } + defer conn.CloseNow() + conn.SetReadLimit(4 << 20) + read := func() (string, error) { + _, data, err := conn.Read(ctx) + if err != nil { + return "", err + } + text := string(data) + // The server's reverse request is answered at once so that the + // exchange that caused it can complete. + if strings.Contains(text, `"kind":"request"`) && strings.Contains(text, `"method":"6:whoami"`) { + id := regexp.MustCompile(`"id":"(s:[0-9]+)"`).FindStringSubmatch(text) + if id == nil { + return "", errors.New("reverse request without an identifier") + } + answer := `{"version":1,"kind":"response","id":"` + id[1] + `","result":"recorder"}` + if err := conn.Write(ctx, websocket.MessageText, []byte(answer)); err != nil { + return "", err + } + } + return mask(text), nil + } + for _, step := range script { + if err := conn.Write(ctx, websocket.MessageText, []byte(step.send)); err != nil { + return err + } + for range step.expect { + text, err := read() + if err != nil { + return fmt.Errorf("after %s: %w", step.send, err) + } + fmt.Println(text) + } + } + // The last frame breaks the protocol; the server must end the connection + // with the profile's code and reason. + _, _, err = conn.Read(ctx) + fmt.Printf("END %d %q\n", websocket.CloseStatus(err), closeReason(err)) + return nil +} + +func mask(text string) string { + return minted.ReplaceAllStringFunc(text, func(member string) string { + match := minted.FindStringSubmatch(member) + if match[1] == "0af7651916cd43dd8448eb211c80319c" { + // A child of the recorder's trace keeps its trace id; only the + // server's own span identifier is random. + if match[2] == "b7ad6b7169203331" { + return member + } + return `"traceparent":"00-0af7651916cd43dd8448eb211c80319c-SPAN-01"` + } + return `"traceparent":"MINTED"` + }) +} + +func closeReason(err error) string { + var closed websocket.CloseError + if errors.As(err, &closed) { + return closed.Reason + } + return "" +} diff --git a/scripts/interop.mjs b/scripts/interop.mjs new file mode 100644 index 0000000..d4dd97a --- /dev/null +++ b/scripts/interop.mjs @@ -0,0 +1,133 @@ +// Runs the interoperability scenario in conformance/interop/README.md: every +// client program against every server program over real WebSockets, and the +// byte-level recorder against every server. Usage: +// node scripts/interop.mjs [program ...] +// Programs default to every one that can be built here. +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {existsSync, mkdtempSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const interop = join(root, 'conformance', 'interop'); +const temp = mkdtempSync(join(tmpdir(), 'bitruntime-interop-')); +const exe = process.platform === 'win32' ? '.exe' : ''; +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function run(command, args, cwd, env = {}) { + const result = spawnSync(command, args, { + cwd, encoding: 'utf8', stdio: 'pipe', env: {...process.env, ...env}, + shell: process.platform === 'win32' && command.endsWith('.cmd'), + }); + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed:\n${result.stdout}\n${result.stderr}`); + return result.stdout; +} + +// Each program is a command line that takes `server ` or `client `. +const builders = { + 'bitruntime-go': () => { + const out = join(temp, `bitruntime-go${exe}`); + run('go', ['build', '-o', out, './conformance/interop/bitruntime/go'], root); + return [out]; + }, + 'nightseam-go': () => { + const out = join(temp, `nightseam-go${exe}`); + // Its own module, pinned to the released v0.6.0 and Bitwire 0.2.0. + run('go', ['build', '-mod=readonly', '-o', out, '.'], join(interop, 'nightseam', 'go'), {GOWORK: 'off'}); + return [out]; + }, + 'bitruntime-ts': () => { + const dir = join(interop, 'bitruntime', 'ts'); + if (!existsSync(join(dir, 'main.mjs'))) return undefined; + if (!existsSync(join(root, 'dist'))) run(npm, ['run', 'build'], root); + return [process.execPath, join(dir, 'main.mjs')]; + }, + 'nightseam-ts': () => { + const dir = join(interop, 'nightseam', 'ts'); + if (!existsSync(join(dir, 'package.json'))) return undefined; + run(npm, ['ci', '--ignore-scripts', '--no-audit'], dir); + return [process.execPath, join(dir, 'main.mjs')]; + }, +}; + +const requested = process.argv.slice(2); +const programs = {}; +for (const [name, build] of Object.entries(builders)) { + if (requested.length && !requested.includes(name)) continue; + const command = build(); + if (command) programs[name] = command; +} +const recorder = join(temp, `recorder${exe}`); +run('go', ['build', '-o', recorder, './conformance/interop/recorder/go'], root); + +let port = 17500 + Math.floor(Math.random() * 400); +function startServer(command) { + const [file, ...args] = command; + const child = spawn(file, [...args, 'server', String(++port)], {stdio: ['ignore', 'pipe', 'pipe']}); + return new Promise((resolve, reject) => { + let out = ''; + child.stdout.on('data', (chunk) => { + out += chunk; + const match = /LISTEN (\S+)/.exec(out); + if (match) resolve({child, url: match[1]}); + }); + child.on('exit', (code) => reject(new Error(`server exited with ${code}: ${out}`))); + setTimeout(() => reject(new Error(`server did not listen: ${out}`)), 20_000); + }); +} +function runClient(command, url) { + const [file, ...args] = command; + return JSON.parse(run(file, [...args, 'client', url], root)); +} + +function expected(client) { + return { + echo: {a: [1, 'x', null, true], n: 1000, u: '๐Ÿ˜€'}, + nested: {space: 'a/b', params: {x: 1}}, + unicodeEmpty: 'unicode-empty', + missing: 'method_not_found', + fail: {code: 'bad_request', message: 'refused on purpose', data: {n: 1}}, + pong: {echo: 7}, + withdrawn: true, + serverSawCancel: true, + meta: {tenant: 't1'}, + reverse: `${client}-client`, + bigLength: 3145728, + concurrent: 20, + }; +} + +const transcripts = {}; +const failures = []; +for (const [server, serverCommand] of Object.entries(programs)) { + const {child, url} = await startServer(serverCommand); + try { + for (const [client, clientCommand] of Object.entries(programs)) { + try { + assert.deepStrictEqual(runClient(clientCommand, url), expected(client)); + console.log(`ok ${client} -> ${server}`); + } catch (error) { + failures.push(`${client} -> ${server}: ${error.message}`); + console.log(`FAIL ${client} -> ${server}`); + } + } + transcripts[server] = run(recorder, [url], root); + } finally { + child.kill(); + } +} +writeFileSync(join(temp, 'transcripts.json'), JSON.stringify(transcripts, null, 2)); +for (const language of ['go', 'ts']) { + const ours = transcripts[`bitruntime-${language}`], theirs = transcripts[`nightseam-${language}`]; + if (ours === undefined || theirs === undefined) continue; + if (ours === theirs) console.log(`ok bitruntime-${language} sends the bytes nightseam-${language} sends`); + else failures.push(`bitruntime-${language} transcript differs from nightseam-${language}:\n--- nightseam\n${theirs}\n--- bitruntime\n${ours}`); +} +console.log(`Transcripts: ${join(temp, 'transcripts.json')}`); +if (failures.length) { + console.error(failures.join('\n\n')); + process.exit(1); +} +console.log(`Interoperability passed for ${Object.keys(programs).join(', ')}.`); From 2d8c45f4d82c6b7abedea61333b05cabbca13176 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:54:18 +0200 Subject: [PATCH 08/39] docs: record the port's provenance and the milestone's changes NOTICE names Nightseam v0.6.0 (5cc9723) as the only source of the ported files, maps each to its bitruntime destination and records the vendored bitwire/1 tables. The changelog gains the missing 0.1.0 heading. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 19 +++++++++++++++++++ NOTICE | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfb637..5c47b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## Unreleased +- Implement the path hand-written adapters use, ported from Nightseam v0.6.0 + (`5cc9723`) with provenance in `NOTICE`: the transport seam, in-memory pipe + and WebSocket (`transports`); `At`, `Mount`, `Forward`, the local pair and the + invocation lifecycle (`core`); the dispatcher and the `Call`, `Emit`, + `Handle` and `Register` helpers (`dispatch`); and the `bitwire/1` protocol + engine with WebSocket connection setup (`engine`). The engine sends, accepts + and refuses exactly what v0.6.0 does and presents the protocol only through + its root Endpoint. +- Fix rather than port Nightseam's recorded defects in this path: a closing + pair answers its queued refusals (nightseam#722), a pair response frees its + call's slot before the caller holds it (nightseam#658), every ended carrier + classifies as one closed error that forwarding answers as `disconnected`, + observe-only close codes are never sent, and forwarding fails only a refused + message. See [the port record](docs/port-from-nightseam.md). +- Hold the engine to Nightseam v0.6.0 peers over real WebSockets in both roles + and both languages, and to its bytes (`scripts/interop.mjs`). + +## 0.1.0 (26 September 2026) + - Implement the first Go/TypeScript structural core: full generic tree construction/selection/decomposition, derived sending, and an explicit addressed facade with exact UTF-8 path conversion. Validate with independent diff --git a/NOTICE b/NOTICE index 12ea446..f1dbe39 100644 --- a/NOTICE +++ b/NOTICE @@ -7,3 +7,38 @@ The structural-core implementations are written against the public Bitwire 0.3.0 contract. Structural test expectations are derived from Bitwire's independent conformance/trees/expected.json under Apache-2.0. No Nightseam runtime source is ported in the v0.1.0 structural core. + +Ported from Nightseam +--------------------- + +The transports, the addressed operators, the local pair, the invocation +lifecycle, the dispatcher and helpers, and the bitwire/1 protocol engine are +ported from Nightseam (https://github.com/Bitspark/nightseam), Copyright 2026 +Bitspark and the Nightseam contributors, licensed under the Apache License, +Version 2.0. The port is from the released v0.6.0 only, commit +5cc9723a24646c40ed1861f892b2b23eb6d785d7; none of its later, unreleased commits +is included. The unmodified sources are recorded in this repository's history +as their own commit, and every modification is a later commit. + + Nightseam v0.6.0 bitruntime + duplex/go/duplex.go, pipe.go transports/go + duplex/go/ws/ws.go transports/websocket/go + duplex/go/wire.go core/go/addressed.go, internal/profile/go + runtime/go/wire_pair.go core/go/pair.go + runtime/go/invocation.go core/go/invocation.go + runtime/go/publication.go, + meta.go, trace.go core/go + runtime/go/wire.go core/go/forward.go, core/go/errors.go, + dispatch/go/dispatch.go, + internal/request/go, engine/go/wire.go + runtime/go/dispatcher.go dispatch/go/dispatcher.go + runtime/go/peer.go engine/go/peer.go, internal/profile/go + runtime/go/http.go engine/websocket/go + runtime/go/json.go, + internal/scalarjson internal/profile/go + duplex/ts/src, runtime/ts/src transports/ts, core/ts, engine/ts, + dispatch/ts + runtime/go and runtime/ts tests the corresponding *_test.go and ts/test + +The files under vectors/bitwire-1/ are byte-identical copies of Nightseam +v0.6.0's conformance/tables/frames.json, serials.json and unicode.json. From cf5cef9bd53b2205bd71742abaf6250707465ce3 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:56:08 +0200 Subject: [PATCH 09/39] core, engine: copy a message before validating it Research 0001 row 28: v0.6.0's Go carriers validated a caller's frame and then copied it, so a concurrent mutation during Send could admit bytes that were never validated. The pair, the root and the call helper's reply now copy first and validate the copy. The byte-level interop transcript is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/pair.go | 25 ++++++++++++++----------- docs/port-from-nightseam.md | 4 ++++ engine/go/wire.go | 12 +++++++----- internal/request/go/request.go | 8 ++++++++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/core/go/pair.go b/core/go/pair.go index 47770ec..ad62603 100644 --- a/core/go/pair.go +++ b/core/go/pair.go @@ -142,6 +142,12 @@ type localCall struct { } func (w *localEnd) Send(path []string, message wire.Message) error { + // Copy, then validate the copy (research 0001, row 28): a caller mutating + // its message during Send cannot admit bytes that were never validated. + path = append([]string(nil), path...) + message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) + message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) + message.Frame.Meta = maps.Clone(message.Frame.Meta) name, err := profile.EncodePath(path) if err != nil { return Unpublished(err) @@ -155,11 +161,7 @@ func (w *localEnd) Send(path []string, message wire.Message) error { if message.Frame.Kind != wire.ProfileEvent && (message.Return == nil || message.Return.Wire == nil) { return Unpublished(errors.New("bitruntime: a request or cancellation requires a return address")) } - // Copy, then keep: what the receiver sees is what was validated. - message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) - message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) - message.Frame.Meta = maps.Clone(message.Frame.Meta) - return w.other.admit(append([]string(nil), path...), message) + return w.other.admit(path, message) } func (w *localEnd) admit(path []string, message wire.Message) error { @@ -512,6 +514,13 @@ func (r *localReturn) Send(path []string, message wire.Message) (err error) { if message.Frame.Kind != wire.ProfileResponse || message.Frame.ID != r.call.message.Frame.ID { return errors.New("bitruntime: invalid wire response") } + // Copy, then validate the copy: the caller receives what was validated. + message.Frame.Result = append(json.RawMessage(nil), message.Frame.Result...) + if message.Frame.Error != nil { + copied := *message.Frame.Error + copied.Data = append(json.RawMessage(nil), copied.Data...) + message.Frame.Error = &copied + } if err := profile.Validate("", message.Frame, r.end.pair.options.MaxFrameBytes); err != nil { // Refused before completion, so the response helper can still send // its bounded internal-error fallback. @@ -538,11 +547,5 @@ func (r *localReturn) Send(path []string, message wire.Message) (err error) { err = errors.New("bitruntime: wire return failed") } }() - message.Frame.Result = append(json.RawMessage(nil), message.Frame.Result...) - if message.Frame.Error != nil { - copied := *message.Frame.Error - copied.Data = append(json.RawMessage(nil), copied.Data...) - message.Frame.Error = &copied - } return WithoutUnpublishedProof(r.call.key.address.Wire.Send(path, message)) } diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index 93640c2..ffd1798 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -73,6 +73,10 @@ None changes a `bitwire/1` frame. - **Receive limits.** A frame over the pipe's limit ends it with 1009 on both sides, as a WebSocket does; a WebSocket past its read limit is ended promptly instead of being left half closed. +- **Copy, then validate (research 0001, row 28).** The pair, the root and the + call helper's reply copy a message's payloads before validating them, so a + caller that mutates its message during `Send` cannot admit bytes that were + never validated. v0.6.0's Go validated first. - **Forwarding (research 0001, row 14).** A message the destination refuses fails only that message; v0.6.0 detached both directions. - **Removed:** the peer's raw method-name API (`Handle`, `HandleEvent`, diff --git a/engine/go/wire.go b/engine/go/wire.go index 48e035e..3809472 100644 --- a/engine/go/wire.go +++ b/engine/go/wire.go @@ -54,6 +54,12 @@ type rootWire struct { } func (w *rootWire) Send(path []string, message wire.Message) error { + // Copy, then validate the copy (research 0001, row 28): a caller mutating + // its message during Send cannot publish bytes that were never validated. + path = append([]string(nil), path...) + message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) + message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) + message.Frame.Meta = maps.Clone(message.Frame.Meta) name, err := profile.EncodePath(path) if err != nil { return core.Unpublished(err) @@ -73,11 +79,7 @@ func (w *rootWire) Send(path []string, message wire.Message) error { if err := profile.Validate(name, message.Frame, w.peer.options.MaxFrameBytes); err != nil { return core.Unpublished(err) } - // Copy, then keep: what is published is what was validated. - message.Frame.Params = append(json.RawMessage(nil), message.Frame.Params...) - message.Frame.Data = append(json.RawMessage(nil), message.Frame.Data...) - message.Frame.Meta = maps.Clone(message.Frame.Meta) - delivered := routedFrame{path: append([]string(nil), path...), message: message} + delivered := routedFrame{path: path, message: message} key := returnKey{message.Return, message.Frame.ID} w.mu.Lock() if err := w.peer.Err(); err != nil { diff --git a/internal/request/go/request.go b/internal/request/go/request.go index ab2e763..e196b0b 100644 --- a/internal/request/go/request.go +++ b/internal/request/go/request.go @@ -64,6 +64,14 @@ func (w *Reply) Send(path []string, message wire.Message) error { if w.dispatch != nil { limit = w.dispatch.MaxFrameBytes } + // Copy, then validate the copy: the waiter keeps nothing the responder + // still owns. + message.Frame.Result = append(json.RawMessage(nil), message.Frame.Result...) + if message.Frame.Error != nil { + copied := *message.Frame.Error + copied.Data = append(json.RawMessage(nil), copied.Data...) + message.Frame.Error = &copied + } if err := profile.Validate("", message.Frame, limit); err != nil { return err } From 2eafd8a475c37f7dc686e39b81d3ce25cb0fe7ab Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:55:37 +0200 Subject: [PATCH 10/39] dispatch: port the v0.6.0 dispatcher and wire tests Ported from Nightseam 5cc9723a (v0.6.0) runtime/go: dispatcher_test.go, dispatcher_ownership_test.go, wire_test.go, wire_event_context_test.go, wire_namespace_test.go, wire_options_test.go, wire_validation_test.go and bitwire_test.go, with names translated per docs/port-from-nightseam.md. What used the removed raw peer API goes through the root Endpoint: a dispatcher over peer.Wire() with Handle/Register, and Call/Emit through peer.Wire(). Raw non-path method names are sent as raw bitwire/1 frames over a pipe and must be answered method_not_found, "ordinary" included, now that no raw handler can take it; a canonical encoding reaches the root namespace. Observer assertions are dropped (HandlerPanic, RequestEnded) or re-instrumented where they checked traffic rather than the hook: FrameSent order and the ConnectionClosed code/reason are read from a recording pipe connection. The pure ForwardWire test stays with core. Co-Authored-By: Claude Opus 5.5 (1M context) --- dispatch/go/bitwire_test.go | 63 ++++ dispatch/go/dispatcher_ownership_test.go | 197 ++++++++++++ dispatch/go/dispatcher_test.go | 78 +++++ dispatch/go/helpers_test.go | 277 ++++++++++++++++ dispatch/go/wire_event_context_test.go | 164 ++++++++++ dispatch/go/wire_namespace_test.go | 352 +++++++++++++++++++++ dispatch/go/wire_options_test.go | 105 ++++++ dispatch/go/wire_test.go | 387 +++++++++++++++++++++++ dispatch/go/wire_validation_test.go | 72 +++++ 9 files changed, 1695 insertions(+) create mode 100644 dispatch/go/bitwire_test.go create mode 100644 dispatch/go/dispatcher_ownership_test.go create mode 100644 dispatch/go/dispatcher_test.go create mode 100644 dispatch/go/helpers_test.go create mode 100644 dispatch/go/wire_event_context_test.go create mode 100644 dispatch/go/wire_namespace_test.go create mode 100644 dispatch/go/wire_options_test.go create mode 100644 dispatch/go/wire_test.go create mode 100644 dispatch/go/wire_validation_test.go diff --git a/dispatch/go/bitwire_test.go b/dispatch/go/bitwire_test.go new file mode 100644 index 0000000..148e3d2 --- /dev/null +++ b/dispatch/go/bitwire_test.go @@ -0,0 +1,63 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "slices" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" + bitwire "github.com/Bitspark/bitwire/wire/go" +) + +// These assignments cross the actual public package boundary. bitruntime +// declares no Message, Receiver or Code of its own: what it presents is +// Bitwire's types. +var _ bitwire.Endpoint = (*dispatch.SelectedEndpoint)(nil) +var _ dispatch.Registry = (*dispatch.Dispatcher)(nil) +var _ bitwire.AddressedWire = (*dispatch.Dispatcher)(nil) +var _ func(*engine.Peer) bitwire.Endpoint = (*engine.Peer).Wire + +func TestPublishedBitwireTypesCarryBitruntimeCalls(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + var client, server bitwire.Endpoint = left, right + defer client.Close(transports.CodeNormal, "done") + detach, err := server.Receive(bitwire.Receiver{ + Message: func(path []string, request bitwire.Message) { + if !slices.Equal(path, []string{"model", "read"}) { + t.Errorf("shared endpoint path = %v", path) + } + if request.Frame.Kind != bitwire.ProfileRequest || request.Return == nil { + t.Error("the shared receiver did not receive a request and return capability") + return + } + err := request.Return.Wire.Send(nil, bitwire.Message{Frame: bitwire.ProfileFrame{ + Version: 1, Kind: bitwire.ProfileResponse, ID: request.Frame.ID, Result: request.Frame.Params, + }}) + if err != nil { + t.Error(err) + } + }, + }) + if err != nil { + t.Fatal(err) + } + defer detach() + selected := core.At(core.Mount(map[string]bitwire.Endpoint{"service": client}), []string{"service", "model"}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var result json.RawMessage + if err := dispatch.Call(ctx, selected, []string{"read"}, json.RawMessage(`{"shared":true}`), &result); err != nil { + t.Fatal(err) + } + if string(result) != `{"shared":true}` { + t.Fatalf("shared contract response: %s", result) + } +} diff --git a/dispatch/go/dispatcher_ownership_test.go b/dispatch/go/dispatcher_ownership_test.go new file mode 100644 index 0000000..b8a6aea --- /dev/null +++ b/dispatch/go/dispatcher_ownership_test.go @@ -0,0 +1,197 @@ +package dispatch_test + +import ( + "errors" + "slices" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// An endpoint has one owning attachment: a second is refused without replacing +// the first, detach is idempotent, and a later attachment is permitted. +func TestAnEndpointHasOneOwningAttachment(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(transports.CodeNormal, "") }) + first := make(chan []string, 4) + detach, err := right.Receive(wire.Receiver{Message: func(path []string, _ wire.Message) { first <- path }}) + if err != nil { + t.Fatal(err) + } + if _, err := right.Receive(wire.Receiver{}); !errors.Is(err, core.ErrReceiverExists) { + t.Fatalf("a second attachment was accepted: %v", err) + } + if err := dispatch.Emit(t.Context(), left, []string{"one"}, nil); err != nil { + t.Fatal(err) + } + if got := <-first; !slices.Equal(got, []string{"one"}) { + t.Fatalf("first attachment saw %v", got) + } + detach() + detach() + second := make(chan []string, 4) + if _, err := right.Receive(wire.Receiver{Message: func(path []string, _ wire.Message) { second <- path }}); err != nil { + t.Fatalf("a later attachment was refused: %v", err) + } + if err := dispatch.Emit(t.Context(), left, []string{"two"}, nil); err != nil { + t.Fatal(err) + } + if got := <-second; !slices.Equal(got, []string{"two"}) { + t.Fatalf("second attachment saw %v", got) + } + select { + case got := <-first: + t.Fatalf("the detached attachment still received %v", got) + default: + } +} + +// A dispatcher refuses a duplicate path and frees it when its registration +// detaches. Exact and prefix are separate spaces: one of each may hold a path. +func TestADispatcherRefusesADuplicatePath(t *testing.T) { + endpoint := newInvocationEndpoint() + d, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + detach, err := d.Register([]string{"a", "b"}, wire.Receiver{Message: func([]string, wire.Message) {}}) + if err != nil { + t.Fatal(err) + } + if _, err := d.Register([]string{"a", "b"}, wire.Receiver{}); !errors.Is(err, core.ErrReceiverExists) { + t.Fatalf("a duplicate exact path was accepted: %v", err) + } + if _, err := d.RegisterPrefix([]string{"a", "b"}, wire.Receiver{}); err != nil { + t.Fatalf("a prefix at an exact path was refused: %v", err) + } + if _, err := d.RegisterPrefix([]string{"a", "b"}, wire.Receiver{}); !errors.Is(err, core.ErrReceiverExists) { + t.Fatalf("a duplicate prefix path was accepted: %v", err) + } + detach() + if _, err := d.Register([]string{"a", "b"}, wire.Receiver{}); err != nil { + t.Fatalf("a detached path was not freed: %v", err) + } +} + +// Overlapping prefixes: the longest match wins, and an exact registration wins +// over every prefix that would also have matched. +func TestOverlappingRoutesSelectTheLongestThenTheExact(t *testing.T) { + endpoint := newInvocationEndpoint() + d, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + reached := make(chan string, 8) + name := func(label string) wire.Receiver { + return wire.Receiver{Message: func([]string, wire.Message) { reached <- label }} + } + for _, route := range []struct { + path []string + label string + }{{nil, "root"}, {[]string{"a"}, "a"}, {[]string{"a", "b"}, "a/b"}} { + if _, err := d.RegisterPrefix(route.path, name(route.label)); err != nil { + t.Fatal(err) + } + } + if _, err := d.Register([]string{"a", "b", "c"}, name("exact a/b/c")); err != nil { + t.Fatal(err) + } + for _, want := range []struct { + path []string + label string + }{ + {[]string{"z"}, "root"}, + {[]string{"a", "z"}, "a"}, + {[]string{"a", "b", "z"}, "a/b"}, + {[]string{"a", "b", "c"}, "exact a/b/c"}, + } { + endpoint.deliver(want.path, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: []byte("null")}}) + if got := <-reached; got != want.label { + t.Fatalf("%v reached %q, want %q", want.path, got, want.label) + } + } +} + +// A nested selection prepends its prefixes outgoing and strips them incoming, +// and every view is a view of the one root attachment. +func TestNestedSelectionPrependsAndStrips(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(transports.CodeNormal, "") }) + d, err := dispatch.NewDispatcher(right) + if err != nil { + t.Fatal(err) + } + inner := d.Select([]string{"a"}).Select([]string{"b"}) + delivered := make(chan []string, 4) + if _, err := inner.Receive(wire.Receiver{Message: func(path []string, _ wire.Message) { delivered <- path }}); err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(t.Context(), left, []string{"a", "b", "read"}, nil); err != nil { + t.Fatal(err) + } + if got := <-delivered; !slices.Equal(got, []string{"read"}) { + t.Fatalf("the nested view was delivered %v", got) + } + // And outgoing: what the view sends arrives at the root's full path. + back := make(chan []string, 4) + if _, err := left.Receive(wire.Receiver{Message: func(path []string, _ wire.Message) { back <- path }}); err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(t.Context(), inner, []string{"reply"}, nil); err != nil { + t.Fatal(err) + } + if got := <-back; !slices.Equal(got, []string{"a", "b", "reply"}) { + t.Fatalf("the nested view sent to %v", got) + } +} + +// Mounting chooses a child by one segment and restores it on delivery; +// closing the mount detaches its own attachments and leaves children usable. +func TestMountRoutesByOneSegmentAndBorrowsItsChildren(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(transports.CodeNormal, "") }) + mount := core.Mount(map[string]wire.Endpoint{"child": right}) + delivered := make(chan []string, 4) + if _, err := mount.Receive(wire.Receiver{Message: func(path []string, _ wire.Message) { delivered <- path }}); err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(t.Context(), left, []string{"read"}, nil); err != nil { + t.Fatal(err) + } + if got := <-delivered; !slices.Equal(got, []string{"child", "read"}) { + t.Fatalf("the mount delivered %v", got) + } + // A mount has no destination at the empty path, and an unknown child has + // no route at all. + if err := mount.Send(nil, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: []byte("null")}}); !errors.Is(err, core.ErrMissingPath) { + t.Fatalf("the mount had a destination at []: %v", err) + } + if err := mount.Send([]string{"absent"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: []byte("null")}}); !errors.Is(err, core.ErrMissingPath) { + t.Fatalf("an unknown child had a route: %v", err) + } + if err := mount.Close(transports.CodeNormal, ""); err != nil { + t.Fatal(err) + } + after := make(chan []string, 4) + if _, err := right.Receive(wire.Receiver{Message: func(path []string, _ wire.Message) { after <- path }}); err != nil { + t.Fatalf("closing the mount closed its borrowed child: %v", err) + } + if err := dispatch.Emit(t.Context(), left, []string{"again"}, nil); err != nil { + t.Fatal(err) + } + if got := <-after; !slices.Equal(got, []string{"again"}) { + t.Fatalf("the borrowed child delivered %v", got) + } +} diff --git a/dispatch/go/dispatcher_test.go b/dispatch/go/dispatcher_test.go new file mode 100644 index 0000000..af6b1b9 --- /dev/null +++ b/dispatch/go/dispatcher_test.go @@ -0,0 +1,78 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func TestDispatcherSharesOneAttachmentAndPreservesBorrowedEndpoint(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(transports.CodeNormal, "") }) + shared, err := dispatch.NewDispatcher(right) + if err != nil { + t.Fatal(err) + } + if _, err := right.Receive(wire.Receiver{}); err == nil { + t.Fatal("second owning attachment accepted") + } + for _, name := range []string{"a", "b"} { + view := shared.Select([]string{name}) + binding, err := dispatch.NewDispatcher(view) + if err != nil { + t.Fatal(err) + } + if _, err := dispatch.Handle(binding, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return name, nil }); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"a", "b"} { + var got string + if err := dispatch.Call(context.Background(), left, []string{name, "read"}, nil, &got); err != nil || got != name { + t.Fatalf("%s: %q, %v", name, got, err) + } + } + if err := shared.Close(transports.CodeNormal, ""); err != nil { + t.Fatal(err) + } + rebound, err := dispatch.NewDispatcher(right) + if err != nil { + t.Fatalf("borrowed endpoint was closed: %v", err) + } + if _, err := dispatch.Handle(rebound, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return "rebound", nil }); err != nil { + t.Fatal(err) + } + var got string + if err := dispatch.Call(context.Background(), left, []string{"read"}, nil, &got); err != nil || got != "rebound" { + t.Fatalf("rebound: %q, %v", got, err) + } +} + +func TestDispatcherClosesAnExplicitlyOwnedEndpoint(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = left.Close(transports.CodeNormal, "") }) + owner, err := dispatch.NewDispatcher(right, dispatch.DispatcherOptions{OwnEndpoint: true}) + if err != nil { + t.Fatal(err) + } + if err := owner.Close(transports.CodeProtocolError, "wire event rejected"); err != nil { + t.Fatal(err) + } + if _, err := right.Receive(wire.Receiver{}); err == nil { + t.Fatal("owned endpoint stayed open") + } + if err := owner.Close(transports.CodeNormal, "again"); err != nil { + t.Fatal(err) + } +} diff --git a/dispatch/go/helpers_test.go b/dispatch/go/helpers_test.go new file mode 100644 index 0000000..7615f21 --- /dev/null +++ b/dispatch/go/helpers_test.go @@ -0,0 +1,277 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + enginews "github.com/Bitspark/bitruntime/engine/websocket/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case value := <-channel: + return value + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for peer activity") + var zero T + return zero + } +} + +// newPair connects a client peer to a server peer over a WebSocket and returns +// them in that order. The server takes the first options. +func newPair(t *testing.T, serverOptions, clientOptions engine.Options) (*engine.Peer, *engine.Peer) { + t.Helper() + connected := make(chan *engine.Peer, 1) + handler, err := enginews.NewHandler(enginews.ServerOptions{ + Options: serverOptions, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *engine.Peer) { connected <- peer }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + client, _, err := enginews.Dial(ctx, server.URL, enginews.DialOptions{Options: clientOptions}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + remote := receive(t, connected) + t.Cleanup(func() { _ = remote.Close() }) + return client, remote +} + +// sentFrames records what a connection's writer hands its transport, in that +// order, as "kind name". It stands where Nightseam v0.6.0's tests read the +// observer's FrameSent events, which bitruntime does not have. A frame is +// recorded before it is sent, so whatever the far side has received is +// already recorded. +type sentFrames struct { + transports.Conn + mu sync.Mutex + sent []string +} + +func (c *sentFrames) Send(ctx context.Context, frame transports.Frame) error { + var f struct { + Kind string `json:"kind"` + Method string `json:"method"` + Event string `json:"event"` + } + _ = json.Unmarshal(frame.Data, &f) + c.record(f.Kind + " " + f.Method + f.Event) + return c.Conn.Send(ctx, frame) +} + +// Close records the close this side decided on as "close code reason". +func (c *sentFrames) Close(ctx context.Context, code transports.Code, reason string) error { + c.record(fmt.Sprintf("close %d %s", code, reason)) + return c.Conn.Close(ctx, code, reason) +} + +func (c *sentFrames) record(line string) { + c.mu.Lock() + c.sent = append(c.sent, line) + c.mu.Unlock() +} + +// await returns the recorded lines of the given kinds once there are count of +// them, or those there are after five seconds. +func (c *sentFrames) await(t *testing.T, count int, kinds ...string) []string { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + var got []string + c.mu.Lock() + for _, line := range c.sent { + for _, kind := range kinds { + if strings.HasPrefix(line, kind+" ") { + got = append(got, line) + } + } + } + c.mu.Unlock() + if len(got) >= count || time.Now().After(deadline) { + return got + } + time.Sleep(time.Millisecond) + } +} + +// recordedPair connects a client peer to a server peer over a pipe, recording +// what each sends, and returns the client, the server and their records. +func recordedPair(t *testing.T, serverOptions, clientOptions engine.Options) (*engine.Peer, *engine.Peer, *sentFrames, *sentFrames) { + t.Helper() + near, far := transports.Pipe(1 << 20) + sent, served := &sentFrames{Conn: near}, &sentFrames{Conn: far} + server, err := engine.NewPeer(context.Background(), served, engine.ServerRole, serverOptions) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + client, err := engine.NewPeer(context.Background(), sent, engine.ClientRole, clientOptions) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return client, server, sent, served +} + +// rawClient is a server peer reached over a connection the test speaks +// bitwire/1 on byte for byte, as the removed raw method-name API once let a +// test choose a frame's method name. +type rawClient struct { + conn transports.Conn + next int +} + +func newRawClient(t *testing.T, serverOptions engine.Options) (*engine.Peer, *rawClient) { + t.Helper() + near, far := transports.Pipe(1 << 20) + server, err := engine.NewPeer(context.Background(), far, engine.ServerRole, serverOptions) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close(); _ = near.Abort() }) + return server, &rawClient{conn: near} +} + +// call sends one request under the given method name and returns its response. +func (c *rawClient) call(t *testing.T, method string) wire.ProfileFrame { + t.Helper() + c.next++ + id := fmt.Sprintf("c:%d", c.next) + request, err := json.Marshal(map[string]any{"version": 1, "kind": "request", "id": id, "method": method, "params": map[string]any{}}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.conn.Send(ctx, transports.Frame{Kind: transports.Text, Data: request}); err != nil { + t.Fatal(err) + } + received, err := c.conn.Receive(ctx) + if err != nil { + t.Fatal(err) + } + var response wire.ProfileFrame + if err := json.Unmarshal(received.Data, &response); err != nil { + t.Fatal(err) + } + if response.Kind != wire.ProfileResponse || response.ID != id { + t.Fatalf("raw %q answered %+v", method, response) + } + return response +} + +func testBinding(t *testing.T, endpoint wire.Endpoint) *dispatch.Dispatcher { + t.Helper() + binding, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = binding.Close(transports.CodeNormal, "done") }) + return binding +} + +type wireReplySink struct{ replies chan wire.ProfileFrame } + +// A return capability refuses what it does not implement, as every addressed +// receiver in this profile does; this one carries outcomes and nothing else. +func (s *wireReplySink) Send(path []string, message wire.Message) error { + if len(path) != 0 { + return errors.New("this return capability carries outcomes only") + } + s.replies <- message.Frame + return nil +} + +func wantUnpublished(t *testing.T, err error, want bool) { + t.Helper() + var unpublished *core.UnpublishedError + if got := errors.As(err, &unpublished); got != want || err == nil { + t.Fatalf("publication proof: %v, want unpublished=%v", err, want) + } +} + +// invocationEndpoint is an endpoint written against the public contract alone: +// Receive holds one attachment and deliver hands it a message exactly as it +// arrived. It is the part of Nightseam v0.6.0's invocation fixture the +// dispatcher tests use. +type invocationEndpoint struct { + mu sync.Mutex + receiver *wire.Receiver + closed bool +} + +func newInvocationEndpoint() *invocationEndpoint { return &invocationEndpoint{} } + +// Send loops back into this endpoint's own attachment, asynchronously. +func (e *invocationEndpoint) Send(path []string, message wire.Message) error { + go e.deliver(path, message) + return nil +} + +func (e *invocationEndpoint) Receive(receiver wire.Receiver) (func(), error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil, transports.ErrClosed + } + if e.receiver != nil { + return nil, core.ErrReceiverExists + } + held := receiver + e.receiver = &held + return func() { + e.mu.Lock() + if e.receiver == &held { + e.receiver = nil + } + e.mu.Unlock() + }, nil +} + +func (e *invocationEndpoint) Close(code wire.Code, reason string) error { + e.mu.Lock() + if e.closed { + e.mu.Unlock() + return nil + } + e.closed = true + receiver := e.receiver + e.receiver = nil + e.mu.Unlock() + if receiver != nil && receiver.Closed != nil { + receiver.Closed(code, reason) + } + return nil +} + +func (e *invocationEndpoint) deliver(path []string, message wire.Message) { + e.mu.Lock() + receiver := e.receiver + e.mu.Unlock() + if receiver != nil && receiver.Message != nil { + receiver.Message(path, message) + } +} diff --git a/dispatch/go/wire_event_context_test.go b/dispatch/go/wire_event_context_test.go new file mode 100644 index 0000000..04a282c --- /dev/null +++ b/dispatch/go/wire_event_context_test.go @@ -0,0 +1,164 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +type eventVerifiedKey struct{} +type eventVerifiedPropagator struct{ verified any } + +func (p eventVerifiedPropagator) Extract(ctx context.Context, trace core.Trace) context.Context { + return context.WithValue(core.DefaultPropagator.Extract(ctx, trace), eventVerifiedKey{}, p.verified) +} +func (eventVerifiedPropagator) Inject(ctx context.Context) core.Trace { + return core.DefaultPropagator.Inject(ctx) +} + +func TestWireEventContextSurvivesPhysicalForwardLocalPairAndMount(t *testing.T) { + verified := &struct{ source string }{"trusted context"} + client, server := newPair(t, engine.Options{Propagator: eventVerifiedPropagator{verified}}, engine.Options{}) + access, binding, err := core.NewPair(core.PairOptions{MaxPendingRequests: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = access.Close(transports.CodeNormal, "done") }) + stop, err := core.Forward(server.Wire(), testBinding(t, core.Mount(map[string]wire.Endpoint{"local": access})).Select([]string{"local"})) + if err != nil { + t.Fatal(err) + } + defer stop() + mountBinding := testBinding(t, core.Mount(map[string]wire.Endpoint{"model": binding})) + model := mountBinding.Select([]string{"model", "events"}) + observed := make(chan context.Context, 1) + effects := 0 + modelBinding := testBinding(t, model) + _, err = dispatch.Register(modelBinding, []string{"change"}, dispatch.Handlers{Event: func(ctx context.Context, _ json.RawMessage) error { + observed <- ctx + if ctx.Value(eventVerifiedKey{}) != verified { + return errors.New("unverified event") + } + effects++ + return nil + }}) + if err != nil { + t.Fatal(err) + } + meta := map[string]string{"tenant": "explicit", "verified": "cannot manufacture context"} + if err := dispatch.Emit(core.WithMeta(context.Background(), meta), client.Wire(), []string{"events", "change"}, nil); err != nil { + t.Fatal(err) + } + ctx := receive(t, observed) + if ctx.Value(eventVerifiedKey{}) != verified || !reflect.DeepEqual(core.MetaFrom(ctx), meta) { + t.Fatalf("lost received context: verified=%v meta=%v", ctx.Value(eventVerifiedKey{}), core.MetaFrom(ctx)) + } + _, _ = dispatch.Handle(mountBinding, []string{"model", "barrier"}, func(context.Context, json.RawMessage) (any, error) { return effects, nil }) + var count int + if err := dispatch.Call(context.Background(), access, []string{"barrier"}, nil, &count); err != nil || count != 1 { + t.Fatalf("effect count=%d, err=%v", count, err) + } + + // New outgoing events carry only explicitly supplied metadata. The private + // received context ends at the next physical boundary. + returned := make(chan context.Context, 2) + clientBinding := testBinding(t, client.Wire()) + _, _ = dispatch.Register(clientBinding, []string{"outgoing"}, dispatch.Handlers{Event: func(ctx context.Context, _ json.RawMessage) error { returned <- ctx; return nil }}) + if err := dispatch.Emit(ctx, server.Wire(), []string{"outgoing"}, nil); err != nil { + t.Fatal(err) + } + fresh := receive(t, returned) + if fresh.Value(eventVerifiedKey{}) != nil || len(core.MetaFrom(fresh)) != 0 { + t.Fatalf("ambient context crossed transport: %v %v", fresh.Value(eventVerifiedKey{}), core.MetaFrom(fresh)) + } + if err := dispatch.Emit(core.WithMeta(ctx, core.MetaFrom(ctx)), server.Wire(), []string{"outgoing"}, nil); err != nil { + t.Fatal(err) + } + if got := core.MetaFrom(receive(t, returned)); !reflect.DeepEqual(got, meta) { + t.Fatalf("explicit outgoing metadata=%v", got) + } + _ = server.Close() + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("event context lost its physical connection lifetime") + } +} + +func TestWireEventMetadataCannotSupplyVerifiedContext(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + access, binding, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = access.Close(transports.CodeNormal, "done") }) + stop, err := core.Forward(server.Wire(), access) + if err != nil { + t.Fatal(err) + } + defer stop() + denied := make(chan bool, 1) + modelBinding := testBinding(t, binding) + _, _ = dispatch.Register(modelBinding, []string{"guard"}, dispatch.Handlers{Event: func(ctx context.Context, _ json.RawMessage) error { + if ctx.Value(eventVerifiedKey{}) == nil { + denied <- true + return errors.New("event denied") + } + denied <- false + return nil + }}) + if err := dispatch.Emit(core.WithMeta(context.Background(), map[string]string{"verified": "yes"}), client.Wire(), []string{"guard"}, nil); err != nil { + t.Fatal(err) + } + if !receive(t, denied) { + t.Fatal("metadata bypassed the event guard") + } +} + +func TestWireEventContextStopsAtAnotherPhysicalBoundary(t *testing.T) { + verified := &struct{}{} + client, incoming := newPair(t, engine.Options{Propagator: eventVerifiedPropagator{verified}}, engine.Options{}) + outgoing, server := newPair(t, engine.Options{}, engine.Options{}) + stop, err := core.Forward(incoming.Wire(), outgoing.Wire()) + if err != nil { + t.Fatal(err) + } + defer stop() + observed := make(chan context.Context, 1) + serverBinding := testBinding(t, server.Wire()) + _, _ = dispatch.Register(serverBinding, []string{"event"}, dispatch.Handlers{Event: func(ctx context.Context, _ json.RawMessage) error { observed <- ctx; return nil }}) + if err := dispatch.Emit(core.WithMeta(context.Background(), map[string]string{"explicit": "yes"}), client.Wire(), []string{"event"}, nil); err != nil { + t.Fatal(err) + } + ctx := receive(t, observed) + if ctx.Value(eventVerifiedKey{}) != nil || core.MetaFrom(ctx)["explicit"] != "yes" { + t.Fatalf("physical boundary: private=%v meta=%v", ctx.Value(eventVerifiedKey{}), core.MetaFrom(ctx)) + } +} + +func TestWireLocalEventsUseSuppliedPropagator(t *testing.T) { + verified := &struct{}{} + a, b, err := core.NewPair(core.PairOptions{Propagator: eventVerifiedPropagator{verified}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = a.Close(transports.CodeNormal, "done") }) + observed := make(chan context.Context, 1) + bBinding := testBinding(t, b) + _, _ = dispatch.Register(bBinding, []string{"event"}, dispatch.Handlers{Event: func(ctx context.Context, _ json.RawMessage) error { observed <- ctx; return nil }}) + if err := dispatch.Emit(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + if receive(t, observed).Value(eventVerifiedKey{}) != verified { + t.Fatal("local event bypassed configured propagator") + } +} diff --git a/dispatch/go/wire_namespace_test.go b/dispatch/go/wire_namespace_test.go new file mode 100644 index 0000000..f9c7054 --- /dev/null +++ b/dispatch/go/wire_namespace_test.go @@ -0,0 +1,352 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +type namespaceObservation struct { + Picked string + Path []string +} + +func namespaceReceiver(picked string, events chan namespaceObservation) wire.Receiver { + return wire.Receiver{Message: func(path []string, message wire.Message) { + observation := namespaceObservation{picked, append([]string{}, path...)} + if message.Frame.Kind == wire.ProfileEvent { + events <- observation + return + } + if message.Frame.Kind != wire.ProfileRequest { + return + } + data, _ := json.Marshal(observation) + _ = message.Return.Wire.Send(nil, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: message.Frame.ID, Result: data}}) + }} +} + +func TestDispatcherUsesExactThenLongestSegmentPrefix(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + events := make(chan namespaceObservation, 20) + root := testBinding(t, server.Wire()) + for _, route := range []struct { + path []string + name string + }{{nil, "root"}, {[]string{"a"}, "a"}, {[]string{"a", "b"}, "ab"}} { + if _, err := root.RegisterPrefix(route.path, namespaceReceiver(route.name, events)); err != nil { + t.Fatal(err) + } + if _, err := root.RegisterPrefix(route.path, namespaceReceiver("duplicate", events)); !errors.Is(err, core.ErrReceiverExists) { + t.Fatalf("duplicate namespace = %v", err) + } + } + detach, err := root.Register([]string{"a"}, namespaceReceiver("exact", events)) + if err != nil { + t.Fatal(err) + } + for _, route := range []struct { + path []string + picked string + }{ + {[]string{"a"}, "exact"}, {[]string{"a", "b", "leaf"}, "ab"}, {[]string{"a", "bc"}, "a"}, {[]string{"a.b", "leaf"}, "root"}, {[]string{"", "๐Ÿ˜€"}, "root"}, + } { + var got namespaceObservation + if err := dispatch.Call(context.Background(), client.Wire(), route.path, nil, &got); err != nil { + t.Fatal(err) + } + want := namespaceObservation{route.picked, route.path} + if !reflect.DeepEqual(got, want) { + t.Fatalf("request = %+v; want %+v", got, want) + } + if err := dispatch.Emit(context.Background(), client.Wire(), route.path, nil); err != nil { + t.Fatal(err) + } + if got := receive(t, events); !reflect.DeepEqual(got, want) { + t.Fatalf("event = %+v; want %+v", got, want) + } + } + detach() + detach() + var got namespaceObservation + if err := dispatch.Call(context.Background(), client.Wire(), []string{"a"}, nil, &got); err != nil || got.Picked != "a" { + t.Fatalf("exact detach did not expose namespace: %+v %v", got, err) + } +} + +// A frame's method name reaches the root's receiver only as the canonical +// encoding of a path. Nightseam v0.6.0 also registered a raw handler here and +// answered its non-path name; bitruntime removed the raw method-name API, so a +// name that encodes no path is answered method_not_found even beside a root +// namespace that would take every path. +func TestRootNamespaceTakesOnlyCanonicalPathEncodings(t *testing.T) { + events := make(chan namespaceObservation, 1) + _, raw := newRawClient(t, engine.Options{Prepare: func(p *engine.Peer) error { + root, err := dispatch.NewDispatcher(p.Wire()) + if err != nil { + return err + } + _, err = root.RegisterPrefix(nil, namespaceReceiver("root", events)) + return err + }}) + response := raw.call(t, "1:a3:b.c") + var got namespaceObservation + if response.Error != nil || json.Unmarshal(response.Result, &got) != nil || !reflect.DeepEqual(got, namespaceObservation{"root", []string{"a", "b.c"}}) { + t.Fatalf("canonical name reached %+v: %+v", got, response) + } + for _, name := range []string{"ordinary", "unknown.raw", "01:a", "1:a.invalid"} { + if response := raw.call(t, name); response.Error == nil || response.Error.Code != "method_not_found" { + t.Fatalf("namespace captured noncanonical name %q: %+v", name, response) + } + } +} + +func TestDispatcherCancellationKeepsOriginalRegistration(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + started := make(chan *wire.ReturnAddress, 1) + cancelled := make(chan *wire.ReturnAddress, 1) + detach, err := serverBinding.RegisterPrefix([]string{"worker"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + if m.Frame.Kind == wire.ProfileRequest { + started <- m.Return + } + if m.Frame.Kind == wire.ProfileCancel { + cancelled <- m.Return + } + }}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { result <- dispatch.Call(ctx, client.Wire(), []string{"worker", "dynamic"}, nil, nil) }() + original := receive(t, started) + detach() + replacement := make(chan wire.ProfileKind, 4) + if _, err := serverBinding.RegisterPrefix([]string{"worker"}, wire.Receiver{Message: func(_ []string, m wire.Message) { replacement <- m.Frame.Kind }}); err != nil { + t.Fatal(err) + } + cancel() + if err := receive(t, result); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if got := receive(t, cancelled); got != original { + t.Fatal("cancellation changed the original return capability") + } + select { + case got := <-replacement: + t.Fatalf("replacement received old request's %s", got) + default: + } +} + +func TestStructuredWireBridgePreservesTraceVerbatim(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + frames := make(chan wire.ProfileFrame, 8) + _, err := serverBinding.Register([]string{"trace"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + frames <- m.Frame + if m.Frame.Kind == wire.ProfileRequest { + _ = m.Return.Wire.Send(nil, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: m.Frame.ID, Result: json.RawMessage(`null`)}}) + } + }}) + if err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan wire.ProfileFrame, 2)} + address := &wire.ReturnAddress{Wire: sink} + for _, trace := range []core.Trace{{Parent: "00-11111111111111111111111111111111-2222222222222222-01", State: "vendor=value"}, {}} { + for _, kind := range []wire.ProfileKind{wire.ProfileRequest, wire.ProfileEvent} { + frame := wire.ProfileFrame{Version: 1, Kind: kind, Traceparent: trace.Parent, Tracestate: trace.State} + if kind == wire.ProfileRequest { + frame.ID = "c:1" + frame.Params = json.RawMessage(`null`) + } else { + frame.Data = json.RawMessage(`null`) + } + if err := client.Wire().Send([]string{"trace"}, wire.Message{Frame: frame, Return: address}); err != nil { + t.Fatal(err) + } + got := receive(t, frames) + if got.Traceparent != trace.Parent || got.Tracestate != trace.State { + t.Fatalf("structured %s trace changed: %+v; want %+v", kind, got, trace) + } + if kind == wire.ProfileRequest { + reply := receive(t, sink.replies) + if reply.Traceparent != trace.Parent || reply.Tracestate != trace.State { + t.Fatalf("response trace changed: %+v", reply) + } + } + } + } +} + +func TestRegisterGroupsRequestAndEventAtOnePath(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + events := make(chan string, 2) + detach, err := dispatch.Register(serverBinding, []string{"shared"}, dispatch.Handlers{ + Request: func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }, + Event: func(_ context.Context, value json.RawMessage) error { + var text string + _ = json.Unmarshal(value, &text) + events <- text + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + var got string + if err := dispatch.Call(context.Background(), client.Wire(), []string{"shared"}, "response", &got); err != nil || got != "response" { + t.Fatalf("grouped request = %q %v", got, err) + } + if err := dispatch.Emit(context.Background(), client.Wire(), []string{"shared"}, "event"); err != nil { + t.Fatal(err) + } + if got := receive(t, events); got != "event" { + t.Fatal(got) + } + detach() + detach() + _, err = dispatch.Register(serverBinding, []string{"shared"}, dispatch.Handlers{Event: func(context.Context, json.RawMessage) error { return nil }}) + if err != nil { + t.Fatal(err) + } + err = dispatch.Call(context.Background(), client.Wire(), []string{"shared"}, nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "method_not_found" { + t.Fatalf("event-only request did not refuse: %v", err) + } + if _, err := dispatch.Register(serverBinding, []string{"empty"}, dispatch.Handlers{}); err == nil { + t.Fatal("registered empty handlers") + } +} + +// This fixture created and owns the carrier, so its registry explicitly owns +// fatal-handler closure. Ordinary borrowed dispatchers only detach themselves. +type ownedEventRegistry struct { + *dispatch.Dispatcher + endpoint wire.Endpoint +} + +func (r ownedEventRegistry) Close(code wire.Code, reason string) error { + _ = r.Dispatcher.Close(code, reason) + return r.endpoint.Close(code, reason) +} + +// Nightseam v0.6.0 read the carrier's ending from the server's observer; here +// the server's connection records the close it was asked for, and the client +// reads that close on its side of the pipe. +func TestRegisterEventFailuresEndOnlyTheirCarrierWithSanitizedReason(t *testing.T) { + for _, panics := range []bool{false, true} { + t.Run(map[bool]string{false: "error", true: "panic"}[panics], func(t *testing.T) { + client, server, _, served := recordedPair(t, engine.Options{}, engine.Options{}) + serverBinding := ownedEventRegistry{testBinding(t, server.Wire()), server.Wire()} + _, err := dispatch.Register(serverBinding, []string{"rejected"}, dispatch.Handlers{Event: func(context.Context, json.RawMessage) error { + if panics { + panic("private failure") + } + return errors.New("private failure") + }}) + if err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(context.Background(), client.Wire(), []string{"rejected"}, nil); err != nil { + t.Fatal(err) + } + receive(t, server.Done()) + if got := served.await(t, 1, "close"); !reflect.DeepEqual(got, []string{"close 1002 wire event rejected"}) { + t.Fatalf("event ending = %v", got) + } + receive(t, client.Done()) + var closed *transports.CloseError + if !errors.As(client.Err(), &closed) || closed.Code != 1002 || closed.Reason != "wire event rejected" { + t.Fatalf("event ending = %v", client.Err()) + } + }) + } +} + +func TestForwardCarriesUnknownPathsAndReverseCallsAcrossPeers(t *testing.T) { + client, middleIn := newPair(t, engine.Options{}, engine.Options{}) + middleOut, server := newPair(t, engine.Options{}, engine.Options{}) + inbound := testBinding(t, core.Mount(map[string]wire.Endpoint{"in": testBinding(t, middleIn.Wire()).Select([]string{"gateway"})})).Select([]string{"in"}) + outbound := testBinding(t, core.Mount(map[string]wire.Endpoint{"out": testBinding(t, middleOut.Wire()).Select([]string{"service"})})).Select([]string{"out"}) + caller := testBinding(t, testBinding(t, client.Wire()).Select([]string{"gateway"})) + implementation := testBinding(t, testBinding(t, server.Wire()).Select([]string{"service"})) + detach, err := core.Forward(inbound, outbound) + if err != nil { + t.Fatal(err) + } + defer detach() + _, err = dispatch.Handle(caller, []string{"reverse", "dynamic"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) + if err != nil { + t.Fatal(err) + } + _, err = dispatch.Handle(implementation, []string{"arbitrary", "nested", "call"}, func(ctx context.Context, value json.RawMessage) (any, error) { + var result string + if err := dispatch.Call(ctx, implementation, []string{"reverse", "dynamic"}, value, &result); err != nil { + return nil, err + } + return result + " returned", nil + }) + if err != nil { + t.Fatal(err) + } + var result string + if err := dispatch.Call(context.Background(), caller, []string{"arbitrary", "nested", "call"}, "callback", &result); err != nil || result != "callback returned" { + t.Fatalf("forwarded reverse call = %q %v", result, err) + } + events := make(chan string, 2) + _, err = dispatch.Register(implementation, []string{"arbitrary", "nested", "event"}, dispatch.Handlers{Event: func(_ context.Context, value json.RawMessage) error { + var text string + _ = json.Unmarshal(value, &text) + events <- text + return nil + }}) + if err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(context.Background(), caller, []string{"arbitrary", "nested", "event"}, "observed"); err != nil { + t.Fatal(err) + } + if got := receive(t, events); got != "observed" { + t.Fatal(got) + } + started, ended := make(chan struct{}), make(chan struct{}) + _, err = dispatch.Handle(implementation, []string{"arbitrary", "cancel"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(ended) + return nil, ctx.Err() + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- dispatch.Call(ctx, caller, []string{"arbitrary", "cancel"}, nil, nil) }() + receive(t, started) + detach() + cancel() + if err := receive(t, finished); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + receive(t, ended) + for _, peer := range []*engine.Peer{client, middleIn, middleOut, server} { + if err := peer.Err(); err != nil { + t.Fatalf("forward detach closed peer: %v", err) + } + } +} diff --git a/dispatch/go/wire_options_test.go b/dispatch/go/wire_options_test.go new file mode 100644 index 0000000..3c2f04e --- /dev/null +++ b/dispatch/go/wire_options_test.go @@ -0,0 +1,105 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// Nightseam v0.6.0 called the raw method name "8:deadline" here; that is the +// wire name of the path ["deadline"], which the removed raw API no longer +// spells for a caller. +func TestWireForwardingRetainsTheAdmittedDeadline(t *testing.T) { + client, server := newPair(t, engine.Options{RequestTimeout: time.Minute}, engine.Options{RequestTimeout: time.Minute}) + serverBinding := testBinding(t, server.Wire()) + _, err := dispatch.Handle(serverBinding, []string{"deadline"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + deadline, ok := ctx.Deadline() + if !ok { + return int64(0), nil + } + return int64(time.Until(deadline) / time.Millisecond), nil + }) + if err != nil { + t.Fatal(err) + } + var remaining int64 + if err := dispatch.Call(context.Background(), client.Wire(), []string{"deadline"}, nil, &remaining); err != nil { + t.Fatal(err) + } + if remaining < 50000 { + t.Fatalf("forwarding shortened the admitted one-minute deadline to %dms", remaining) + } +} + +type configuredWirePropagator struct { + remaining time.Duration + trace core.Trace +} + +func (p *configuredWirePropagator) Extract(ctx context.Context, _ core.Trace) context.Context { + return ctx +} +func (p *configuredWirePropagator) Inject(ctx context.Context) core.Trace { + if deadline, ok := ctx.Deadline(); ok { + p.remaining = time.Until(deadline) + } + return p.trace +} + +type optionWire struct { + send func([]string, wire.Message) error +} + +func (w optionWire) Send(path []string, m wire.Message) error { return w.send(path, m) } + +func TestWireUsesTheConfiguredOutgoingPropagatorAndTimeout(t *testing.T) { + want := core.Trace{Parent: "00-11111111111111111111111111111111-2222222222222222-01", State: "vendor=kept"} + propagator := &configuredWirePropagator{trace: want} + received := make(chan wire.ProfileFrame, 2) + access := optionWire{send: func(_ []string, m wire.Message) error { + received <- m.Frame + if m.Frame.Kind == wire.ProfileRequest { + return m.Return.Wire.Send(nil, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: m.Frame.ID, Result: json.RawMessage(`null`)}}) + } + return nil + }} + if err := dispatch.Call(context.Background(), access, []string{"call"}, nil, nil, dispatch.CallOptions{Propagator: propagator, Timeout: time.Minute}); err != nil { + t.Fatal(err) + } + if propagator.remaining < 50*time.Second { + t.Fatalf("configured minute shortened to %v", propagator.remaining) + } + if err := dispatch.Emit(context.Background(), access, []string{"event"}, nil, dispatch.EmitOptions{Propagator: propagator}); err != nil { + t.Fatal(err) + } + for range 2 { + frame := <-received + if frame.Traceparent != want.Parent || frame.Tracestate != want.State { + t.Fatalf("configured trace lost: %+v", frame) + } + } +} + +func TestWireConfiguredTimeoutCancelsTheSameReturnCapability(t *testing.T) { + messages := make(chan wire.Message, 2) + access := optionWire{send: func(_ []string, m wire.Message) error { messages <- m; return nil }} + started := time.Now() + err := dispatch.Call(context.Background(), access, []string{"wait"}, nil, nil, dispatch.CallOptions{Timeout: 20 * time.Millisecond}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout result: %v", err) + } + if time.Since(started) > time.Second { + t.Fatal("configured timeout was ignored") + } + request, cancel := <-messages, <-messages + if cancel.Frame.Kind != wire.ProfileCancel || cancel.Return != request.Return || cancel.Frame.ID != request.Frame.ID || cancel.Frame.Traceparent != request.Frame.Traceparent { + t.Fatal("timeout changed request correlation") + } +} diff --git a/dispatch/go/wire_test.go b/dispatch/go/wire_test.go new file mode 100644 index 0000000..407af7b --- /dev/null +++ b/dispatch/go/wire_test.go @@ -0,0 +1,387 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func TestReceiverDeadlineWinsImmediateHandlerRefusal(t *testing.T) { + previous := runtime.GOMAXPROCS(4) + t.Cleanup(func() { runtime.GOMAXPROCS(previous) }) + client, server := newPair(t, engine.Options{RequestTimeout: 100 * time.Microsecond}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + if _, err := dispatch.Handle(serverBinding, []string{"deadline"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + <-ctx.Done() + return nil, &core.PublicError{Code: "declined", Message: "Body completed at deadline"} + }); err != nil { + t.Fatal(err) + } + // Exercise the real timer/body race: completion can run before the + // asynchronous deadline callback, but the deadline is already selected. + for i := range 1024 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := dispatch.Call(ctx, client.Wire(), []string{"deadline"}, nil, nil) + cancel() + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatalf("call %d deadline response = %v", i, err) + } + } +} + +func TestWireCancellationRetainsExecutingHandlerBudget(t *testing.T) { + for _, mode := range []string{"cancel", "caller-deadline", "receiver-deadline", "public-refusal"} { + for _, route := range []string{"wire", "forwarded", "peer"} { + t.Run(mode+"/"+route, func(t *testing.T) { + options := engine.Options{MaxConcurrentHandlers: 1} + if mode == "receiver-deadline" { + options.RequestTimeout = 100 * time.Millisecond + } + entered, cancelled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var released sync.Once + t.Cleanup(func() { released.Do(func() { close(release) }) }) + var calls atomic.Int32 + handler := func(ctx context.Context, _ json.RawMessage) (any, error) { + if calls.Add(1) == 1 { + close(entered) + <-ctx.Done() + close(cancelled) + <-release + if mode == "public-refusal" { + return nil, &core.PublicError{Code: "cancelled", Message: "Application refusal"} + } + } + return "finished", nil + } + if route == "peer" { + // The peer's raw Handle is removed; its root carries the + // request, with the handler installed before the peer reads. + options.Prepare = func(p *engine.Peer) error { + binding, err := dispatch.NewDispatcher(p.Wire()) + if err != nil { + return err + } + _, err = dispatch.Handle(binding, []string{"hold"}, handler) + return err + } + } + client, server := newPair(t, options, engine.Options{}) + call := func(ctx context.Context) error { return dispatch.Call(ctx, client.Wire(), []string{"hold"}, nil, nil) } + if route != "peer" { + model := server.Wire() + if route == "forwarded" { + left, right, pairErr := core.NewPair(core.PairOptions{}) + if pairErr != nil { + t.Fatal(pairErr) + } + t.Cleanup(func() { _ = left.Close(transports.CodeNormal, "") }) + stop, forwardErr := core.Forward(server.Wire(), left) + if forwardErr != nil { + t.Fatal(forwardErr) + } + t.Cleanup(stop) + model = right + } + modelBinding := testBinding(t, model) + if _, err := dispatch.Handle(modelBinding, []string{"hold"}, handler); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + if mode == "caller-deadline" { + cancel() + ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond) + } + defer cancel() + result := make(chan error, 1) + go func() { result <- call(ctx) }() + receive(t, entered) + if mode == "cancel" || mode == "public-refusal" { + cancel() + } + if mode == "receiver-deadline" { + var public *core.PublicError + if err := receive(t, result); !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatalf("receiver deadline = %v", err) + } + } else { + if err := receive(t, result); !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("caller cancellation = %v", err) + } + } + receive(t, cancelled) + // Repeated round trips keep exercising admission while the first body + // is explicitly held, independent of when its wrapper is scheduled. + for range 10 { + err := call(context.Background()) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("request admitted while cancelled body still runs: calls=%d, err=%v", calls.Load(), err) + } + } + if calls.Load() != 1 { + t.Fatalf("executed %d bodies at a limit of one", calls.Load()) + } + released.Do(func() { close(release) }) + ready, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + for { + err := call(ready) + if err == nil { + break + } + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatal(err) + } + } + }) + } + } +} + +type wireVerifiedKey struct{} +type wireContextPropagator struct{ core.Propagator } + +func (p wireContextPropagator) Extract(ctx context.Context, trace core.Trace) context.Context { + return context.WithValue(p.Propagator.Extract(ctx, trace), wireVerifiedKey{}, true) +} + +func TestWireKeepsReceivedContextWithoutForwardingApplicationMetadata(t *testing.T) { + client, server := newPair(t, engine.Options{Propagator: wireContextPropagator{core.DefaultPropagator}}, engine.Options{}) + clientBinding := testBinding(t, client.Wire()) + _, err := dispatch.Handle(clientBinding, []string{"reverse"}, func(ctx context.Context, _ json.RawMessage) (any, error) { return core.MetaFrom(ctx), nil }) + if err != nil { + t.Fatal(err) + } + serverBinding := testBinding(t, server.Wire()) + _, err = dispatch.Handle(serverBinding, []string{"check"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + var reverse core.Meta + if err := dispatch.Call(ctx, server.Wire(), []string{"reverse"}, nil, &reverse); err != nil { + return nil, err + } + return map[string]any{"verified": ctx.Value(wireVerifiedKey{}) == true, "received": core.MetaFrom(ctx), "reverse": reverse}, nil + }) + if err != nil { + t.Fatal(err) + } + var got struct { + Verified bool + Received core.Meta + Reverse core.Meta + } + if err := dispatch.Call(core.WithMeta(context.Background(), core.Meta{"credential": "one-call"}), client.Wire(), []string{"check"}, nil, &got); err != nil { + t.Fatal(err) + } + if !got.Verified || got.Received["credential"] != "one-call" || len(got.Reverse) != 0 { + t.Fatalf("wire request context = %+v", got) + } +} + +// Nightseam v0.6.0 also counted the peer's HandlerPanic observations here; +// bitruntime has no observer. +func TestWireHandlerPanicStaysPrivate(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + _, err := dispatch.Handle(serverBinding, []string{"panic"}, func(context.Context, json.RawMessage) (any, error) { panic("private failure") }) + if err != nil { + t.Fatal(err) + } + err = dispatch.Call(context.Background(), client.Wire(), []string{"panic"}, nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "internal" || public.Message != "Internal error" { + t.Fatalf("panic response = %v", err) + } + if server.Err() != nil { + t.Fatalf("panic ended carrier: %v", server.Err()) + } +} + +func TestWirePreservesRequestAndEventAdmissionOrder(t *testing.T) { + client, server, sent, _ := recordedPair(t, engine.Options{}, engine.Options{}) + sink := &wireReplySink{replies: make(chan wire.ProfileFrame, 40)} + address := &wire.ReturnAddress{Wire: sink} + root := client.Wire() + var want []string + serverBinding := testBinding(t, server.Wire()) + for i := range 40 { + path := []string{"ordered", fmt.Sprint(i)} + _, err := dispatch.Handle(serverBinding, path, func(context.Context, json.RawMessage) (any, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + name, _ := profile.EncodePath(path) + for _, kind := range []wire.ProfileKind{wire.ProfileRequest, wire.ProfileEvent} { + frame := wire.ProfileFrame{Version: 1, Kind: kind} + if kind == wire.ProfileRequest { + frame.ID = fmt.Sprintf("c:%d", i+1) + frame.Params = json.RawMessage("{}") + } else { + frame.Data = json.RawMessage("null") + } + if err := root.Send(path, wire.Message{Frame: frame, Return: address}); err != nil { + t.Fatal(err) + } + want = append(want, string(kind)+" "+name) + } + } + for range 40 { + receive(t, sink.replies) + } + if got := sent.await(t, len(want), "request", "event"); !reflect.DeepEqual(got, want) { + t.Fatalf("per-wire send order changed:\n got %v\nwant %v", got, want) + } +} + +func TestWirePreservesCancellationBeforeTheFollowingEvent(t *testing.T) { + client, server, sent, _ := recordedPair(t, engine.Options{}, engine.Options{}) + started := make(chan struct{}) + eventReceived := make(chan struct{}) + serverBinding := testBinding(t, server.Wire()) + if _, err := dispatch.Register(serverBinding, []string{"after"}, dispatch.Handlers{Event: func(context.Context, json.RawMessage) error { close(eventReceived); return nil }}); err != nil { + t.Fatal(err) + } + _, err := dispatch.Handle(serverBinding, []string{"wait"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }) + if err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan wire.ProfileFrame, 1)} + address := &wire.ReturnAddress{Wire: sink} + root := client.Wire() + if err := root.Send([]string{"wait"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "c:1", Params: json.RawMessage("{}")}, Return: address}); err != nil { + t.Fatal(err) + } + receive(t, started) + if err := root.Send([]string{"wait"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: "c:1"}, Return: address}); err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(context.Background(), root, []string{"after"}, nil); err != nil { + t.Fatal(err) + } + receive(t, sink.replies) + receive(t, eventReceived) + var kinds []string + for _, line := range sent.await(t, 3, "request", "cancel", "event", "response") { + kind, _, _ := strings.Cut(line, " ") + kinds = append(kinds, kind) + } + if !reflect.DeepEqual(kinds, []string{"request", "cancel", "event"}) { + t.Fatalf("send order = %v", kinds) + } +} + +func TestMountedWireKeepsIndependentOriginsAndCancellation(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + started := make(chan string, 2) + finished := make(chan string, 2) + allow := make(chan struct{}) + defer close(allow) + serverBinding := testBinding(t, server.Wire()) + _, err := dispatch.Handle(serverBinding, []string{"worker", "run"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var name string + if err := json.Unmarshal(raw, &name); err != nil { + return nil, err + } + started <- name + select { + case <-ctx.Done(): + finished <- name + return nil, ctx.Err() + case <-allow: + return name, nil + } + }) + if err != nil { + t.Fatal(err) + } + // Both views select the same existing carrier. Each Call has its own + // local return address, so cancelling one cannot cancel the other's id. + access := core.At(core.Mount(map[string]wire.Endpoint{"service": client.Wire()}), []string{"service", "worker"}) + first, cancelFirst := context.WithCancel(context.Background()) + second, cancelSecond := context.WithCancel(context.Background()) + defer cancelFirst() + defer cancelSecond() + var calls sync.WaitGroup + results := make(chan error, 2) + for _, call := range []struct { + ctx context.Context + name string + }{{first, "first"}, {second, "second"}} { + calls.Add(1) + go func() { + defer calls.Done() + results <- dispatch.Call(call.ctx, access, []string{"run"}, call.name, nil) + }() + } + receive(t, started) + receive(t, started) + cancelFirst() + if err := receive(t, results); !errors.Is(err, context.Canceled) { + t.Fatalf("first cancellation = %v", err) + } + if name := receive(t, finished); name != "first" { + t.Fatalf("cancelled %q, want first", name) + } + var echoed string + _, err = dispatch.Handle(serverBinding, []string{"worker", "echo"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) + if err != nil { + t.Fatal(err) + } + if err := dispatch.Call(context.Background(), access, []string{"echo"}, "still open", &echoed); err != nil || echoed != "still open" { + t.Fatalf("sibling call after cancellation = %q, %v", echoed, err) + } + cancelSecond() + if err := receive(t, results); !errors.Is(err, context.Canceled) { + t.Fatalf("second cancellation = %v", err) + } + if name := receive(t, finished); name != "second" { + t.Fatalf("second cancellation reached %q", name) + } + calls.Wait() +} + +func TestWirePathPreservesOpaqueSegmentsOverTheExistingEnvelope(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + for _, route := range []struct { + path []string + want string + }{{[]string{"a.b"}, "one segment"}, {[]string{"a", "b"}, "two segments"}, {[]string{""}, "empty segment"}} { + _, err := dispatch.Handle(serverBinding, route.path, func(context.Context, json.RawMessage) (any, error) { return route.want, nil }) + if err != nil { + t.Fatal(err) + } + } + for _, route := range []struct { + path []string + want string + }{{[]string{"a.b"}, "one segment"}, {[]string{"a", "b"}, "two segments"}, {[]string{""}, "empty segment"}} { + var got string + if err := dispatch.Call(context.Background(), client.Wire(), route.path, nil, &got); err != nil || got != route.want { + t.Fatalf("path %q = %q, %v; want %q", route.path, got, err, route.want) + } + if err := dispatch.Call(context.Background(), core.At(client.Wire(), route.path), nil, nil, &got); err != nil || got != route.want { + t.Fatalf("selected leaf %q = %q, %v; want %q", route.path, got, err, route.want) + } + } +} diff --git a/dispatch/go/wire_validation_test.go b/dispatch/go/wire_validation_test.go new file mode 100644 index 0000000..efa1f29 --- /dev/null +++ b/dispatch/go/wire_validation_test.go @@ -0,0 +1,72 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func TestWireRefusesMalformedFramesBeforeDispatch(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{MaxFrameBytes: 256}) + invoked := 0 + serverBinding := testBinding(t, server.Wire()) + _, err := dispatch.Handle(serverBinding, []string{"echo"}, func(_ context.Context, raw json.RawMessage) (any, error) { + invoked++ + return raw, nil + }) + if err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan wire.ProfileFrame, 30)} + address := &wire.ReturnAddress{Wire: sink} + valid := wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "c:1", Params: json.RawMessage("{}")} + for name, change := range map[string]func(*wire.ProfileFrame){ + "version": func(f *wire.ProfileFrame) { f.Version = 0 }, + "id": func(f *wire.ProfileFrame) { f.ID = "unscoped" }, + "zero id": func(f *wire.ProfileFrame) { f.ID = "c:0" }, + "missing params": func(f *wire.ProfileFrame) { f.Params = nil }, + "foreign member": func(f *wire.ProfileFrame) { f.Result = json.RawMessage("null") }, + "trace": func(f *wire.ProfileFrame) { f.Traceparent = "invalid" }, + "reserved metadata": func(f *wire.ProfileFrame) { f.Meta = map[string]string{"nightseam.future": "value"} }, + "oversize": func(f *wire.ProfileFrame) { f.Params, _ = json.Marshal(strings.Repeat("x", 300)) }, + } { + t.Run(name, func(t *testing.T) { + frame := valid + change(&frame) + if err := client.Wire().Send([]string{"echo"}, wire.Message{Frame: frame, Return: address}); err == nil { + t.Errorf("malformed frame admitted") + } + }) + } + var result string + if err := dispatch.Call(context.Background(), client.Wire(), []string{"echo"}, "still usable", &result); err != nil || result != "still usable" { + t.Fatalf("healthy call = %q, %v", result, err) + } + if invoked != 1 { + t.Fatalf("handler invoked %d times, want only the valid call", invoked) + } +} + +func TestWireSanitizesMalformedPublicErrors(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + serverBinding := testBinding(t, server.Wire()) + for _, value := range []*core.PublicError{nil, {Code: ""}, {Code: "bad", Message: ""}} { + detach, err := dispatch.Handle(serverBinding, []string{"fail"}, func(context.Context, json.RawMessage) (any, error) { return nil, value }) + if err != nil { + t.Fatal(err) + } + err = dispatch.Call(context.Background(), client.Wire(), []string{"fail"}, nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public == nil || public.Code != "internal" { + t.Errorf("malformed public error = %v", err) + } + detach() + } +} From 53abde5442f4521b8d39691562b838cfd23f3d55 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:55:37 +0200 Subject: [PATCH 11/39] dispatch: port the v0.6.0 full-carrier send tests Ported from Nightseam 5cc9723a (v0.6.0) runtime/go/wire_send_test.go. The raw Emit that filled the destination's queue is the root's Emit; since the root admits asynchronously, a propagator marks when the root has taken each frame so the queue is where v0.6.0's synchronous Emit left it. The observer's Backpressure count is dropped, and so is the raw, paced call that was withdrawn before admission: a root call is never paced. The earlier admitted call now reaches its caller through the root as a public error, so it is asserted disconnected (R26) rather than ErrBackpressure. It is answered cancelled instead: the root's waiter runs under a context derived from the peer's own, which the peer's end cancels before releasing it. That is an engine/go defect, as in v0.6.0's root; the test skips on exactly that answer and fails on any other. Co-Authored-By: Claude Opus 5.5 (1M context) --- dispatch/go/wire_send_test.go | 236 ++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 dispatch/go/wire_send_test.go diff --git a/dispatch/go/wire_send_test.go b/dispatch/go/wire_send_test.go new file mode 100644 index 0000000..8b53a32 --- /dev/null +++ b/dispatch/go/wire_send_test.go @@ -0,0 +1,236 @@ +package dispatch_test + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + enginews "github.com/Bitspark/bitruntime/engine/websocket/go" +) + +// Delaying actual TCP writes makes producer/transport imbalance reproducible +// without depending on the OS socket-buffer size or a stopped remote reader. +type delayedWriteControl struct { + enabled atomic.Bool + delay time.Duration + started chan struct{} + gate <-chan struct{} + once sync.Once +} + +type delayedWriteListener struct { + net.Listener + control *delayedWriteControl +} + +func (l delayedWriteListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return &delayedWriteConn{Conn: conn, control: l.control}, nil +} + +type delayedWriteConn struct { + net.Conn + control *delayedWriteControl +} + +func (c *delayedWriteConn) Write(data []byte) (int, error) { + if c.control.enabled.Load() { + c.control.once.Do(func() { close(c.control.started) }) + if c.control.gate != nil { + <-c.control.gate + } + time.Sleep(c.control.delay) + } + return c.Conn.Write(data) +} + +// delayedWritePair connects a client to a server whose socket writes the +// control holds, and returns them in that order. +func delayedWritePair(t *testing.T, control *delayedWriteControl, serverOptions, clientOptions engine.Options) (*engine.Peer, *engine.Peer) { + t.Helper() + connected := make(chan *engine.Peer, 1) + handler, err := enginews.NewHandler(enginews.ServerOptions{ + Options: serverOptions, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *engine.Peer) { + // The HTTP upgrade has been flushed before introducing write delay. + control.enabled.Store(true) + connected <- peer + }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(handler) + server.Listener = delayedWriteListener{Listener: server.Listener, control: control} + server.Start() + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + client, _, err := enginews.Dial(ctx, server.URL, enginews.DialOptions{Options: clientOptions}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + remote := receive(t, connected) + t.Cleanup(func() { _ = remote.Close() }) + return client, remote +} + +// rootTaken tells when the peer's root has taken a frame off its own queue: +// the root extracts each request's and event's trace before it hands the frame +// to the peer. Nightseam v0.6.0's raw Emit queued its frame synchronously; +// through the root, a test that needs the root's queue empty again waits here. +type rootTaken struct { + core.Propagator + taken chan struct{} +} + +func newRootTaken() rootTaken { + return rootTaken{Propagator: core.DefaultPropagator, taken: make(chan struct{}, 64)} +} + +func (p rootTaken) Extract(ctx context.Context, trace core.Trace) context.Context { + select { + case p.taken <- struct{}{}: + default: + } + return p.Propagator.Extract(ctx, trace) +} + +// A full destination's consumer remains held while the caller finishes. The +// write deadline is deliberately much longer than the caller's bound: waiting +// for that deadline would make fan-out run at its slowest destination's pace. +// +// Nightseam v0.6.0 also counted the observer's terminal Backpressure events; +// bitruntime has no observer. Its raw Emit is the root's Emit here. +func TestWireSendEndsAFullCarrierWithoutWaitingForItsConsumer(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + _, destination := delayedWritePair(t, control, engine.Options{ + QueueCapacity: 1, + WriteTimeout: 5 * time.Second, + }, engine.Options{}) + if err := dispatch.Emit(context.Background(), destination.Wire(), []string{"first"}, 1); err != nil { + t.Fatal(err) + } + receive(t, control.started) + if err := dispatch.Emit(context.Background(), destination.Wire(), []string{"second"}, 2); err != nil { + t.Fatal(err) + } + finished := make(chan error, 1) + go func() { finished <- dispatch.Emit(context.Background(), destination.Wire(), []string{"overflow"}, 3) }() + select { + case err := <-finished: + if err != nil && !errors.Is(err, core.ErrBackpressure) { + t.Fatalf("wire admission = %v, want admission or backpressure", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("send waited for a destination whose consumer is still held") + } + select { + case <-destination.Done(): + case <-time.After(500 * time.Millisecond): + t.Fatal("wire dispatch waited for a destination whose consumer is still held") + } + if !errors.Is(destination.Err(), core.ErrBackpressure) { + t.Fatalf("full carrier remained open: %v", destination.Err()) + } +} + +func TestWireRequestEndsAFullCarrierWithoutWaitingForItsConsumer(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + root := newRootTaken() + _, destination := delayedWritePair(t, control, engine.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second, Propagator: root}, engine.Options{}) + if err := dispatch.Emit(context.Background(), destination.Wire(), []string{"first"}, 1); err != nil { + t.Fatal(err) + } + receive(t, root.taken) + receive(t, control.started) + if err := dispatch.Emit(context.Background(), destination.Wire(), []string{"second"}, 2); err != nil { + t.Fatal(err) + } + receive(t, root.taken) + finished := make(chan error, 1) + go func() { + finished <- dispatch.Call(context.Background(), destination.Wire(), []string{"overflow"}, nil, nil) + }() + select { + case err := <-finished: + if err == nil { + t.Fatal("wire request into a full carrier succeeded") + } + // Root wire admission already succeeded. A downstream carrier refusal + // is an ordinary result, not proof of pre-admission non-publication. + wantUnpublished(t, err, false) + case <-time.After(500 * time.Millisecond): + t.Fatal("wire request waited for a destination whose consumer is still held") + } + if !errors.Is(destination.Err(), core.ErrBackpressure) { + t.Fatalf("full carrier remained open: %v", destination.Err()) + } +} + +// Nightseam v0.6.0 also made a raw, paced call wait for the full queue here +// and withdrew it, proving that call unpublished; bitruntime removed the raw +// call, and a call through the root is never paced. What remains is the +// earlier admitted call: the immediate dispatch that ends the carrier cannot +// make it prove that it never left. Through the root it is answered +// disconnected, since an overflowed carrier is a closed one. +func TestOutputCancellationProofBelongsOnlyToTheUnadmittedCall(t *testing.T) { + release := make(chan struct{}) + defer close(release) + control := &delayedWriteControl{started: make(chan struct{}), gate: release} + root := newRootTaken() + _, destination := delayedWritePair(t, control, engine.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second, Propagator: root}, engine.Options{}) + accepted := make(chan error, 1) + go func() { + accepted <- dispatch.Call(context.Background(), destination.Wire(), []string{"accepted"}, nil, nil) + }() + // The earlier call is already in its carrier's write. Hold it there, then + // occupy the only queue slot. + receive(t, root.taken) + receive(t, control.started) + if err := dispatch.Emit(context.Background(), destination.Wire(), []string{"queued"}, nil); err != nil { + t.Fatal(err) + } + receive(t, root.taken) + // A subsequent immediate Wire dispatch ends this full carrier. Its failure + // cannot make the earlier admitted call prove that it never left. + if err := dispatch.Emit(context.Background(), destination.Wire(), []string{"overflow"}, nil); err != nil { + t.Fatal(err) + } + earlier := receive(t, accepted) + wantUnpublished(t, earlier, false) + receive(t, destination.Done()) + if !errors.Is(destination.Err(), core.ErrBackpressure) { + t.Fatalf("full carrier ended with %v", destination.Err()) + } + var public *core.PublicError + if errors.As(earlier, &public) && public.Code == "cancelled" { + // The root hands an admitted request to the peer under a context + // derived from the peer's own, which the peer's end cancels before it + // releases the waiter; the waiter can then read its end as a + // withdrawal. Nightseam v0.6.0's root did the same. + t.Skip("engine/go: a root call the peer's end settles is answered cancelled, not disconnected") + } + if !errors.As(earlier, &public) || public.Code != "disconnected" { + t.Fatalf("earlier call = %v", earlier) + } +} From bd80e2c513efa2904a0fa66d697995833f7a04ae Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:55:37 +0200 Subject: [PATCH 12/39] engine: port the v0.6.0 root cancellation-reservation tests Ported from Nightseam 5cc9723a (v0.6.0) runtime/go/wire_cancel_reservation_test.go. The tests build a peer whose root alone runs, read its outgoing queue and complete its pending calls directly, so they are internal to engine/go rather than beside the dispatcher. The root is started explicitly, as v0.6.0's Wire() started it lazily; pending results are request.Result. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/go/root_reservation_test.go | 220 +++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 engine/go/root_reservation_test.go diff --git a/engine/go/root_reservation_test.go b/engine/go/root_reservation_test.go new file mode 100644 index 0000000..bad38a8 --- /dev/null +++ b/engine/go/root_reservation_test.go @@ -0,0 +1,220 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/request/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// Ported from Nightseam v0.6.0 runtime/go/wire_cancel_reservation_test.go. +// These tests read the peer's outgoing queue and complete its pending calls +// directly, so they live beside the root they exercise. + +type wireDrainGate struct { + started chan struct{} + release chan struct{} + once sync.Once +} + +func (g *wireDrainGate) open() { g.once.Do(func() { close(g.release) }) } + +type wireReservationPropagator struct { + core.Propagator + gates chan *wireDrainGate +} + +func (p *wireReservationPropagator) Extract(ctx context.Context, trace core.Trace) context.Context { + select { + case gate := <-p.gates: + close(gate.started) + select { + case <-gate.release: + case <-ctx.Done(): + } + default: + } + return p.Propagator.Extract(ctx, trace) +} +func (p *wireReservationPropagator) pause(t *testing.T) *wireDrainGate { + gate := &wireDrainGate{started: make(chan struct{}), release: make(chan struct{})} + p.gates <- gate + t.Cleanup(gate.open) + return gate +} + +type wireReservationSink struct { + replies chan wire.ProfileFrame + onReply func(wire.ProfileFrame) +} + +func (s *wireReservationSink) Send(_ []string, message wire.Message) error { + if s.onReply != nil { + s.onReply(message.Frame) + } + s.replies <- message.Frame + return nil +} + +func wireReservationAwait[T any](t *testing.T, values <-chan T) T { + t.Helper() + select { + case value := <-values: + return value + case <-time.After(3 * time.Second): + t.Fatal("wire reservation barrier did not arrive") + var zero T + return zero + } +} + +// Only the root dispatcher runs. Its actual peer admission writes into an +// independently drained carrier queue, so a full root queue is tested without +// a second, unrelated transport saturation masking the root's result. +func wireReservationPeer(t *testing.T) (*Peer, wire.AddressedWire, *wireReservationPropagator) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + near, far := transports.Pipe(1 << 20) + propagator := &wireReservationPropagator{Propagator: core.DefaultPropagator, gates: make(chan *wireDrainGate, 1)} + options, err := (Options{QueueCapacity: 1, MaxPendingRequests: 1, Propagator: propagator}).Normalized() + if err != nil { + t.Fatal(err) + } + peer := &Peer{ctx: ctx, cancel: cancel, conn: near, options: options, prefix: "c:", remotePrefix: "s:", done: make(chan struct{}), pending: map[string]chan request.Result{}, incoming: map[string]context.CancelFunc{}, outputs: make(chan queuedFrame, 16)} + peer.root = &rootWire{peer: peer, wake: make(chan struct{}, 1), incoming: map[returnKey]*routedCall{}} + go peer.root.run() + t.Cleanup(func() { _ = peer.Close(); _ = far.Abort() }) + return peer, peer.Wire(), propagator +} + +func wireReservationMessage(kind wire.ProfileKind, id string, address *wire.ReturnAddress) wire.Message { + frame := wire.ProfileFrame{Version: 1, Kind: kind, ID: id} + if kind == wire.ProfileRequest { + frame.Params = json.RawMessage(`{}`) + } else if kind == wire.ProfileEvent { + frame.Data = json.RawMessage(`null`) + } + return wire.Message{Frame: frame, Return: address} +} +func wireReservationSend(t *testing.T, root wire.AddressedWire, kind wire.ProfileKind, id string, address *wire.ReturnAddress) { + t.Helper() + if err := root.Send([]string{"operation"}, wireReservationMessage(kind, id, address)); err != nil { + t.Fatal(err) + } +} + +func TestRootWireCancellationHasReservedAdmissionAndKeepsFIFO(t *testing.T) { + peer, root, propagator := wireReservationPeer(t) + sink := &wireReservationSink{replies: make(chan wire.ProfileFrame, 8)} + address := &wire.ReturnAddress{Wire: sink} + gate := propagator.pause(t) + wireReservationSend(t, root, wire.ProfileRequest, "c:1", address) + wireReservationAwait(t, gate.started) + wireReservationSend(t, root, wire.ProfileEvent, "", nil) // The sole data slot is occupied. + wireReservationSend(t, root, wire.ProfileCancel, "c:1", address) + for range 4 { + wireReservationSend(t, root, wire.ProfileCancel, "c:1", address) + wireReservationSend(t, root, wire.ProfileCancel, "c:999", address) + } + if err := peer.Err(); err != nil { + t.Fatalf("cancellation ended its carrier: %v", err) + } + gate.open() + var kinds []string + for range 3 { + kinds = append(kinds, wireReservationAwait(t, peer.outputs).frame.Kind) + } + if !reflect.DeepEqual(kinds, []string{"request", "event", "cancel"}) { + t.Fatalf("physical admission order = %v", kinds) + } + if reply := wireReservationAwait(t, sink.replies); reply.Error == nil || reply.Error.Code != "cancelled" { + t.Fatalf("cancel reply = %+v", reply) + } + // A fence after duplicate, unknown and settled controls proves none escaped. + wireReservationSend(t, root, wire.ProfileCancel, "c:1", address) + wireReservationSend(t, root, wire.ProfileEvent, "", nil) + if got := wireReservationAwait(t, peer.outputs).frame.Kind; got != "event" { + t.Fatalf("stale control reached the carrier: %s", got) + } + if err := peer.Err(); err != nil { + t.Fatalf("carrier ended: %v", err) + } +} + +func TestRootWireCompletedCallRetainsItsQueuedCancellationBudget(t *testing.T) { + peer, root, propagator := wireReservationPeer(t) + first := &wireReservationSink{replies: make(chan wire.ProfileFrame, 4)} + address := &wire.ReturnAddress{Wire: first} + second := &wireReservationSink{replies: make(chan wire.ProfileFrame, 4)} + secondAddress := &wire.ReturnAddress{Wire: second} + reentrant := make(chan error, 1) + first.onReply = func(wire.ProfileFrame) { + reentrant <- root.Send([]string{"operation"}, wireReservationMessage(wire.ProfileRequest, "c:2", secondAddress)) + } + wireReservationSend(t, root, wire.ProfileRequest, "c:1", address) + requested := wireReservationAwait(t, peer.outputs).frame + gate := propagator.pause(t) + wireReservationSend(t, root, wire.ProfileEvent, "", nil) + wireReservationAwait(t, gate.started) + wireReservationSend(t, root, wire.ProfileCancel, "c:1", address) + // Complete before the root can drain the control. The response callback + // attempts to spend the same one-request budget while its stale control is + // still queued; it must receive busy, not replenish control capacity. + peer.mu.Lock() + reply := peer.pending[requested.ID] + peer.mu.Unlock() + reply <- request.Result{Value: json.RawMessage(`7`)} + if err := wireReservationAwait(t, reentrant); err != nil { + t.Fatalf("refusal admission closed carrier: %v", err) + } + if response := wireReservationAwait(t, first.replies); string(response.Result) != "7" { + t.Fatalf("first response = %+v", response) + } + gate.open() + if got := wireReservationAwait(t, peer.outputs).frame.Kind; got != "event" { + t.Fatalf("queued event = %s", got) + } + if response := wireReservationAwait(t, second.replies); response.Error == nil || response.Error.Code != "busy" { + t.Fatalf("retained reservation allowed another request: %+v", response) + } + // Reuse the original local identity after the control drains. The stale + // cancellation must neither cancel it nor delete its new state. + first.onReply = nil + wireReservationSend(t, root, wire.ProfileRequest, "c:1", address) + third := wireReservationAwait(t, peer.outputs).frame + if third.Kind != "request" { + t.Fatalf("stale control was emitted: %+v", third) + } + peer.mu.Lock() + reply = peer.pending[third.ID] + peer.mu.Unlock() + reply <- request.Result{Value: json.RawMessage(`9`)} + if response := wireReservationAwait(t, first.replies); string(response.Result) != "9" { + t.Fatalf("reused identity response = %+v", response) + } + if err := peer.Err(); err != nil { + t.Fatalf("carrier ended: %v", err) + } +} + +func TestRootWireCancellationReservationDoesNotIncreaseDataCapacity(t *testing.T) { + peer, root, propagator := wireReservationPeer(t) + gate := propagator.pause(t) + wireReservationSend(t, root, wire.ProfileEvent, "", nil) + wireReservationAwait(t, gate.started) + wireReservationSend(t, root, wire.ProfileEvent, "", nil) + if err := root.Send([]string{"operation"}, wireReservationMessage(wire.ProfileEvent, "", nil)); !errors.Is(err, core.ErrBackpressure) { + t.Fatalf("extra data admission = %v", err) + } + if !errors.Is(peer.Err(), core.ErrBackpressure) { + t.Fatalf("full data carrier remained open: %v", peer.Err()) + } +} From 90041b78201bd6f3837e5cc5a514dbf85ef1270a Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 11:59:29 +0200 Subject: [PATCH 13/39] engine: answer a call the peer's end cut off as disconnected, and send the close code Two defects the ported tests found, both present in Nightseam v0.6.0: - A request the root had handed to the peer waited under a context derived from the peer's, which the peer's end cancels before releasing waiters, so its caller was answered cancelled instead of disconnected (R26). The waiter now prefers the peer's terminal error and owes no cancellation. - Peer.end cancelled the reader's context before closing the connection. A WebSocket whose pending read is cancelled drops the socket, so a peer ended from another goroutine let the far side observe 1006 instead of its code. The connection now closes first. TestAClosingPeerDeliversItsCodeOverAWebSocket fails on the first of 50 runs without the fix and passes all of them with it; the send test's skip is removed and now requires disconnected. Co-Authored-By: Claude Opus 5.5 (1M context) --- dispatch/go/wire_send_test.go | 10 ++---- docs/port-from-nightseam.md | 6 ++++ engine/go/peer.go | 21 +++++++++--- engine/websocket/go/close_test.go | 55 +++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 engine/websocket/go/close_test.go diff --git a/dispatch/go/wire_send_test.go b/dispatch/go/wire_send_test.go index 8b53a32..be532b3 100644 --- a/dispatch/go/wire_send_test.go +++ b/dispatch/go/wire_send_test.go @@ -222,14 +222,10 @@ func TestOutputCancellationProofBelongsOnlyToTheUnadmittedCall(t *testing.T) { if !errors.Is(destination.Err(), core.ErrBackpressure) { t.Fatalf("full carrier ended with %v", destination.Err()) } + // A call the peer's end cut off is disconnected, not withdrawn, although + // its context derives from the peer's. Nightseam v0.6.0's root answered + // cancelled here. var public *core.PublicError - if errors.As(earlier, &public) && public.Code == "cancelled" { - // The root hands an admitted request to the peer under a context - // derived from the peer's own, which the peer's end cancels before it - // releases the waiter; the waiter can then read its end as a - // withdrawal. Nightseam v0.6.0's root did the same. - t.Skip("engine/go: a root call the peer's end settles is answered cancelled, not disconnected") - } if !errors.As(earlier, &public) || public.Code != "disconnected" { t.Fatalf("earlier call = %v", earlier) } diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index ffd1798..c031162 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -67,6 +67,12 @@ None changes a `bitwire/1` frame. (`core.ErrBackpressure`, a remote `CloseError`, a context error). A forwarded request whose destination closed or overflowed is answered `disconnected`, not `internal`. +- **A peer's end is disconnected, and its close code arrives.** A call through + the root that the peer's end cuts off is answered `disconnected`; v0.6.0 + answered `cancelled`, because the call's context derives from the peer's. + The peer closes its connection before cancelling its context, so a WebSocket + peer ended from another goroutine transmits the code it chose; v0.6.0's + order let the far side observe 1006 instead. - **Observe-only close codes (R27).** `transports.Sendable` separates codes that may be sent from 1005, 1006 and 1015. Transports refuse the latter with `ErrUnsendableCode`; a peer asked to close with one aborts instead. diff --git a/engine/go/peer.go b/engine/go/peer.go index 2514674..4abc418 100644 --- a/engine/go/peer.go +++ b/engine/go/peer.go @@ -270,18 +270,22 @@ func (p *Peer) end(err error, code transports.Code, reason string) { p.mu.Lock() p.err = err p.mu.Unlock() - p.cancel() close(p.done) + // The connection is closed before the peer's context is cancelled. The + // reader receives under that context, and a WebSocket whose pending read + // is cancelled drops the socket, so the far side would observe 1006 + // instead of the code chosen here. if code == codeAborted || !transports.Sendable(code) { _ = p.conn.Abort() } else { - // The peer's own context is already cancelled, so the handshake - // waits on one of its own: a far side that answers is told the - // code, and one that does not holds nothing up past the deadline. + // The handshake waits on a context of its own: a far side that + // answers is told the code, and one that does not holds nothing up + // past the deadline. ctx, cancel := context.WithTimeout(context.Background(), p.options.WriteTimeout) _ = p.conn.Close(ctx, code, closeReason(reason)) cancel() } + p.cancel() }) } @@ -371,6 +375,15 @@ func (p *Peer) beginCall(ctx context.Context, method string, params json.RawMess return &admittedCall{await: func(result any) error { defer finish() cancelRemote, err := request.Await(ctx, reply, p.done, p.Err, result) + if cancelRemote { + // The call's context derives from the peer's, which ends with the + // peer: a call cut off by the peer ending is disconnected, not + // withdrawn, and owes the far side no cancellation. + if ended := p.Err(); ended != nil { + complete(false) + return ended + } + } complete(cancelRemote) return err }, withdraw: func() { diff --git a/engine/websocket/go/close_test.go b/engine/websocket/go/close_test.go new file mode 100644 index 0000000..7560185 --- /dev/null +++ b/engine/websocket/go/close_test.go @@ -0,0 +1,55 @@ +package websocket_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + engine "github.com/Bitspark/bitruntime/engine/go" + websocket "github.com/Bitspark/bitruntime/engine/websocket/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// TestAClosingPeerDeliversItsCodeOverAWebSocket: a peer closed from another +// goroutine while its reader waits must transmit the code it chose. Nightseam +// v0.6.0 cancelled the reader's context first, and a WebSocket whose pending +// read is cancelled drops the socket, so the far side often observed 1006. +func TestAClosingPeerDeliversItsCodeOverAWebSocket(t *testing.T) { + for i := range 50 { + peers := make(chan *engine.Peer, 1) + handler, err := websocket.NewHandler(websocket.ServerOptions{ + Authenticate: func(*http.Request) (context.Context, error) { return context.Background(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(p *engine.Peer) { peers <- p }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + client, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), websocket.DialOptions{}) + if err != nil { + t.Fatal(err) + } + accepted := <-peers + // The server's reader is waiting for a frame when another goroutine + // closes the peer. + time.Sleep(5 * time.Millisecond) + _ = accepted.Wire().Close(4001, "chosen") + select { + case <-client.Done(): + case <-ctx.Done(): + t.Fatal("the client never saw the close") + } + var closed *transports.CloseError + if !errors.As(client.Err(), &closed) || closed.Code != 4001 || closed.Reason != "chosen" { + t.Fatalf("run %d: the client observed %v, not 4001 chosen", i, client.Err()) + } + cancel() + server.Close() + } +} From b092aee82021085f01cb22f99aab4a7e16dc144f Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:02:04 +0200 Subject: [PATCH 14/39] build: smoke the runtime path from a fresh public Go module The fresh consumer now also calls through a local pair, a dispatcher and a WebSocket peer, installing the pushed commit or tag from the public proxy. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/go-smoke.mjs | 52 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/scripts/go-smoke.mjs b/scripts/go-smoke.mjs index 6b0916d..5e11c85 100644 --- a/scripts/go-smoke.mjs +++ b/scripts/go-smoke.mjs @@ -17,14 +17,23 @@ function run(args) { run(['mod', 'init', 'example.com/bitruntime-consumer']); writeFileSync(join(temp, 'main.go'), `package main import ( + "context" + "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + websocket "github.com/Bitspark/bitruntime/engine/websocket/go" wire "github.com/Bitspark/bitwire/wire/go" ) type target struct { seen *int } func (t target) Send(wire.Message) error { *t.seen++; return nil } -func main() { +func trees() { seen := 0 leaf, err := core.Compose[wire.Wire](target{&seen}, nil) if err != nil { panic(err) } @@ -37,9 +46,46 @@ func main() { if err = core.AsAddressed(tree).Send([]string{"child"}, message); err != nil { panic(err) } if seen != 2 { panic("derived sends did not reach the selected primitive") } if err = core.Send(tree, wire.TreePath{[]byte{255}}, message); !errors.Is(err, core.ErrMissingPath) { panic("missing path was not refused") } - fmt.Println("Fresh public Go module consumer passed.") +} +func serve(d *dispatch.Dispatcher) error { + _, err := dispatch.Handle(d, []string{"spaces", "a/b", "echo"}, func(_ context.Context, raw json.RawMessage) (any, error) { return raw, nil }) + return err +} +func runtimePath() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { panic(err) } + d, err := dispatch.NewDispatcher(right) + if err != nil { panic(err) } + if err = serve(d); err != nil { panic(err) } + var got map[string]int + if err = dispatch.Call(ctx, core.At(left, []string{"spaces", "a/b"}), []string{"echo"}, map[string]int{"n": 1}, &got); err != nil || got["n"] != 1 { panic(fmt.Sprint("pair call: ", got, err)) } + handler, err := websocket.NewHandler(websocket.ServerOptions{ + Authenticate: func(*http.Request) (context.Context, error) { return context.Background(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + Options: engine.Options{Prepare: func(p *engine.Peer) error { + d, err := dispatch.NewDispatcher(p.Wire()) + if err != nil { return err } + return serve(d) + }}, + }) + if err != nil { panic(err) } + server := httptest.NewServer(handler) + defer server.Close() + peer, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), websocket.DialOptions{}) + if err != nil { panic(err) } + defer peer.Close() + got = nil + if err = dispatch.Call(ctx, peer.Wire(), []string{"spaces", "a/b", "echo"}, map[string]int{"n": 2}, &got); err != nil || got["n"] != 2 { panic(fmt.Sprint("socket call: ", got, err)) } +} +func main() { + trees() + runtimePath() + fmt.Println("Fresh public Go module consumer passed: trees, a local pair and a WebSocket peer.") } `); -run(['get', `github.com/Bitspark/bitruntime/core/go@${revision}`]); +run(['get', `github.com/Bitspark/bitruntime@${revision}`]); +run(['mod', 'tidy']); run(['run', '.']); console.log(`Installed public Go module at ${revision} in ${temp}`); From 7766a1d6b3f9cec32d1db59e0e22067eb909b030 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:01:50 +0200 Subject: [PATCH 15/39] core: port the v0.6.0 At, Mount and path encoding tests Port duplex/go/wire_test.go from Nightseam v0.6.0 (5cc9723a) to core/go/addressed_test.go. Names follow docs/port-from-nightseam.md: duplex.At and Mount are core.At and Mount, ErrNoRoute and ErrPath are core.ErrMissingPath and ErrInvalidPath, ErrClosed is transports.ErrClosed, and the canonical path encoding is internal/profile's. Every assertion is kept as it was. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/addressed_test.go | 480 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 core/go/addressed_test.go diff --git a/core/go/addressed_test.go b/core/go/addressed_test.go new file mode 100644 index 0000000..1cadab3 --- /dev/null +++ b/core/go/addressed_test.go @@ -0,0 +1,480 @@ +package core_test + +// Ported from Nightseam v0.6.0 duplex/go/wire_test.go (commit 5cc9723a): At, +// Mount and the canonical path encoding the addressed carriers share. + +import ( + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +func TestPathEncodingIsCanonicalAndComposable(t *testing.T) { + paths := [][]string{{}, {""}, {"a", "b"}, {"a.b"}, {"a", "b:c"}, {"รฉ", "e\u0301", "๐Ÿ˜€", "\ufeff", "\x00"}} + seen := map[string]bool{} + for _, path := range paths { + encoded, err := profile.EncodePath(path) + if err != nil { + t.Fatal(err) + } + if seen[encoded] { + t.Fatalf("paths alias at %q", encoded) + } + seen[encoded] = true + decoded, err := profile.DecodePath(encoded) + if err != nil || !reflect.DeepEqual(decoded, path) { + t.Fatalf("%q: %#v, %v", encoded, decoded, err) + } + for _, suffix := range paths { + b, _ := profile.EncodePath(suffix) + combined, _ := profile.EncodePath(append(append([]string{}, path...), suffix...)) + if combined != encoded+b { + t.Fatal("prefixing did not compose by concatenation") + } + } + } + if got, _ := profile.EncodePath([]string{"a", "๐Ÿ˜€", ""}); got != "1:a4:๐Ÿ˜€0:" { + t.Fatal(got) + } + for _, malformed := range []string{"01:a", "00:", "1", ":", "-1:a", "2:a", "1:รฉ", "99999999999999999999999999999:x", "1:\xff"} { + if _, err := profile.DecodePath(malformed); err == nil { + t.Fatalf("accepted %q", malformed) + } + } + if _, err := profile.EncodePath([]string{"\xff"}); err == nil { + t.Fatal("accepted non-scalar UTF-8") + } +} + +// queuedRoot is a deterministic endpoint fixture. Only drain executes queued +// deliveries, so composition cannot pass the asynchronous check by timing luck. +type queuedRoot struct { + mu sync.Mutex + queue []queuedDelivery + current *rootAttachment + closed bool + closes int +} +type rootAttachment struct{ receiver wire.Receiver } +type queuedDelivery struct { + path []string + message wire.Message +} +type nonComparableRoot struct { + *queuedRoot + marker []int +} + +func newRoot() *queuedRoot { return &queuedRoot{} } +func (r *queuedRoot) Send(path []string, message wire.Message) error { + if _, err := profile.EncodePath(path); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return transports.ErrClosed + } + r.queue = append(r.queue, queuedDelivery{append([]string{}, path...), message}) + return nil +} +func (r *queuedRoot) Receive(receiver wire.Receiver) (func(), error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil, transports.ErrClosed + } + if r.current != nil { + return nil, core.ErrReceiverExists + } + attachment := &rootAttachment{receiver} + r.current = attachment + return func() { + r.mu.Lock() + if r.current == attachment { + r.current = nil + } + r.mu.Unlock() + }, nil +} +func (r *queuedRoot) attached() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.current != nil +} +func (r *queuedRoot) captured() wire.Receiver { + r.mu.Lock() + defer r.mu.Unlock() + return r.current.receiver +} +func (r *queuedRoot) Close(code wire.Code, reason string) error { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return nil + } + r.closed = true + r.closes++ + attachment := r.current + r.current = nil + r.mu.Unlock() + if attachment != nil && attachment.receiver.Closed != nil { + attachment.receiver.Closed(code, reason) + } + return nil +} +func (r *queuedRoot) drain() { + for { + r.mu.Lock() + if len(r.queue) == 0 { + r.mu.Unlock() + return + } + d := r.queue[0] + r.queue = r.queue[1:] + attachment := r.current + r.mu.Unlock() + if attachment != nil && attachment.receiver.Message != nil { + attachment.receiver.Message(d.path, d.message) + } + } +} + +type sendOnly func([]string, wire.Message) error + +func (s sendOnly) Send(path []string, message wire.Message) error { return s(path, message) } + +func TestWireSelectionsGrantOnlySendAccess(t *testing.T) { + root := newRoot() + prefix := []string{"a.b"} + selected := core.At(sendOnly(root.Send), prefix) + prefix[0] = "changed" + for _, view := range []wire.AddressedWire{selected, core.At(root, nil), core.At(selected, []string{"๐Ÿ˜€"})} { + if _, ok := view.(interface { + Receive(wire.Receiver) (func(), error) + }); ok { + t.Fatal("selection grants receive authority") + } + if _, ok := view.(interface { + Close(wire.Code, string) error + }); ok { + t.Fatal("selection grants lifecycle authority") + } + } + path := []string{"call"} + if err := core.At(selected, []string{"๐Ÿ˜€"}).Send(path, wire.Message{}); err != nil { + t.Fatal(err) + } + path[0] = "changed" + if !reflect.DeepEqual(root.queue[0].path, []string{"a.b", "๐Ÿ˜€", "call"}) { + t.Fatal(root.queue) + } +} + +func TestMountPreservesPathsFramesAndReturnCapability(t *testing.T) { + left, right, reply := newRoot(), newRoot(), newRoot() + children := map[string]wire.Endpoint{"left": left, "": right} + mounted := core.Mount(children) + children["left"] = reply + address := &wire.ReturnAddress{Wire: nonComparableRoot{reply, []int{1}}} + var paths [][]string + var received []wire.Message + _, err := mounted.Receive(wire.Receiver{Message: func(path []string, message wire.Message) { + paths = append(paths, path) + received = append(received, message) + }}) + if err != nil { + t.Fatal(err) + } + frames := []wire.ProfileFrame{ + {Version: 1, Kind: wire.ProfileRequest, ID: "c:1", Params: json.RawMessage(`{"n":9007199254740993}`), Meta: map[string]string{"tag": "value"}}, + {Version: 1, Kind: wire.ProfileResponse, ID: "c:1", Error: &wire.ProfileError{Code: "refused", Message: "No", Data: json.RawMessage(`{"why":"test"}`)}}, + {Version: 1, Kind: wire.ProfileEvent, Data: json.RawMessage(`null`)}, + {Version: 1, Kind: wire.ProfileCancel, ID: "c:1"}, + } + view := core.At(core.At(mounted, []string{"left"}), []string{"๐Ÿ˜€"}) + for _, frame := range frames { + if err := view.Send([]string{"call"}, wire.Message{Frame: frame, Return: address}); err != nil { + t.Fatal(err) + } + } + if len(received) != 0 || len(left.queue) != 4 || len(reply.queue) != 0 { + t.Fatal("composition changed dispatch ownership") + } + for _, delivery := range left.queue { + if !reflect.DeepEqual(delivery.path, []string{"๐Ÿ˜€", "call"}) { + t.Fatal(delivery.path) + } + } + left.drain() + for i, frame := range frames { + if !reflect.DeepEqual(paths[i], []string{"left", "๐Ÿ˜€", "call"}) || !reflect.DeepEqual(received[i].Frame, frame) || received[i].Return != address { + t.Fatal(paths[i], received[i]) + } + } + if err := mounted.Send([]string{""}, wire.Message{}); err != nil { + t.Fatal(err) + } + right.drain() + if !reflect.DeepEqual(paths[4], []string{""}) { + t.Fatal(paths[4]) + } + for _, path := range [][]string{nil, {"missing"}} { + if err := mounted.Send(path, wire.Message{}); !errors.Is(err, core.ErrMissingPath) { + t.Fatal(err) + } + } + if err := mounted.Send([]string{"left", "\xff"}, wire.Message{}); !errors.Is(err, core.ErrInvalidPath) { + t.Fatal(err) + } +} + +func TestMountRefusesDuplicateAttachmentAndRebindsWithoutStealingCapturedDeliveries(t *testing.T) { + root, reply := newRoot(), newRoot() + mounted := core.Mount(map[string]wire.Endpoint{"service": root}) + address := &wire.ReturnAddress{Wire: core.At(reply, nil)} + var old, fresh []wire.ProfileKind + var oldPaths [][]string + closed := 0 + detach, err := mounted.Receive(wire.Receiver{ + Message: func(path []string, message wire.Message) { + oldPaths = append(oldPaths, path) + old = append(old, message.Frame.Kind) + if message.Return != address { + t.Fatal("return capability changed") + } + }, + Closed: func(wire.Code, string) { closed++ }, + }) + if err != nil { + t.Fatal(err) + } + captured := root.captured() + if _, err := mounted.Receive(wire.Receiver{}); !errors.Is(err, core.ErrReceiverExists) { + t.Fatal(err) + } + captured.Message([]string{"wait"}, wire.Message{Frame: wire.ProfileFrame{Kind: wire.ProfileRequest}, Return: address}) + detach() + detach() + if root.attached() || root.closed { + t.Fatal("detach retained ownership or closed borrowed root") + } + freshDetach, err := mounted.Receive(wire.Receiver{Message: func(_ []string, message wire.Message) { fresh = append(fresh, message.Frame.Kind) }}) + if err != nil { + t.Fatal(err) + } + detach() // The old token must never remove the new attachment. + captured.Closed(transports.CodeNormal, "stale close") + captured.Message([]string{"wait"}, wire.Message{Frame: wire.ProfileFrame{Kind: wire.ProfileCancel}, Return: address}) + if err := address.Wire.Send(nil, wire.Message{Frame: wire.ProfileFrame{Kind: wire.ProfileResponse}}); err != nil { + t.Fatal(err) + } + if err := mounted.Send([]string{"service", "new"}, wire.Message{Frame: wire.ProfileFrame{Kind: wire.ProfileEvent}}); err != nil { + t.Fatal(err) + } + root.drain() + if !reflect.DeepEqual(old, []wire.ProfileKind{wire.ProfileRequest, wire.ProfileCancel}) || !reflect.DeepEqual(fresh, []wire.ProfileKind{wire.ProfileEvent}) || closed != 0 || len(reply.queue) != 1 { + t.Fatal(old, fresh, closed, reply.queue) + } + if !reflect.DeepEqual(oldPaths, [][]string{{"service", "wait"}, {"service", "wait"}}) { + t.Fatal(oldPaths) + } + freshDetach() + _ = mounted.Close(transports.CodeNormal, "done") + if closed != 0 || root.closed { + t.Fatal("detached owner or borrowed child was closed") + } +} + +func TestMountAttachmentFailureRollsBackOnlyItsBorrowedAttachments(t *testing.T) { + for _, duplicate := range []bool{false, true} { + t.Run(map[bool]string{false: "occupied-child", true: "aliased-child"}[duplicate], func(t *testing.T) { + first, occupied := newRoot(), newRoot() + if duplicate { + occupied = first + } else { + if _, err := occupied.Receive(wire.Receiver{}); err != nil { + t.Fatal(err) + } + } + mounted := core.Mount(map[string]wire.Endpoint{"a": first, "z": occupied}) + if _, err := mounted.Receive(wire.Receiver{}); !errors.Is(err, core.ErrReceiverExists) { + t.Fatal(err) + } + if first.attached() || first.closed || occupied.closed { + t.Fatal("failed acquisition leaked or closed a child") + } + if !duplicate && !occupied.attached() { + t.Fatal("rollback removed another owner") + } + if _, err := first.Receive(wire.Receiver{}); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestMountedChildEndKeepsHealthySiblingAndEndsOwnerOnce(t *testing.T) { + left, right := newRoot(), newRoot() + mounted := core.Mount(map[string]wire.Endpoint{"left": left, "right": right}) + closed, deliveries := 0, 0 + _, err := mounted.Receive(wire.Receiver{ + Message: func(path []string, _ wire.Message) { + if !reflect.DeepEqual(path, []string{"right", "call"}) { + t.Fatal(path) + } + deliveries++ + }, + Closed: func(code wire.Code, reason string) { + closed++ + if code != transports.CodeNormal || reason != "last" { + t.Fatal(code, reason) + } + }, + }) + if err != nil { + t.Fatal(err) + } + stale := left.captured() + _ = left.Close(transports.CodeNormal, "first") + stale.Closed(transports.CodeNormal, "duplicate") + if closed != 0 { + t.Fatal("one child ended the mount attachment") + } + if err := mounted.Send([]string{"right", "call"}, wire.Message{}); err != nil { + t.Fatal(err) + } + right.drain() + _ = right.Close(transports.CodeNormal, "last") + if closed != 1 || deliveries != 1 { + t.Fatal(closed, deliveries) + } + if err := mounted.Send(nil, wire.Message{}); !errors.Is(err, core.ErrMissingPath) { + t.Fatal("child ending permanently closed mount", err) + } + if _, err := mounted.Receive(wire.Receiver{}); !errors.Is(err, transports.ErrClosed) { + t.Fatal(err) + } + _ = mounted.Close(transports.CodeNormal, "mount") + if closed != 1 { + t.Fatal(closed) + } +} + +func TestMountCloseDetachesOwnAttachmentAndPreservesChildren(t *testing.T) { + root := newRoot() + mounted := core.Mount(map[string]wire.Endpoint{"": root}) + closed := 0 + _, err := mounted.Receive(wire.Receiver{Closed: func(code wire.Code, reason string) { + closed++ + if code != transports.CodeNormal || reason != "mount ended" { + t.Error(code, reason) + } + _ = mounted.Close(code, reason) + if _, err := root.Receive(wire.Receiver{}); err != nil { + t.Error("child was not released before closure callback", err) + } + }}) + if err != nil { + t.Fatal(err) + } + _ = mounted.Close(transports.CodeNormal, "mount ended") + _ = mounted.Close(transports.CodeNormal, "again") + if closed != 1 || root.closes != 0 || !root.attached() { + t.Fatal(closed, root.closes, root.attached()) + } + if err := mounted.Send([]string{""}, wire.Message{}); !errors.Is(err, transports.ErrClosed) { + t.Fatal(err) + } + if _, err := mounted.Receive(wire.Receiver{}); !errors.Is(err, transports.ErrClosed) { + t.Fatal(err) + } + if err := root.Send(nil, wire.Message{}); err != nil { + t.Fatal(err) + } +} + +type registeringRoot struct { + *queuedRoot + registered chan struct{} + resume chan struct{} +} + +func (r *registeringRoot) Receive(receiver wire.Receiver) (func(), error) { + detach, err := r.queuedRoot.Receive(receiver) + close(r.registered) + <-r.resume + return detach, err +} +func TestMountCloseDuringReceiveDisposesLateChildAttachment(t *testing.T) { + root := ®isteringRoot{newRoot(), make(chan struct{}), make(chan struct{})} + mounted := core.Mount(map[string]wire.Endpoint{"x": root}) + finished := make(chan error, 1) + closed := 0 + go func() { + _, err := mounted.Receive(wire.Receiver{Closed: func(wire.Code, string) { closed++ }}) + finished <- err + }() + <-root.registered + _ = mounted.Close(transports.CodeNormal, "done") + close(root.resume) + if err := <-finished; !errors.Is(err, transports.ErrClosed) { + t.Fatal(err) + } + if root.attached() || root.closes != 0 || closed != 1 { + t.Fatal(root.attached(), root.closes, closed) + } +} + +type endingRoot struct{ *queuedRoot } + +func (r *endingRoot) Receive(receiver wire.Receiver) (func(), error) { + detach, err := r.queuedRoot.Receive(receiver) + if err == nil { + _ = r.Close(transports.CodeNormal, "ended during acquisition") + } + return detach, err +} +func TestMountChildEndingDuringAcquisitionRollsBackHealthySibling(t *testing.T) { + healthy := newRoot() + ending := &endingRoot{newRoot()} + mounted := core.Mount(map[string]wire.Endpoint{"a": healthy, "z": ending}) + if _, err := mounted.Receive(wire.Receiver{}); !errors.Is(err, transports.ErrClosed) { + t.Fatal(err) + } + if healthy.attached() || ending.attached() || healthy.closed { + t.Fatal("partial acquisition retained a child") + } + if _, err := healthy.Receive(wire.Receiver{}); err != nil { + t.Fatal(err) + } +} + +func TestEmptyMountStillOwnsOneDetachableAttachment(t *testing.T) { + mounted := core.Mount(nil) + detach, err := mounted.Receive(wire.Receiver{}) + if err != nil { + t.Fatal(err) + } + if _, err := mounted.Receive(wire.Receiver{}); !errors.Is(err, core.ErrReceiverExists) { + t.Fatal(err) + } + detach() + closed := 0 + _, err = mounted.Receive(wire.Receiver{Closed: func(wire.Code, string) { closed++ }}) + if err != nil { + t.Fatal(err) + } + detach() + _ = mounted.Close(transports.CodeNormal, "done") + if closed != 1 { + t.Fatal(closed) + } +} From c4348bcbc5774ff4fa103aed9dd766fb2fee02d3 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:02:23 +0200 Subject: [PATCH 16/39] core: port the v0.6.0 local pair tests Port runtime/go/wire_pair_test.go from Nightseam v0.6.0 (5cc9723a) to core/go/pair_test.go as an external test. NewWirePair is core.NewPair; HandleWire, CallWire and EmitWire are dispatch.Handle, Call and Emit; the unexported wireDispatchContext and callWire are internal/delivery.Context and internal/request.Call; sendWireResponse is core.Respond. TestLocalWirePairStalledEventDeadlineIsObserved drops its one assertion on the removed Observer hook: the Backpressure event with Stalled set and a Deadline of 15ms. The stall is still observed as the pair's ending and a closed carrier afterwards. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/pair_test.go | 345 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 core/go/pair_test.go diff --git a/core/go/pair_test.go b/core/go/pair_test.go new file mode 100644 index 0000000..fa6678d --- /dev/null +++ b/core/go/pair_test.go @@ -0,0 +1,345 @@ +package core_test + +// Ported from Nightseam v0.6.0 runtime/go/wire_pair_test.go (commit 5cc9723a). + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/request/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +type localTestReturn struct{ send func(wire.Message) error } + +func (r *localTestReturn) Send(_ []string, m wire.Message) error { return r.send(m) } + +func receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case value := <-channel: + return value + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for peer activity") + var zero T + return zero + } +} + +func testBinding(t *testing.T, endpoint wire.Endpoint) *dispatch.Dispatcher { + t.Helper() + binding, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = binding.Close(transports.CodeNormal, "done") }) + return binding +} + +func localPair(t *testing.T, options core.PairOptions) (wire.Endpoint, wire.Endpoint) { + t.Helper() + a, b, err := core.NewPair(options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = a.Close(transports.CodeNormal, "done") }) + return a, b +} + +func TestLocalWirePairRoundTripReverseAndIsolation(t *testing.T) { + a, b := localPair(t, core.PairOptions{}) + aBinding := testBinding(t, a) + _, err := dispatch.Handle(aBinding, []string{"reverse"}, func(_ context.Context, raw json.RawMessage) (any, error) { return string(raw), nil }) + if err != nil { + t.Fatal(err) + } + bBinding := testBinding(t, b) + _, err = dispatch.Handle(bBinding, []string{"call"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + var result string + err := dispatch.Call(ctx, b, []string{"reverse"}, raw, &result) + return result, err + }) + if err != nil { + t.Fatal(err) + } + var result string + if err := dispatch.Call(context.Background(), a, []string{"call"}, 7, &result); err != nil || result != "7" { + t.Fatalf("reverse result %q: %v", result, err) + } + x, y := localPair(t, core.PairOptions{}) + yBinding := testBinding(t, y) + _, _ = dispatch.Handle(yBinding, []string{"call"}, func(context.Context, json.RawMessage) (any, error) { return "independent", nil }) + _ = a.Close(transports.CodeNormal, "first pair only") + if err := dispatch.Call(context.Background(), x, []string{"call"}, nil, &result); err != nil || result != "independent" { + t.Fatalf("other pair %q: %v", result, err) + } +} + +func TestLocalWirePairRetainsPendingUntilResponse(t *testing.T) { + a, b := localPair(t, core.PairOptions{MaxPendingRequests: 1}) + started, release := make(chan struct{}), make(chan struct{}) + bBinding := testBinding(t, b) + _, _ = dispatch.Handle(bBinding, []string{"hold"}, func(context.Context, json.RawMessage) (any, error) { close(started); <-release; return "done", nil }) + first := make(chan error, 1) + go func() { + var result string + first <- dispatch.Call(context.Background(), a, []string{"hold"}, nil, &result) + }() + <-started + var result any + err := dispatch.Call(context.Background(), a, []string{"hold"}, nil, &result) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("pending budget: %v", err) + } + close(release) + if err := <-first; err != nil { + t.Fatal(err) + } + _, _ = dispatch.Handle(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) + if err := dispatch.Call(context.Background(), a, []string{"next"}, nil, &result); err != nil { + t.Fatal(err) + } +} + +func TestLocalWirePairOrderedEventsAndReservedCancel(t *testing.T) { + a, b := localPair(t, core.PairOptions{QueueCapacity: 1, MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + started, cancelled := make(chan struct{}), make(chan struct{}) + _, _ = dispatch.Handle(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(cancelled) + return nil, ctx.Err() + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + answer := make(chan error, 1) + go func() { answer <- dispatch.Call(ctx, a, []string{"hold"}, nil, nil) }() + <-started + entered, release, drained := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var mu sync.Mutex + var seen []int + _, _ = bBinding.Register([]string{"event"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + var n int + _ = json.Unmarshal(m.Frame.Data, &n) + mu.Lock() + seen = append(seen, n) + mu.Unlock() + if n == 1 { + close(entered) + <-release + } else { + close(drained) + } + }}) + if err := dispatch.Emit(context.Background(), a, []string{"event"}, 1); err != nil { + t.Fatal(err) + } + <-entered + if err := dispatch.Emit(context.Background(), a, []string{"event"}, 2); err != nil { + t.Fatal(err) + } + cancel() + if !errors.Is(<-answer, context.Canceled) { + t.Fatal("caller was not cancelled") + } + close(release) + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("reserved cancel did not arrive") + } + <-drained + mu.Lock() + defer mu.Unlock() + if len(seen) != 2 || seen[0] != 1 || seen[1] != 2 { + t.Fatalf("event order: %v", seen) + } +} + +func TestLocalWirePairOverflowClosesOnlyItsCarrier(t *testing.T) { + a, b := localPair(t, core.PairOptions{QueueCapacity: 1}) + bBinding := testBinding(t, b) + entered, release, ended := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer close(release) + _, _ = bBinding.Register([]string{"event"}, wire.Receiver{Message: func([]string, wire.Message) { close(entered); <-release }, Closed: func(wire.Code, string) { close(ended) }}) + if err := dispatch.Emit(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + <-entered + if err := dispatch.Emit(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(context.Background(), a, []string{"event"}, nil); !errors.Is(err, core.ErrBackpressure) { + t.Fatalf("overflow: %v", err) + } + select { + case <-ended: + case <-time.After(time.Second): + t.Fatal("blocked consumer hid closure") + } +} + +func TestLocalWirePairReturnMappingAndFailedResponseRetirement(t *testing.T) { + a, b := localPair(t, core.PairOptions{MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + received := make(chan wire.Message, 2) + _, _ = bBinding.Register([]string{"raw"}, wire.Receiver{Message: func(_ []string, m wire.Message) { received <- m }}) + failed := errors.New("return failed") + original := &wire.ReturnAddress{Wire: &localTestReturn{send: func(wire.Message) error { return core.Unpublished(failed) }}} + if err := a.Send([]string{"raw"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "c:1", Params: json.RawMessage("null")}, Return: original}); err != nil { + t.Fatal(err) + } + request := <-received + if request.Return == original { + t.Fatal("root did not map the return capability") + } + if err := a.Send([]string{"raw"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: "c:1"}, Return: original}); err != nil { + t.Fatal(err) + } + if cancelled := <-received; cancelled.Return != request.Return { + t.Fatal("cancellation used a different return capability") + } + err := request.Return.Wire.Send(nil, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: "c:1", Result: json.RawMessage("null")}}) + if !errors.Is(err, failed) { + t.Fatalf("return failure lost: %v", err) + } + var unpublished *core.UnpublishedError + if errors.As(err, &unpublished) { + t.Fatal("return retained publication proof after dispatch") + } + _, _ = dispatch.Handle(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) + var result string + if err := dispatch.Call(context.Background(), a, []string{"next"}, nil, &result); err != nil || result != "reused" { + t.Fatalf("next: %q, %v", result, err) + } +} + +// v0.6.0 constructed its unexported wireDispatchContext and called callWire; +// bitruntime keeps the same private association in internal/delivery and the +// same call primitive in internal/request. +func TestLocalWirePairPrivateDispatchContext(t *testing.T) { + a, b := localPair(t, core.PairOptions{}) + type verifiedKey struct{} + verified := &struct{ identity string }{"verified locally"} + established := &delivery.Context{Ctx: context.WithValue(context.Background(), verifiedKey{}, verified)} + bBinding := testBinding(t, b) + _, _ = dispatch.Handle(bBinding, []string{"inspect"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + if ctx.Value(verifiedKey{}) != verified { + return nil, errors.New("lost verified dispatch context") + } + return "observed", nil + }) + var result string + if err := request.Call(context.Background(), a, []string{"inspect"}, nil, &result, established, request.Options{}); err != nil || result != "observed" { + t.Fatalf("context: %q, %v", result, err) + } +} + +func TestLocalDispatcherExactAndPrefixRoutes(t *testing.T) { + a, b := localPair(t, core.PairOptions{}) + bBinding := testBinding(t, b) + for _, path := range [][]string{nil, {"a"}} { + label := "root" + if len(path) > 0 { + label = "a" + } + _, err := bBinding.RegisterPrefix(path, wire.Receiver{Message: func(received []string, m wire.Message) { + if len(received) == 0 { + t.Error("callback path lost its origin") + } + core.Respond(m, json.RawMessage(`"`+label+`"`), nil) + }}) + if err != nil { + t.Fatal(err) + } + } + detach, _ := dispatch.Handle(bBinding, []string{"a", "b"}, func(context.Context, json.RawMessage) (any, error) { return "exact", nil }) + var result string + if err := dispatch.Call(context.Background(), a, []string{"a", "b"}, nil, &result); err != nil || result != "exact" { + t.Fatalf("exact: %q, %v", result, err) + } + detach() + if err := dispatch.Call(context.Background(), a, []string{"a", "b"}, nil, &result); err != nil || result != "a" { + t.Fatalf("prefix: %q, %v", result, err) + } + if err := dispatch.Call(context.Background(), a, []string{"other"}, nil, &result); err != nil || result != "root" { + t.Fatalf("root: %q, %v", result, err) + } +} + +func TestLocalWirePairDeadlineRetainsNoncooperativeHandlerBudget(t *testing.T) { + a, b := localPair(t, core.PairOptions{RequestTimeout: 15 * time.Millisecond, MaxConcurrentHandlers: 1}) + started, cancelled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer close(release) + bBinding := testBinding(t, b) + _, _ = dispatch.Handle(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(cancelled) + <-release + return nil, nil + }) + first := make(chan error, 1) + go func() { first <- dispatch.Call(context.Background(), a, []string{"hold"}, nil, nil) }() + <-started + var public *core.PublicError + if err := <-first; !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatalf("deadline: %v", err) + } + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("deadline did not cancel handler") + } + err := dispatch.Call(context.Background(), a, []string{"hold"}, nil, nil) + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("handler budget: %v", err) + } +} + +// v0.6.0 also held the stall to a Backpressure observation carrying its +// deadline; the observer hooks are not ported, so the stall is observed as +// the pair's ending alone. +func TestLocalWirePairStalledEventDeadlineIsObserved(t *testing.T) { + a, b := localPair(t, core.PairOptions{WriteTimeout: 15 * time.Millisecond}) + bBinding := testBinding(t, b) + release, closed := make(chan struct{}), make(chan struct{}) + defer close(release) + _, _ = bBinding.Register([]string{"event"}, wire.Receiver{Message: func([]string, wire.Message) { <-release }, Closed: func(wire.Code, string) { close(closed) }}) + if err := dispatch.Emit(context.Background(), a, []string{"event"}, nil); err != nil { + t.Fatal(err) + } + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("stalled event was not observed") + } + if err := dispatch.Emit(context.Background(), a, []string{"event"}, nil); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("closed pair: %v", err) + } +} + +func TestLocalWirePairOversizedResponseUsesBoundedFallback(t *testing.T) { + a, b := localPair(t, core.PairOptions{MaxFrameBytes: 512}) + bBinding := testBinding(t, b) + _, _ = dispatch.Handle(bBinding, []string{"large"}, func(context.Context, json.RawMessage) (any, error) { return strings.Repeat("x", 2048), nil }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var result string + err := dispatch.Call(ctx, a, []string{"large"}, nil, &result) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "internal" { + t.Fatalf("oversized response: %v", err) + } +} From 12903751267add119d4b716c24ac7443f4879a13 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:02:32 +0200 Subject: [PATCH 17/39] core: test the pair's close, pending-slot and closed-carrier fixes Regression tests for the three pair behaviors docs/port-from-nightseam.md records as fixed rather than ported: - nightseam#722: with the receiving side's delivery held, a refusal still queued when the pair closes is answered busy at once instead of at the caller's deadline, and the call the pair held is answered disconnected. - nightseam#658: a return capability that issues the next request while it is handed the answer finds the pending slot free at a limit of one, and 500 sequential calls at that limit are never refused busy. - R26: the send that overflows a pair reports both ErrBackpressure and transports.ErrClosed, the ended pair is closed on both ends, and a request core.Forward hands into the overflow is answered disconnected, not internal. Each fails against the v0.6.0 behavior it guards: completing a call after the hand-off, dropping queued refusals at close, or returning a bare ErrBackpressure. TestLocalWirePairRetainsPendingUntilResponse passes with -race -count=200. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/pair_test.go | 180 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/core/go/pair_test.go b/core/go/pair_test.go index fa6678d..451d373 100644 --- a/core/go/pair_test.go +++ b/core/go/pair_test.go @@ -1,6 +1,7 @@ package core_test -// Ported from Nightseam v0.6.0 runtime/go/wire_pair_test.go (commit 5cc9723a). +// Ported from Nightseam v0.6.0 runtime/go/wire_pair_test.go (commit 5cc9723a), +// with regression tests for nightseam#722, nightseam#658 and research R26. import ( "context" @@ -84,6 +85,9 @@ func TestLocalWirePairRoundTripReverseAndIsolation(t *testing.T) { } } +// nightseam#658: run this with -race and a high -count. The response frees its +// call's pending slot before the caller holds the answer, so the call issued +// right after it is never refused busy. func TestLocalWirePairRetainsPendingUntilResponse(t *testing.T) { a, b := localPair(t, core.PairOptions{MaxPendingRequests: 1}) started, release := make(chan struct{}), make(chan struct{}) @@ -111,6 +115,47 @@ func TestLocalWirePairRetainsPendingUntilResponse(t *testing.T) { } } +// nightseam#658, deterministically: the caller's return capability issues the +// next request while it is being handed the answer, before that hand-off has +// returned to the pair. At a pending limit of one it must find the slot free. +func TestPairResponseFreesPendingSlotBeforeCallerHoldsAnswer(t *testing.T) { + a, b := localPair(t, core.PairOptions{MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + _, _ = dispatch.Handle(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) + answers := make(chan wire.ProfileFrame, 2) + request := func(id string, returning *wire.ReturnAddress) wire.Message { + return wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: id, Params: json.RawMessage("null")}, Return: returning} + } + second := &wire.ReturnAddress{Wire: &localTestReturn{send: func(m wire.Message) error { answers <- m.Frame; return nil }}} + first := &wire.ReturnAddress{Wire: &localTestReturn{send: func(m wire.Message) error { + answers <- m.Frame + return a.Send([]string{"next"}, request("c:2", second)) + }}} + if err := a.Send([]string{"next"}, request("c:1", first)); err != nil { + t.Fatal(err) + } + for _, id := range []string{"c:1", "c:2"} { + answer := receive(t, answers) + if answer.ID != id || answer.Error != nil || string(answer.Result) != `"reused"` { + t.Fatalf("answer to %s: %+v %+v", id, answer, answer.Error) + } + } +} + +// nightseam#658, repeated: at a pending limit of one, each call is issued the +// moment the previous one returned, and none of them finds its slot taken. +func TestPairSequentialCallsAtThePendingLimitAreNeverBusy(t *testing.T) { + a, b := localPair(t, core.PairOptions{MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + _, _ = dispatch.Handle(bBinding, []string{"next"}, func(context.Context, json.RawMessage) (any, error) { return "reused", nil }) + for i := range 500 { + var result string + if err := dispatch.Call(context.Background(), a, []string{"next"}, nil, &result); err != nil || result != "reused" { + t.Fatalf("call %d after its predecessor returned: %q, %v", i, result, err) + } + } +} + func TestLocalWirePairOrderedEventsAndReservedCancel(t *testing.T) { a, b := localPair(t, core.PairOptions{QueueCapacity: 1, MaxPendingRequests: 1}) bBinding := testBinding(t, b) @@ -343,3 +388,136 @@ func TestLocalWirePairOversizedResponseUsesBoundedFallback(t *testing.T) { t.Fatalf("oversized response: %v", err) } } + +// nightseam#722: a pair that closes with a refusal still queued answers it +// with the refusal it was admitted with, instead of leaving its caller to its +// own deadline, and answers the call it still held disconnected. +func TestLocalWirePairCloseAnswersQueuedRefusals(t *testing.T) { + for _, mode := range []string{"blocked-delivery", "immediate-close"} { + t.Run(mode, func(t *testing.T) { + a, b := localPair(t, core.PairOptions{MaxPendingRequests: 1}) + bBinding := testBinding(t, b) + started, release := make(chan struct{}), make(chan struct{}) + var released sync.Once + t.Cleanup(func() { released.Do(func() { close(release) }) }) + _, _ = dispatch.Handle(bBinding, []string{"hold"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + select { + case <-release: + case <-ctx.Done(): + } + return "done", nil + }) + entered, unblock := make(chan struct{}), make(chan struct{}) + var unblocked sync.Once + t.Cleanup(func() { unblocked.Do(func() { close(unblock) }) }) + _, _ = bBinding.Register([]string{"block"}, wire.Receiver{Message: func([]string, wire.Message) { close(entered); <-unblock }}) + // Deadlines far past the test's own wait: an answer that depends + // on them is the defect. + long := dispatch.CallOptions{Timeout: time.Minute} + held := make(chan error, 1) + go func() { held <- dispatch.Call(context.Background(), a, []string{"hold"}, nil, nil, long) }() + receive(t, started) + if mode == "blocked-delivery" { + // The receiving side's delivery is held, so nothing it has + // queued drains before the pair closes. + if err := dispatch.Emit(context.Background(), a, []string{"block"}, nil); err != nil { + t.Fatal(err) + } + receive(t, entered) + } + queued := make(chan struct{}) + admitted := sendOnly(func(path []string, m wire.Message) error { + err := a.Send(path, m) + if m.Frame.Kind == wire.ProfileRequest { + close(queued) + } + return err + }) + refused := make(chan error, 1) + go func() { refused <- dispatch.Call(context.Background(), admitted, []string{"hold"}, nil, nil, long) }() + receive(t, queued) + _ = a.Close(transports.CodeNormal, "closing with a queued refusal") + var public *core.PublicError + if err := receive(t, refused); !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("queued refusal at close: %v", err) + } + if err := receive(t, held); !errors.As(err, &public) || public.Code != "disconnected" { + t.Fatalf("held call at close: %v", err) + } + }) + } +} + +// R26: a pair that overflowed is a closed carrier that keeps its cause. The +// send that overflowed reports both; the carrier is closed from then on. +func TestLocalWirePairOverflowIsAClosedCarrier(t *testing.T) { + a, b := localPair(t, core.PairOptions{QueueCapacity: 1}) + bBinding := testBinding(t, b) + entered, release, ended := make(chan struct{}), make(chan struct{}), make(chan wire.Code, 1) + defer close(release) + _, _ = bBinding.Register([]string{"event"}, wire.Receiver{Message: func([]string, wire.Message) { close(entered); <-release }, Closed: func(code wire.Code, _ string) { ended <- code }}) + event := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: json.RawMessage("null")}} + if err := a.Send([]string{"event"}, event); err != nil { + t.Fatal(err) + } + receive(t, entered) + if err := a.Send([]string{"event"}, event); err != nil { + t.Fatal(err) + } + err := a.Send([]string{"event"}, event) + if !errors.Is(err, core.ErrBackpressure) || !errors.Is(err, transports.ErrClosed) { + t.Fatalf("overflowing send: %v", err) + } + if code := receive(t, ended); code != transports.CodeProtocol { + t.Fatalf("overflow ended the pair with %d", code) + } + for _, end := range []wire.Endpoint{a, b} { + if err := end.Send([]string{"event"}, event); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("send after overflow: %v", err) + } + if _, err := end.Receive(wire.Receiver{}); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("receive after overflow: %v", err) + } + } +} + +// R26: a request that core.Forward hands to a pair it overflows is answered +// disconnected, not internal, through its own return capability. +func TestForwardedRequestToAnOverflowedPairIsAnsweredDisconnected(t *testing.T) { + caller, inbound := localPair(t, core.PairOptions{}) + left, right := localPair(t, core.PairOptions{QueueCapacity: 1}) + stop, err := core.Forward(inbound, left) + if err != nil { + t.Fatal(err) + } + t.Cleanup(stop) + rightBinding := testBinding(t, right) + entered, release := make(chan struct{}), make(chan struct{}) + defer close(release) + var once sync.Once + _, _ = rightBinding.Register([]string{"event"}, wire.Receiver{Message: func([]string, wire.Message) { + once.Do(func() { close(entered) }) + <-release + }}) + reached := make(chan struct{}, 1) + _, _ = dispatch.Handle(rightBinding, []string{"call"}, func(context.Context, json.RawMessage) (any, error) { reached <- struct{}{}; return nil, nil }) + if err := dispatch.Emit(context.Background(), caller, []string{"event"}, 1); err != nil { + t.Fatal(err) + } + receive(t, entered) + // The destination's queue now holds this one event and nothing drains it. + if err := dispatch.Emit(context.Background(), caller, []string{"event"}, 2); err != nil { + t.Fatal(err) + } + err = dispatch.Call(context.Background(), caller, []string{"call"}, nil, nil, dispatch.CallOptions{Timeout: time.Minute}) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "disconnected" { + t.Fatalf("request forwarded into an overflow: %v", err) + } + select { + case <-reached: + t.Fatal("the overflowing request reached its handler") + default: + } +} From 21af095d5dcd10edb54ad0ab514a55113a4a2dc3 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:02:46 +0200 Subject: [PATCH 18/39] core: port the v0.6.0 invocation lifecycle tests and experiment Port runtime/go/invocation_test.go and invocation_experiment_test.go from Nightseam v0.6.0 (5cc9723a), complete: the two independently implemented lifecycle participants (one composing core.Invocation, one answering the vocabulary from its own ledger), the opaque forwarding wrapper, the pure conduit route, and every assertion. They use public facilities only: core's vocabulary and helpers, dispatch.NewDispatcher, Handle and Register, and core.Forward for ForwardWire. Path-taking duplex.Wire sinks are wire.AddressedWire. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/invocation_experiment_test.go | 573 ++++++++++++++++++++++++++ core/go/invocation_test.go | 523 +++++++++++++++++++++++ 2 files changed, 1096 insertions(+) create mode 100644 core/go/invocation_experiment_test.go create mode 100644 core/go/invocation_test.go diff --git a/core/go/invocation_experiment_test.go b/core/go/invocation_experiment_test.go new file mode 100644 index 0000000..4ae07e9 --- /dev/null +++ b/core/go/invocation_experiment_test.go @@ -0,0 +1,573 @@ +package core_test + +// Ported from Nightseam v0.6.0 runtime/go/invocation_experiment_test.go +// (commit 5cc9723a): the second independent lifecycle participant, a pure +// forwarding route and an opaque wrapper, using public facilities only. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// ledgerEndpoint is the second independent integration. It shares no ledger +// with the first and composes none of bitruntime's lifecycle: it answers the +// invocation vocabulary itself, out of its own state, using the operation +// paths and nothing else. If the dispatcher works against this, the boundary +// is public in fact and not only in name. +type ledgerEndpoint struct { + mu sync.Mutex + receiver *wire.Receiver + captureCap int + bodyCap int + calls map[string]*ledgerCall + next atomic.Uint64 + retirements atomic.Int64 + refusals atomic.Int64 +} + +type ledgerCall struct { + mu sync.Mutex + owner *ledgerEndpoint + address *wire.ReturnAddress + outcome chan wire.Message + sinks map[string]wire.AddressedWire + delivered map[string]bool + told map[string]bool + bodies map[string]bool + takenCaps int + takenBody int + pending int + control *wire.Message + settled bool + retired bool +} + +func newLedgerEndpoint(captures, bodies int) *ledgerEndpoint { + return &ledgerEndpoint{captureCap: captures, bodyCap: bodies, calls: map[string]*ledgerCall{}} +} + +func (e *ledgerEndpoint) Send([]string, wire.Message) error { return nil } +func (e *ledgerEndpoint) Close(code wire.Code, reason string) error { + e.mu.Lock() + receiver := e.receiver + e.receiver = nil + e.mu.Unlock() + if receiver != nil && receiver.Closed != nil { + receiver.Closed(code, reason) + } + return nil +} +func (e *ledgerEndpoint) Receive(receiver wire.Receiver) (func(), error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.receiver != nil { + return nil, core.ErrReceiverExists + } + held := receiver + e.receiver = &held + return func() { + e.mu.Lock() + if e.receiver == &held { + e.receiver = nil + } + e.mu.Unlock() + }, nil +} + +// ledgerReturn answers the outcome at its own origin and the invocation +// vocabulary everywhere else, refusing any path it does not implement. +type ledgerReturn struct{ call *ledgerCall } + +func (r *ledgerReturn) Send(path []string, message wire.Message) error { + call := r.call + if len(path) == 0 { + if message.Frame.Kind != wire.ProfileResponse { + return errors.New("invalid outcome") + } + select { + case call.outcome <- message: + default: + } + call.settle() + return nil + } + switch path[0] { + case core.InvocationControl: + if len(path) != 1 || message.Frame.Kind != wire.ProfileCancel { + return errors.New("unknown invocation operation") + } + call.latch(message) + return nil + case core.InvocationCapture: + if len(path) != 2 || message.Return == nil || message.Return.Wire == nil { + return errors.New("unknown invocation operation") + } + return call.capture(path[1], message.Return.Wire) + case core.InvocationReady: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + call.markReady(path[1]) + return nil + case core.InvocationRelease: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + call.release(path[1]) + return nil + case core.InvocationBegin: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + return call.begin(path[1]) + case core.InvocationDone: + if len(path) != 2 { + return errors.New("unknown invocation operation") + } + call.done(path[1]) + return nil + } + return errors.New("unknown invocation operation") +} + +func (c *ledgerCall) capture(identifier string, sink wire.AddressedWire) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.retired { + return errors.New("retired") + } + if c.takenCaps >= c.owner.captureCap { + c.owner.refusals.Add(1) + return errors.New("capture bound reached") + } + c.takenCaps++ + c.pending++ + c.sinks[identifier] = sink + return nil +} + +func (c *ledgerCall) markReady(identifier string) { + c.mu.Lock() + sink := c.sinks[identifier] + if sink == nil || c.delivered[identifier] { + c.mu.Unlock() + return + } + c.delivered[identifier] = true + c.pending-- + var deliver wire.AddressedWire + var control wire.Message + if c.control != nil && !c.told[identifier] { + c.told[identifier] = true + deliver, control = sink, *c.control + } + c.mu.Unlock() + if deliver != nil { + _ = deliver.Send(nil, control) + } + c.retire() +} + +func (c *ledgerCall) release(identifier string) { + c.mu.Lock() + if _, exists := c.sinks[identifier]; !exists { + c.mu.Unlock() + return + } + if !c.delivered[identifier] { + c.pending-- + } + delete(c.sinks, identifier) + delete(c.delivered, identifier) + c.mu.Unlock() + c.retire() +} + +func (c *ledgerCall) begin(identifier string) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.retired { + return errors.New("retired") + } + if c.takenBody >= c.owner.bodyCap { + c.owner.refusals.Add(1) + return errors.New("body bound reached") + } + c.takenBody++ + c.bodies[identifier] = true + return nil +} + +func (c *ledgerCall) done(identifier string) { + c.mu.Lock() + if !c.bodies[identifier] { + c.mu.Unlock() + return + } + delete(c.bodies, identifier) + c.mu.Unlock() + c.retire() +} + +func (c *ledgerCall) latch(message wire.Message) { + c.mu.Lock() + if c.retired || c.control != nil { + c.mu.Unlock() + return + } + c.control = &message + var sinks []wire.AddressedWire + for identifier, sink := range c.sinks { + if c.delivered[identifier] && !c.told[identifier] { + c.told[identifier] = true + sinks = append(sinks, sink) + } + } + c.mu.Unlock() + for _, sink := range sinks { + _ = sink.Send(nil, message) + } +} + +func (c *ledgerCall) settle() { + c.mu.Lock() + c.settled = true + c.mu.Unlock() + c.retire() +} + +func (c *ledgerCall) retire() { + c.mu.Lock() + if c.retired || !c.settled || c.pending != 0 || len(c.bodies) != 0 { + c.mu.Unlock() + return + } + c.retired = true + c.sinks, c.delivered, c.told, c.control = nil, nil, nil, nil + c.mu.Unlock() + c.owner.retirements.Add(1) +} + +func (e *ledgerEndpoint) admit(path []string, params json.RawMessage) (*ledgerCall, chan wire.Message) { + identifier := fmt.Sprintf("l:%d", e.next.Add(1)) + call := &ledgerCall{owner: e, outcome: make(chan wire.Message, 1), sinks: map[string]wire.AddressedWire{}, + delivered: map[string]bool{}, told: map[string]bool{}, bodies: map[string]bool{}, pending: 1} + call.address = &wire.ReturnAddress{Wire: &ledgerReturn{call: call}} + e.mu.Lock() + e.calls[identifier] = call + receiver := e.receiver + e.mu.Unlock() + if receiver != nil && receiver.Message != nil { + receiver.Message(path, wire.Message{ + Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: identifier, Params: params}, + Return: call.address, + }) + } + // The request's own delivery has returned. + call.mu.Lock() + call.pending-- + call.mu.Unlock() + call.retire() + return call, call.outcome +} + +func (c *ledgerCall) cancel(identifier string) { + _ = c.address.Wire.Send([]string{core.InvocationControl}, wire.Message{ + Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: identifier}, + Return: c.address, + }) +} + +func (c *ledgerCall) isRetired() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.retired +} + +// TestASecondIntegrationParticipatesWithNoSharedLedger runs bitruntime's +// dispatcher and its handler registration over an endpoint that implements +// the lifecycle itself, through an opaque wrapper, with no shared state and +// no concrete type recognized on either side. +func TestASecondIntegrationParticipatesWithNoSharedLedger(t *testing.T) { + endpoint := newLedgerEndpoint(8, 8) + router, err := dispatch.NewDispatcher(opaqueEndpoint{endpoint}) + if err != nil { + t.Fatal(err) + } + view, err := dispatch.NewDispatcher(router.Select([]string{"space"})) + if err != nil { + t.Fatal(err) + } + started, release := make(chan struct{}, 1), make(chan struct{}) + observed := make(chan error, 1) + if _, err := dispatch.Handle(view, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + started <- struct{}{} + <-release + observed <- ctx.Err() + return "answer", nil + }); err != nil { + t.Fatal(err) + } + call, outcome := endpoint.admit([]string{"space", "read"}, nil) + <-started + if call.isRetired() { + t.Fatal("retired while the body was running") + } + call.cancel("l:1") + close(release) + if err := <-observed; err == nil { + t.Fatal("cancellation did not reach the captured traversal of the second integration") + } + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + for range 500 { + if call.isRetired() { + break + } + time.Sleep(2 * time.Millisecond) + } + if !call.isRetired() { + t.Fatal("the second integration never retired its invocation") + } + if endpoint.retirements.Load() != 1 { + t.Fatalf("retirements: %d", endpoint.retirements.Load()) + } +} + +// conduitEndpoint is half of a pure route: what is sent on one half is +// delivered to the other half's attachment, verbatim, with the message's +// return capability untouched. It correlates nothing and admits nothing, so a +// composition over it is pure forwarding rather than a carrier hop. +type conduitEndpoint struct { + mu sync.Mutex + receiver *wire.Receiver + other *conduitEndpoint +} + +func newConduit() (*conduitEndpoint, *conduitEndpoint) { + near, far := &conduitEndpoint{}, &conduitEndpoint{} + near.other, far.other = far, near + return near, far +} + +func (c *conduitEndpoint) Send(path []string, message wire.Message) error { + c.other.mu.Lock() + receiver := c.other.receiver + c.other.mu.Unlock() + if receiver == nil || receiver.Message == nil { + return transports.ErrClosed + } + receiver.Message(path, message) + return nil +} +func (c *conduitEndpoint) Receive(receiver wire.Receiver) (func(), error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.receiver != nil { + return nil, core.ErrReceiverExists + } + held := receiver + c.receiver = &held + return func() { + c.mu.Lock() + if c.receiver == &held { + c.receiver = nil + } + c.mu.Unlock() + }, nil +} +func (c *conduitEndpoint) Close(wire.Code, string) error { return nil } + +// TestForwardingPreservesLifecycleParticipation admits on one integration and +// forwards through an opaque wrapper and a pure route into a dispatcher on the +// far side. The lifecycle travels with the preserved return capability rather +// than being reconstructed at the boundary, and detach and rebind on the far +// side leave the captured traversal owning the control. +func TestForwardingPreservesLifecycleParticipation(t *testing.T) { + origin := newLedgerEndpoint(8, 8) + near, far := newConduit() + stop, err := core.Forward(opaqueEndpoint{origin}, opaqueEndpoint{near}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(stop) + router, err := dispatch.NewDispatcher(far) + if err != nil { + t.Fatal(err) + } + controls, requests := make(chan wire.Message, 4), make(chan wire.Message, 4) + detach, err := router.Register([]string{"read"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + if m.Frame.Kind == wire.ProfileCancel { + controls <- m + return + } + requests <- m + }}) + if err != nil { + t.Fatal(err) + } + call, _ := origin.admit([]string{"read"}, nil) + var admitted wire.Message + select { + case admitted = <-requests: + case <-time.After(5 * time.Second): + t.Fatal("the forwarded request never arrived") + } + if admitted.Return != call.address { + t.Fatal("forwarding did not preserve the original return capability") + } + detach() + rebound := make(chan wire.Message, 4) + if _, err := router.Register([]string{"read"}, wire.Receiver{Message: func(_ []string, m wire.Message) { rebound <- m }}); err != nil { + t.Fatal(err) + } + call.cancel("l:1") + select { + case m := <-controls: + if m.Frame.Kind != wire.ProfileCancel { + t.Fatalf("control was %q", m.Frame.Kind) + } + case <-time.After(5 * time.Second): + t.Fatal("the control did not follow the captured traversal across the forwarder") + } + select { + case <-rebound: + t.Fatal("the control reached the rebound registration") + default: + } +} + +// TestAQueuedControlCannotReachAReusedIdentity is the first identity-reuse +// race: a control already queued against one invocation keeps that invocation, +// so a later invocation reusing the same textual identifier is untouched. +func TestAQueuedControlCannotReachAReusedIdentity(t *testing.T) { + endpoint := newLedgerEndpoint(8, 8) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + seen := make(chan string, 8) + if _, err := router.Register([]string{"read"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + seen <- string(m.Frame.Kind) + ":" + m.Frame.ID + }}); err != nil { + t.Fatal(err) + } + first, _ := endpoint.admit([]string{"read"}, nil) + if got := <-seen; got != "request:l:1" { + t.Fatalf("first request: %s", got) + } + // The first invocation settles and retires before its old control is + // released. Its control ticket is the invocation itself, not a key. + first.settle() + stale := first.address + second, _ := endpoint.admit([]string{"read"}, nil) + if got := <-seen; got != "request:l:2" { + t.Fatalf("second request: %s", got) + } + // The stale control names the first invocation's identifier and travels on + // the first invocation's own return capability. It reaches nothing. + _ = stale.Wire.Send([]string{core.InvocationControl}, wire.Message{ + Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: "l:1"}, + Return: stale, + }) + select { + case got := <-seen: + t.Fatalf("a stale control was delivered: %s", got) + case <-time.After(200 * time.Millisecond): + } + second.cancel("l:2") + select { + case got := <-seen: + if got != "cancel:l:2" { + t.Fatalf("live control: %s", got) + } + case <-time.After(5 * time.Second): + t.Fatal("the live invocation's control never arrived") + } +} + +// TestAResponseRacingAQueuedControlRetiresOnce drives a response and a control +// at one invocation concurrently, many times, under the race detector. +func TestAResponseRacingAQueuedControlRetiresOnce(t *testing.T) { + endpoint := newLedgerEndpoint(8, 8) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + if _, err := dispatch.Handle(router, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + return "answer", nil + }); err != nil { + t.Fatal(err) + } + for i := range 64 { + call, outcome := endpoint.admit([]string{"read"}, nil) + var wait sync.WaitGroup + wait.Add(1) + go func() { defer wait.Done(); call.cancel(fmt.Sprintf("l:%d", i+1)) }() + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + wait.Wait() + } + for range 500 { + if endpoint.retirements.Load() == 64 { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("retirements after 64 raced completions: %d", endpoint.retirements.Load()) +} + +// TestCaptureBoundRefusesRatherThanGrowing checks the refusal a bounded +// integration gives, and that the dispatcher answers it instead of routing. +func TestCaptureBoundRefusesRatherThanGrowing(t *testing.T) { + endpoint := newLedgerEndpoint(1, 8) + outer, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + inner, err := dispatch.NewDispatcher(outer.Select([]string{"a"})) + if err != nil { + t.Fatal(err) + } + delivered := make(chan struct{}, 1) + if _, err := inner.Register([]string{"read"}, wire.Receiver{Message: func([]string, wire.Message) { delivered <- struct{}{} }}); err != nil { + t.Fatal(err) + } + _, outcome := endpoint.admit([]string{"a", "read"}, nil) + select { + case answer := <-outcome: + // The refusal is this integration's own: a dispatcher reports busy for + // a bound it can recognize as one, and invalid_message for a refusal + // whose reason a facility did not spell in the agreed vocabulary. + if answer.Frame.Error == nil || answer.Frame.Error.Code != "invalid_message" { + t.Fatalf("a traversal past the capture bound answered %+v", answer.Frame.Error) + } + case <-time.After(5 * time.Second): + t.Fatal("no refusal") + } + select { + case <-delivered: + t.Fatal("the inner receiver was reached past the bound") + default: + } + if endpoint.refusals.Load() == 0 { + t.Fatal("the bound was never exercised") + } +} diff --git a/core/go/invocation_test.go b/core/go/invocation_test.go new file mode 100644 index 0000000..2841caf --- /dev/null +++ b/core/go/invocation_test.go @@ -0,0 +1,523 @@ +package core_test + +// Ported from Nightseam v0.6.0 runtime/go/invocation_test.go (commit 5cc9723a). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// invocationEndpoint is an endpoint written against the public contract alone. +// It admits requests, answers the invocation vocabulary out of its own ledger, +// and imports nothing of bitruntime's but the vocabulary's paths. It is one of +// the two independent integrations nightseam#439 requires. +type invocationEndpoint struct { + mu sync.Mutex + receiver *wire.Receiver + closed bool + limits core.InvocationLimits + admitted map[string]*core.Invocation + returns map[string]*wire.ReturnAddress + outcomes map[string]chan wire.Message + retirements atomic.Int64 + next atomic.Uint64 +} + +func newInvocationEndpoint(limits core.InvocationLimits) *invocationEndpoint { + return &invocationEndpoint{limits: limits, admitted: map[string]*core.Invocation{}, + returns: map[string]*wire.ReturnAddress{}, outcomes: map[string]chan wire.Message{}} +} + +// invocationReturn is this endpoint's return capability. The empty path is the +// outcome; every other path is the invocation's own vocabulary. +type invocationReturn struct { + owner *invocationEndpoint + identifier string + invocation *core.Invocation +} + +func (r *invocationReturn) Send(path []string, message wire.Message) error { + if len(path) != 0 { + return r.invocation.Deliver(path, message) + } + if message.Frame.Kind != wire.ProfileResponse { + return errors.New("invalid outcome") + } + r.owner.mu.Lock() + outcome := r.owner.outcomes[r.identifier] + r.owner.mu.Unlock() + if outcome != nil { + select { + case outcome <- message: + default: + } + } + r.invocation.Settle() + return nil +} + +// Send loops back into this endpoint's own attachment, asynchronously, so a +// composition above it can be traversed more than once in one invocation +// without running destination code on the sender's stack. +func (e *invocationEndpoint) Send(path []string, message wire.Message) error { + go e.deliver(path, message) + return nil +} + +func (e *invocationEndpoint) Receive(receiver wire.Receiver) (func(), error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil, transports.ErrClosed + } + if e.receiver != nil { + return nil, core.ErrReceiverExists + } + held := receiver + e.receiver = &held + return func() { + e.mu.Lock() + if e.receiver == &held { + e.receiver = nil + } + e.mu.Unlock() + }, nil +} + +func (e *invocationEndpoint) Close(code wire.Code, reason string) error { + e.mu.Lock() + if e.closed { + e.mu.Unlock() + return nil + } + e.closed = true + receiver := e.receiver + e.receiver = nil + e.mu.Unlock() + if receiver != nil && receiver.Closed != nil { + receiver.Closed(code, reason) + } + return nil +} + +// admit delivers one request through the attached receiver with a fresh +// invocation, and returns the channel its outcome arrives on. +func (e *invocationEndpoint) admit(path []string, params json.RawMessage) (string, chan wire.Message) { + identifier := fmt.Sprintf("x:%d", e.next.Add(1)) + outcome := make(chan wire.Message, 1) + invocation := core.NewInvocation(e.limits, func() { e.retirements.Add(1) }) + address := &wire.ReturnAddress{Wire: &invocationReturn{owner: e, identifier: identifier, invocation: invocation}} + e.mu.Lock() + e.admitted[identifier] = invocation + e.returns[identifier] = address + e.outcomes[identifier] = outcome + receiver := e.receiver + e.mu.Unlock() + message := wire.Message{ + Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: identifier, Params: params}, + Return: address, + } + if receiver != nil && receiver.Message != nil { + receiver.Message(path, message) + } + invocation.DispatchDone() + return identifier, outcome +} + +// deliver hands a message to the attachment exactly as it arrived, without +// admitting an invocation of this endpoint's own. +func (e *invocationEndpoint) deliver(path []string, message wire.Message) { + e.mu.Lock() + receiver := e.receiver + e.mu.Unlock() + if receiver != nil && receiver.Message != nil { + receiver.Message(path, message) + } +} + +func (e *invocationEndpoint) cancel(identifier string) { + e.mu.Lock() + invocation, address := e.admitted[identifier], e.returns[identifier] + e.mu.Unlock() + if invocation == nil { + return + } + _ = invocation.Deliver([]string{core.InvocationControl}, wire.Message{ + Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileCancel, ID: identifier}, + Return: address, + }) +} + +func (e *invocationEndpoint) invocation(identifier string) *core.Invocation { + e.mu.Lock() + defer e.mu.Unlock() + return e.admitted[identifier] +} + +// opaqueEndpoint wraps another endpoint with nothing but the contract. It +// passes the complete message, its return capability and its relative path +// through, and recognizes no concrete type on either side. +type opaqueEndpoint struct{ inner wire.Endpoint } + +func (o opaqueEndpoint) Send(path []string, message wire.Message) error { + return o.inner.Send(path, message) +} +func (o opaqueEndpoint) Receive(receiver wire.Receiver) (func(), error) { + return o.inner.Receive(receiver) +} +func (o opaqueEndpoint) Close(code wire.Code, reason string) error { + return o.inner.Close(code, reason) +} + +func TestIndependentEndpointParticipatesThroughThePublicVocabulary(t *testing.T) { + endpoint := newInvocationEndpoint(core.DefaultInvocationLimits()) + router, err := dispatch.NewDispatcher(opaqueEndpoint{endpoint}) + if err != nil { + t.Fatal(err) + } + started, release := make(chan struct{}, 1), make(chan struct{}) + cancelled := make(chan error, 1) + if _, err := dispatch.Handle(router, []string{"read"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + started <- struct{}{} + <-release + cancelled <- ctx.Err() + return "answer", nil + }); err != nil { + t.Fatal(err) + } + identifier, outcome := endpoint.admit([]string{"read"}, nil) + <-started + invocation := endpoint.invocation(identifier) + if invocation.Retired() { + t.Fatal("an invocation retired while its body was still running") + } + endpoint.cancel(identifier) + close(release) + if err := <-cancelled; err == nil { + t.Fatal("cancellation did not reach the captured traversal") + } + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + waitRetired(t, invocation) + if endpoint.retirements.Load() != 1 { + t.Fatalf("retirements: %d", endpoint.retirements.Load()) + } +} + +func TestCapturedTraversalSurvivesDetachAndRebind(t *testing.T) { + endpoint := newInvocationEndpoint(core.DefaultInvocationLimits()) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + first, second := make(chan wire.Message, 4), make(chan wire.Message, 4) + detach, err := router.Register([]string{"read"}, wire.Receiver{Message: func(_ []string, m wire.Message) { first <- m }}) + if err != nil { + t.Fatal(err) + } + identifier, _ := endpoint.admit([]string{"read"}, nil) + if got := (<-first).Frame.Kind; got != wire.ProfileRequest { + t.Fatalf("first receiver saw %q", got) + } + detach() + if _, err := router.Register([]string{"read"}, wire.Receiver{Message: func(_ []string, m wire.Message) { second <- m }}); err != nil { + t.Fatal(err) + } + endpoint.cancel(identifier) + select { + case m := <-first: + if m.Frame.Kind != wire.ProfileCancel || m.Frame.ID != identifier { + t.Fatalf("captured receiver got %q %q", m.Frame.Kind, m.Frame.ID) + } + case <-time.After(5 * time.Second): + t.Fatal("cancellation did not reach the receiver that was captured") + } + select { + case <-second: + t.Fatal("cancellation reached the rebound receiver") + default: + } +} + +func TestEachTraversalOfOneDispatcherCapturesSeparately(t *testing.T) { + endpoint := newInvocationEndpoint(core.DefaultInvocationLimits()) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + // One dispatcher visited twice in one traversal: its outer route forwards + // back into its own inner route. Each visit is a capture of its own. + seen := make(chan string, 8) + if _, err := router.Register([]string{"outer"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + if m.Frame.Kind == wire.ProfileRequest { + go func() { _ = router.Send([]string{"inner"}, m) }() + } + seen <- "outer:" + string(m.Frame.Kind) + }}); err != nil { + t.Fatal(err) + } + if _, err := router.Register([]string{"inner"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + seen <- "inner:" + string(m.Frame.Kind) + }}); err != nil { + t.Fatal(err) + } + identifier, _ := endpoint.admit([]string{"outer"}, nil) + collect(t, seen, 2, map[string]bool{"outer:request": true, "inner:request": true}) + endpoint.cancel(identifier) + collect(t, seen, 2, map[string]bool{"outer:cancel": true, "inner:cancel": true}) +} + +func TestLatchedCancellationReachesACaptureInstalledAfterIt(t *testing.T) { + endpoint := newInvocationEndpoint(core.DefaultInvocationLimits()) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + inner, err := dispatch.NewDispatcher(router.Select([]string{"a"})) + if err != nil { + t.Fatal(err) + } + controls := make(chan wire.Message, 4) + var identifier atomic.Value + identifier.Store("") + // The outer receiver cancels the invocation before the inner capture is + // installed. The latch is what carries the control to the later capture. + if _, err := inner.Register([]string{"read"}, wire.Receiver{Message: func(_ []string, m wire.Message) { + if m.Frame.Kind == wire.ProfileRequest { + endpoint.cancel(m.Frame.ID) + return + } + controls <- m + }}); err != nil { + t.Fatal(err) + } + endpoint.admit([]string{"a", "read"}, nil) + _ = identifier + select { + case m := <-controls: + if m.Frame.Kind != wire.ProfileCancel { + t.Fatalf("latched control was %q", m.Frame.Kind) + } + case <-time.After(5 * time.Second): + t.Fatal("a capture installed while cancellation was latched never received it") + } +} + +func TestInvocationBoundsCapturesAndBodies(t *testing.T) { + endpoint := newInvocationEndpoint(core.InvocationLimits{Captures: 2, Bodies: 1}) + identifier, _ := endpoint.admit([]string{"read"}, nil) + invocation := endpoint.invocation(identifier) + message := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: identifier}, + Return: &wire.ReturnAddress{Wire: &invocationReturn{owner: endpoint, identifier: identifier, invocation: invocation}}} + for i := range 2 { + if _, err := core.CaptureInvocation(message, func(wire.Message) {}); err != nil { + t.Fatalf("capture %d refused: %v", i, err) + } + } + if _, err := core.CaptureInvocation(message, func(wire.Message) {}); !errors.Is(err, core.ErrInvocationLimit) { + t.Fatalf("capture beyond the bound: %v", err) + } + body, err := core.BeginInvocationBody(message) + if err != nil { + t.Fatal(err) + } + if _, err := core.BeginInvocationBody(message); !errors.Is(err, core.ErrInvocationLimit) { + t.Fatalf("body beyond the bound: %v", err) + } + // A released capture gives back no slot: the bound is a total, so neither + // depth nor shallow fan-out can grow what one invocation retains. + body.Done() + if _, err := core.BeginInvocationBody(message); !errors.Is(err, core.ErrInvocationLimit) { + t.Fatalf("a finished body returned its slot: %v", err) + } +} + +func TestRetirementWaitsForTheBodyAndTheControlDrain(t *testing.T) { + endpoint := newInvocationEndpoint(core.DefaultInvocationLimits()) + identifier, _ := endpoint.admit([]string{"read"}, nil) + invocation := endpoint.invocation(identifier) + message := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: identifier}, + Return: &wire.ReturnAddress{Wire: &invocationReturn{owner: endpoint, identifier: identifier, invocation: invocation}}} + capture, err := core.CaptureInvocation(message, func(wire.Message) {}) + if err != nil { + t.Fatal(err) + } + body, err := core.BeginInvocationBody(message) + if err != nil { + t.Fatal(err) + } + invocation.Settle() + if invocation.Retired() { + t.Fatal("retired with an undelivered capture and a running body") + } + capture.Ready() + if invocation.Retired() { + t.Fatal("retired while the body was still running") + } + body.Done() + if !invocation.Retired() { + t.Fatal("did not retire once settled with nothing outstanding") + } + if _, err := core.CaptureInvocation(message, func(wire.Message) {}); !errors.Is(err, core.ErrInvocationEnded) { + t.Fatalf("a retired invocation admitted a capture: %v", err) + } + if _, err := core.BeginInvocationBody(message); !errors.Is(err, core.ErrInvocationEnded) { + t.Fatalf("a retired invocation admitted a body: %v", err) + } +} + +func TestSequentialCompletionsBeyondCapacityRetainNothing(t *testing.T) { + endpoint := newInvocationEndpoint(core.InvocationLimits{Captures: 2, Bodies: 2}) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + if _, err := dispatch.Handle(router, []string{"read"}, func(context.Context, json.RawMessage) (any, error) { return "answer", nil }); err != nil { + t.Fatal(err) + } + var invocations []*core.Invocation + for range 32 { + identifier, outcome := endpoint.admit([]string{"read"}, nil) + select { + case <-outcome: + case <-time.After(5 * time.Second): + t.Fatal("no outcome") + } + invocations = append(invocations, endpoint.invocation(identifier)) + } + for i, invocation := range invocations { + waitRetired(t, invocation) + if i == 0 { + continue + } + } + if got := endpoint.retirements.Load(); got != 32 { + t.Fatalf("retirements after 32 sequential completions: %d", got) + } +} + +// A bound the runtime's own facility keeps is a busy refusal: something to +// try again at, not a request that was malformed. +func TestATraversalPastTheCaptureBoundIsRefusedAsBusy(t *testing.T) { + endpoint := newInvocationEndpoint(core.InvocationLimits{Captures: 1, Bodies: 8}) + outer, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + inner, err := dispatch.NewDispatcher(outer.Select([]string{"a"})) + if err != nil { + t.Fatal(err) + } + delivered := make(chan struct{}, 1) + if _, err := inner.Register([]string{"read"}, wire.Receiver{Message: func([]string, wire.Message) { delivered <- struct{}{} }}); err != nil { + t.Fatal(err) + } + _, outcome := endpoint.admit([]string{"a", "read"}, nil) + select { + case answer := <-outcome: + if answer.Frame.Error == nil || answer.Frame.Error.Code != "busy" { + t.Fatalf("a traversal past the bound answered %+v", answer.Frame.Error) + } + case <-time.After(5 * time.Second): + t.Fatal("no refusal") + } + select { + case <-delivered: + t.Fatal("the inner receiver was reached past the bound") + default: + } +} + +func TestADispatcherRefusesAnInvocationWithoutALifecycle(t *testing.T) { + endpoint := newInvocationEndpoint(core.DefaultInvocationLimits()) + router, err := dispatch.NewDispatcher(endpoint) + if err != nil { + t.Fatal(err) + } + delivered := make(chan struct{}, 1) + if _, err := router.Register([]string{"read"}, wire.Receiver{Message: func([]string, wire.Message) { delivered <- struct{}{} }}); err != nil { + t.Fatal(err) + } + answered := make(chan wire.Message, 1) + bare := &bareReturn{answer: func(m wire.Message) { answered <- m }} + // A request arrives through the contract alone, with a return capability + // that carries no lifecycle. It is refused explicitly, on its own original + // return capability, rather than routed with weaker guarantees. + endpoint.deliver([]string{"read"}, wire.Message{ + Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "b:1", Params: []byte("null")}, + Return: &wire.ReturnAddress{Wire: bare}, + }) + select { + case refusal := <-answered: + if refusal.Frame.Error == nil || refusal.Frame.Error.Code != "invalid_message" { + t.Fatalf("refusal was %+v", refusal.Frame.Error) + } + case <-time.After(5 * time.Second): + t.Fatal("an unmanaged invocation was neither routed nor refused") + } + select { + case <-delivered: + t.Fatal("an unmanaged invocation was routed with weaker guarantees") + default: + } + if bare.uses.Load() != 1 { + t.Fatalf("the refusal did not use the original return capability once: %d", bare.uses.Load()) + } +} + +type bareReturn struct { + answer func(wire.Message) + uses atomic.Int64 +} + +func (b *bareReturn) Send(path []string, message wire.Message) error { + if len(path) != 0 { + return errors.New("this return capability carries no lifecycle") + } + b.uses.Add(1) + b.answer(message) + return nil +} + +func waitRetired(t *testing.T, invocation *core.Invocation) { + t.Helper() + for range 500 { + if invocation.Retired() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("invocation never retired") +} + +func collect(t *testing.T, seen chan string, count int, want map[string]bool) { + t.Helper() + got := map[string]bool{} + for range count { + select { + case value := <-seen: + got[value] = true + case <-time.After(5 * time.Second): + t.Fatalf("saw %v, wanted %v", got, want) + } + } + for value := range want { + if !got[value] { + t.Fatalf("saw %v, wanted %v", got, want) + } + } +} From 1a0dd08f34cf46d8a3954ccb528ac4e74201b73f Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:02:46 +0200 Subject: [PATCH 19/39] core: port the v0.6.0 publication and trace tests through the root Port runtime/go/publication_test.go and trace_test.go from Nightseam v0.6.0 (5cc9723a). The raw peer API they used is removed (R20), so handlers are attached with a dispatcher in engine.Options.Prepare and calls and events go through dispatch.Call and dispatch.Emit on peer.Wire(). Frames a test writes or reads name the canonical path encoding ("5:outer"). Changed, with the reason in each test: - TestQueuedWriteFailureHasNoUnpublishedProof and TestReadFailureHasNoNestedUnpublishedProof: a call through the root is answered with a public error, so the transport cause (and the nested context.Canceled) is asserted on Peer.Err, which keeps it (R26); the no-proof assertions stay on the call and are added for Peer.Err. - TestACustomPropagatorSeesTheMembersVerbatim: the outgoing trace is injected by the call's CallOptions.Propagator, as v0.6.0's root did. - TestRefusedReverseReplyCannotProveDeliveredCallUnpublished runs with a 250ms client RequestTimeout: the root refuses the oversized reply and its fallback at the return capability and leaves the request to its deadline. Two v0.6.0 assertions the root Endpoint does not meet are kept, skipped, in tests of their own: TestCallerBoundRefusalIsUnpublished (the pending bound's busy refusal is not unpublished through the root) and TestOversizedReverseReplyFallbackEndsTheCarrier (the carrier does not end). Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/publication_test.go | 370 ++++++++++++++++++++++++++++++++++++ core/go/trace_test.go | 247 ++++++++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 core/go/publication_test.go create mode 100644 core/go/trace_test.go diff --git a/core/go/publication_test.go b/core/go/publication_test.go new file mode 100644 index 0000000..5076bed --- /dev/null +++ b/core/go/publication_test.go @@ -0,0 +1,370 @@ +package core_test + +// Ported from Nightseam v0.6.0 runtime/go/publication_test.go (commit +// 5cc9723a). v0.6.0 served handlers by method name (Options.Handlers) and +// called through the peer's raw Call and Emit; bitruntime presents the +// protocol only through the peer's root Endpoint (research R20), so handlers +// are attached with a dispatcher in Options.Prepare and calls go through +// dispatch.Call and dispatch.Emit on peer.Wire(). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + endpoint "github.com/Bitspark/bitruntime/engine/websocket/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// serving is the Prepare that attaches a dispatcher to the peer's root and +// serves each handler group at the one-segment path of its name: the root +// Endpoint's counterpart of v0.6.0's Options.Handlers and Options.Events. +func serving(handlers func(peer *engine.Peer) map[string]dispatch.Handlers) func(*engine.Peer) error { + return func(peer *engine.Peer) error { + router, err := dispatch.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + for name, group := range handlers(peer) { + if _, err := dispatch.Register(router, []string{name}, group); err != nil { + return err + } + } + return nil + } +} + +// newPeerPair connects a client peer to a server peer over a WebSocket. +func newPeerPair(t *testing.T, serverOptions, clientOptions engine.Options) (*engine.Peer, *engine.Peer) { + t.Helper() + connected := make(chan *engine.Peer, 1) + handler, err := endpoint.NewHandler(endpoint.ServerOptions{ + Options: serverOptions, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *engine.Peer) { connected <- peer }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + client, _, err := endpoint.Dial(ctx, server.URL, endpoint.DialOptions{Options: clientOptions}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + remote := receive(t, connected) + t.Cleanup(func() { _ = remote.Close() }) + return client, remote +} + +// rawPeer is one peer reached over a raw connection, so a test spells the +// frames it sends byte for byte rather than letting a peer encode them. +func rawPeer(t *testing.T, options engine.Options) (*engine.Peer, *websocket.Conn, context.Context) { + t.Helper() + connected := make(chan *engine.Peer, 1) + handler, err := endpoint.NewHandler(endpoint.ServerOptions{ + Options: options, + Authenticate: func(r *http.Request) (context.Context, error) { return r.Context(), nil }, + CheckOrigin: func(*http.Request) bool { return true }, + OnConnect: func(peer *engine.Peer) { connected <- peer }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + conn, _, err := websocket.Dial(ctx, server.URL, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.CloseNow() }) + return receive(t, connected), conn, ctx +} + +func readFrame(ctx context.Context, t *testing.T, conn *websocket.Conn) map[string]json.RawMessage { + t.Helper() + kind, data, err := conn.Read(ctx) + if err != nil || kind != websocket.MessageText { + t.Fatalf("read frame: kind=%v error=%v", kind, err) + } + var members map[string]json.RawMessage + if err := json.Unmarshal(data, &members); err != nil { + t.Fatalf("decode frame %s: %v", data, err) + } + return members +} + +func wantUnpublished(t *testing.T, err error, want bool) { + t.Helper() + var unpublished *core.UnpublishedError + if got := errors.As(err, &unpublished); got != want || err == nil { + t.Fatalf("publication proof: %v, want unpublished=%v", err, want) + } +} + +// proofServer serves the operations TestUnpublishedProofIsLocalToTheSendAttempt +// calls. +func proofServer(entered chan<- struct{}, finish <-chan struct{}) func(*engine.Peer) map[string]dispatch.Handlers { + return func(peer *engine.Peer) map[string]dispatch.Handlers { + return map[string]dispatch.Handlers{ + "wait": {Request: func(c context.Context, _ json.RawMessage) (any, error) { + entered <- struct{}{} + select { + case <-finish: + return nil, nil + case <-c.Done(): + return nil, c.Err() + } + }}, + "busy": {Request: func(context.Context, json.RawMessage) (any, error) { + return nil, &core.PublicError{Code: "busy", Message: "retained before refusing"} + }}, + "nested": {Request: func(c context.Context, _ json.RawMessage) (any, error) { + withdrawn, cancel := context.WithCancel(c) + cancel() + return nil, dispatch.Call(withdrawn, peer.Wire(), []string{"never.sent"}, nil, nil) + }}, + } + } +} + +func TestUnpublishedProofIsLocalToTheSendAttempt(t *testing.T) { + entered, finish := make(chan struct{}, 1), make(chan struct{}) + client, server := newPeerPair(t, engine.Options{Prepare: serving(proofServer(entered, finish))}, engine.Options{MaxPendingRequests: 1, MaxFrameBytes: 512}) + _ = server + root := client.Wire() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := dispatch.Call(ctx, root, []string{"wait"}, nil, nil) + wantUnpublished(t, err, true) + if !errors.Is(err, context.Canceled) { + t.Fatalf("wrapping lost cancellation identity: %v", err) + } + wantUnpublished(t, dispatch.Call(context.Background(), root, []string{"wait"}, make(chan int), nil), true) + wantUnpublished(t, dispatch.Call(context.Background(), root, []string{"wait"}, strings.Repeat("x", 1024), nil), true) + wantUnpublished(t, dispatch.Emit(context.Background(), root, []string{"event"}, make(chan int)), true) + wantUnpublished(t, dispatch.Emit(context.Background(), root, []string{"event"}, strings.Repeat("x", 1024)), true) + + done := make(chan error, 1) + go func() { done <- dispatch.Call(context.Background(), root, []string{"wait"}, nil, nil) }() + receive(t, entered) + // The caller's own pending bound still refuses busy. That this refusal + // also proves it unpublished is TestCallerBoundRefusalIsUnpublished's. + err = dispatch.Call(context.Background(), root, []string{"busy"}, nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("wrapping lost public refusal: %v", err) + } + close(finish) + if err := receive(t, done); err != nil { + t.Fatal(err) + } + + // The same public code received from the other side carries no proof. + err = dispatch.Call(context.Background(), root, []string{"busy"}, nil, nil) + wantUnpublished(t, err, false) + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatal(err) + } + // A marker from a nested, definitely-unsent call must not cross the wire + // as proof about the request whose implementation has already run. + err = dispatch.Call(context.Background(), root, []string{"nested"}, nil, nil) + wantUnpublished(t, err, false) + if !errors.As(err, &public) || public.Code != "cancelled" { + t.Fatal(err) + } +} + +// TestCallerBoundRefusalIsUnpublished keeps the one assertion of v0.6.0's +// TestUnpublishedProofIsLocalToTheSendAttempt that the root Endpoint does not +// meet. v0.6.0's raw Peer.Call refused a call past MaxPendingRequests +// synchronously, before anything was queued, as unpublished. That API is +// removed (R20). The root Endpoint admits the request and answers the refusal +// it queued through the request's return capability, as v0.6.0's own root +// did, so the caller receives a public busy that carries no proof. +func TestCallerBoundRefusalIsUnpublished(t *testing.T) { + t.Skip("root Endpoint answers its pending-bound refusal through the return capability, without unpublished proof; v0.6.0 proved it only through the removed Peer.Call (R20)") + entered, finish := make(chan struct{}, 1), make(chan struct{}) + defer close(finish) + client, _ := newPeerPair(t, engine.Options{Prepare: serving(proofServer(entered, finish))}, engine.Options{MaxPendingRequests: 1}) + go func() { _ = dispatch.Call(context.Background(), client.Wire(), []string{"wait"}, nil, nil) }() + receive(t, entered) + err := dispatch.Call(context.Background(), client.Wire(), []string{"busy"}, nil, nil) + wantUnpublished(t, err, true) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("wrapping lost public refusal: %v", err) + } +} + +type publicationWriteFailure struct { + transports.Conn + wrote chan struct{} + err error +} + +func (c *publicationWriteFailure) Send(context.Context, transports.Frame) error { + close(c.wrote) + return c.err +} + +// A call through the root Endpoint is answered through its return capability, +// which carries a public error, never a transport's error value. v0.6.0's raw +// Peer.Call returned the transport cause itself; here the carrier's own ending +// report, Peer.Err, is what keeps it (R26), and the call's answer must carry +// no proof either way. +func TestQueuedWriteFailureHasNoUnpublishedProof(t *testing.T) { + client, _ := newPeerPair(t, engine.Options{}, engine.Options{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + proof := dispatch.Call(ctx, client.Wire(), []string{"unsent"}, nil, nil) + wantUnpublished(t, proof, true) + for _, tc := range []struct { + name string + cause, identity error + }{ + {"plain", errors.New("transport failed after accepting a queued frame"), nil}, + {"nested", fmt.Errorf("adapter send: %w", proof), context.Canceled}, + } { + t.Run(tc.name, func(t *testing.T) { + near, far := transports.Pipe(1 << 20) + defer far.Abort() + cause := tc.cause + connection := &publicationWriteFailure{Conn: near, wrote: make(chan struct{}), err: cause} + peer, err := engine.NewPeer(context.Background(), connection, engine.ClientRole, engine.Options{}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + err = dispatch.Call(context.Background(), peer.Wire(), []string{"supply"}, nil, nil) + receive(t, connection.wrote) + wantUnpublished(t, err, false) + ended := peer.Err() + wantUnpublished(t, ended, false) + if !errors.Is(ended, cause) { + t.Fatalf("transport cause lost: %v", ended) + } + if tc.identity != nil && !errors.Is(ended, tc.identity) { + t.Fatalf("nested cause lost: %v", ended) + } + }) + } +} + +type publicationReadFailure struct { + transports.Conn + wrote chan struct{} + err error +} + +func (c *publicationReadFailure) Send(context.Context, transports.Frame) error { + close(c.wrote) + return nil +} +func (c *publicationReadFailure) Receive(context.Context) (transports.Frame, error) { + <-c.wrote + return transports.Frame{}, c.err +} + +// As in TestQueuedWriteFailureHasNoUnpublishedProof, the nested cause is kept +// by the carrier's ending report rather than by the call's public answer. +func TestReadFailureHasNoNestedUnpublishedProof(t *testing.T) { + client, _ := newPeerPair(t, engine.Options{}, engine.Options{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + proof := dispatch.Call(ctx, client.Wire(), []string{"unsent"}, nil, nil) + near, far := transports.Pipe(1 << 20) + defer far.Abort() + connection := &publicationReadFailure{Conn: near, wrote: make(chan struct{}), err: fmt.Errorf("adapter receive: %w", proof)} + peer, err := engine.NewPeer(context.Background(), connection, engine.ClientRole, engine.Options{}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + err = dispatch.Call(context.Background(), peer.Wire(), []string{"supply"}, nil, nil) + wantUnpublished(t, err, false) + ended := peer.Err() + wantUnpublished(t, ended, false) + if !errors.Is(ended, context.Canceled) { + t.Fatalf("nested cause lost: %v", ended) + } +} + +// reverseReplyServers is the pair TestRefusedReverseReplyCannotProveDeliveredCallUnpublished +// runs: the server's "a" calls the client's "b", whose reply exceeds the +// client's frame limit. +func reverseReplyServers(delivered *atomic.Bool) (engine.Options, engine.Options) { + server := engine.Options{Prepare: serving(func(peer *engine.Peer) map[string]dispatch.Handlers { + return map[string]dispatch.Handlers{"a": {Request: func(ctx context.Context, _ json.RawMessage) (any, error) { + delivered.Store(true) + return nil, dispatch.Call(ctx, peer.Wire(), []string{"b"}, nil, nil) + }}} + })} + client := engine.Options{MaxFrameBytes: 180, Prepare: serving(func(*engine.Peer) map[string]dispatch.Handlers { + return map[string]dispatch.Handlers{"b": {Request: func(context.Context, json.RawMessage) (any, error) { return strings.Repeat("x", 2000), nil }}} + })} + return server, client +} + +// The client's reply to "b" is refused by the return capability its root +// handed the dispatcher, and so is its bounded fallback, which does not fit +// 180 bytes beside a traceparent; the request then ends at the client's +// RequestTimeout, shortened here so the test does not wait 30 seconds. That +// refusal cannot prove the delivered outer call unpublished. +func TestRefusedReverseReplyCannotProveDeliveredCallUnpublished(t *testing.T) { + var delivered atomic.Bool + serverOptions, clientOptions := reverseReplyServers(&delivered) + clientOptions.RequestTimeout = 250 * time.Millisecond + client, _ := newPeerPair(t, serverOptions, clientOptions) + err := dispatch.Call(context.Background(), client.Wire(), []string{"a"}, nil, nil) + if !delivered.Load() { + t.Fatal("outer request was not delivered") + } + wantUnpublished(t, err, false) +} + +// TestOversizedReverseReplyFallbackEndsTheCarrier keeps the assertion of +// v0.6.0's TestRefusedReverseReplyCannotProveDeliveredCallUnpublished that the +// root Endpoint does not meet: when even the bounded fallback of an oversized +// reply does not fit, v0.6.0's raw handler path failed the connection (the +// "failure broadcast"). Through the root, the reply capability refuses both +// the reply and its fallback (core.Respond, internal/request Reply.Send, as +// v0.6.0's replyWire did), the carrier stays up and the remote caller waits +// for its request deadline. +// +// Unskipped, it ends only when newPeerPair's 10-second dial context ends the +// client, which would satisfy the original assertion for the wrong reason; the +// elapsed-time check refuses that. +func TestOversizedReverseReplyFallbackEndsTheCarrier(t *testing.T) { + t.Skip("root Endpoint refuses an oversized reply and its fallback at the return capability and leaves the request to its deadline; only v0.6.0's removed Options.Handlers path failed the carrier") + var delivered atomic.Bool + serverOptions, clientOptions := reverseReplyServers(&delivered) + client, _ := newPeerPair(t, serverOptions, clientOptions) + started := time.Now() + err := dispatch.Call(context.Background(), client.Wire(), []string{"a"}, nil, nil) + if !delivered.Load() { + t.Fatal("outer request was not delivered") + } + if client.Err() == nil || time.Since(started) > 5*time.Second { + t.Fatalf("oversized reply fallback did not reach the failure broadcast: %v after %v", client.Err(), time.Since(started)) + } + wantUnpublished(t, err, false) +} diff --git a/core/go/trace_test.go b/core/go/trace_test.go new file mode 100644 index 0000000..5426172 --- /dev/null +++ b/core/go/trace_test.go @@ -0,0 +1,247 @@ +package core_test + +// Ported from Nightseam v0.6.0 runtime/go/trace_test.go (commit 5cc9723a). +// Handlers are attached through the peer's root Endpoint (research R20), so a +// method or event name on the wire is the canonical encoding of its path: +// "far" travels as "3:far", "outer" as "5:outer", and so on. + +import ( + "context" + "encoding/json" + "testing" + + "github.com/coder/websocket" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// The trace a test sends and expects to see continued: one traceparent of the +// W3C form and a tracestate the runtime never reads, only forwards. +var carried = core.Trace{Parent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", State: "congo=t61rcWkgMzE"} + +const carriedMembers = `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"congo=t61rcWkgMzE"` + +func frameMember(t *testing.T, members map[string]json.RawMessage, name string) string { + t.Helper() + raw, present := members[name] + if !present { + return "" + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("member %s = %s: %v", name, raw, err) + } + return value +} + +// frameTrace is what one frame carries, read off the wire rather than from the +// peer that wrote it. +func frameTrace(t *testing.T, members map[string]json.RawMessage) core.Trace { + t.Helper() + return core.Trace{Parent: frameMember(t, members, "traceparent"), State: frameMember(t, members, "tracestate")} +} + +// childOf holds a trace to what a child of parent is: the same trace id and +// flags, a span id of its own, and the tracestate it inherited verbatim. +func childOf(t *testing.T, parent, child core.Trace) { + t.Helper() + if len(child.Parent) != 55 { + t.Fatalf("child traceparent %q is not of the W3C form", child.Parent) + } + if child.Parent[:36] != parent.Parent[:36] || child.Parent[52:] != parent.Parent[52:] { + t.Fatalf("child %q is not of the trace %q", child.Parent, parent.Parent) + } + if child.Parent[36:52] == parent.Parent[36:52] { + t.Fatalf("child %q reuses its parent's span id", child.Parent) + } + for _, c := range child.Parent[36:52] { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Fatalf("child span id in %q is not lower-case hexadecimal", child.Parent) + } + } + if child.State != parent.State { + t.Fatalf("child tracestate = %q, want %q verbatim", child.State, parent.State) + } +} + +// TestOutgoingFramesContinueTheContextTrace: a request sent from a context +// carrying a trace carries a child of it, and the cancellation that follows +// carries that request's members rather than a sibling span of them. +func TestOutgoingFramesContinueTheContextTrace(t *testing.T) { + peer, conn, ctx := rawPeer(t, engine.Options{}) + traced, cancel := context.WithCancel(core.DefaultPropagator.Extract(context.Background(), carried)) + t.Cleanup(cancel) + returned := make(chan error, 1) + go func() { returned <- dispatch.Call(traced, peer.Wire(), []string{"far"}, nil, nil) }() + + request := readFrame(ctx, t, conn) + sent := frameTrace(t, request) + childOf(t, carried, sent) + cancel() + cancellation := readFrame(ctx, t, conn) + if string(cancellation["kind"]) != `"cancel"` || string(cancellation["id"]) != string(request["id"]) { + t.Fatalf("frame after the cancelled call = %v", cancellation) + } + if got := frameTrace(t, cancellation); got != sent { + t.Fatalf("cancel carried %+v, not its request's %+v", got, sent) + } + receive(t, returned) +} + +// TestARequestFromABareContextCarriesANewTrace: nothing correlates two calls +// made from a context that carries no trace, and each is nonetheless traced โ€” +// a peer with no propagator configured still says where its frames came from. +func TestARequestFromABareContextCarriesANewTrace(t *testing.T) { + peer, conn, ctx := rawPeer(t, engine.Options{}) + traces := make([]core.Trace, 0, 2) + for range 2 { + go func() { _ = dispatch.Call(context.Background(), peer.Wire(), []string{"far"}, nil, nil) }() + trace := frameTrace(t, readFrame(ctx, t, conn)) + if len(trace.Parent) != 55 || trace.Parent[:3] != "00-" || trace.Parent[52:] != "-01" { + t.Fatalf("a new trace = %q, not a sampled traceparent of version 00", trace.Parent) + } + if trace.State != "" { + t.Fatalf("a new trace carries the tracestate %q of nothing", trace.State) + } + traces = append(traces, trace) + } + if traces[0].Parent[3:35] == traces[1].Parent[3:35] { + t.Fatalf("two calls from bare contexts share the trace id in %q", traces[0].Parent) + } +} + +// TestAHandlerRunsUnderItsRequestsTrace: the trace of an incoming request is in +// the context its handler runs under and readable there; what the handler sends +// is a child of it; the response carries the request's members byte for byte. +// An incoming event's trace reaches its handler the same way. +func TestAHandlerRunsUnderItsRequestsTrace(t *testing.T) { + handlerTraces := make(chan core.Trace, 2) + peer, conn, ctx := rawPeer(t, engine.Options{Prepare: serving(func(peer *engine.Peer) map[string]dispatch.Handlers { + return map[string]dispatch.Handlers{ + "progress": {Event: func(ctx context.Context, _ json.RawMessage) error { + trace, _ := core.TraceOf(ctx) + handlerTraces <- trace + return nil + }}, + "outer": {Request: func(ctx context.Context, _ json.RawMessage) (any, error) { + trace, found := core.TraceOf(ctx) + if !found { + return nil, transports.ErrClosed + } + handlerTraces <- trace + if err := dispatch.Emit(ctx, peer.Wire(), []string{"progress"}, 1); err != nil { + return nil, err + } + var answer string + if err := dispatch.Call(ctx, peer.Wire(), []string{"reverse"}, nil, &answer); err != nil { + return nil, err + } + return answer, nil + }}, + } + })}) + write := func(data string) { + t.Helper() + if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { + t.Fatal(err) + } + } + write(`{"version":1,"kind":"request","id":"c:1","method":"5:outer","params":{},` + carriedMembers + `}`) + if got := receive(t, handlerTraces); got != carried { + t.Fatalf("the handler's context carried %+v, not the request's %+v", got, carried) + } + event := readFrame(ctx, t, conn) + if string(event["event"]) != `"8:progress"` { + t.Fatalf("frame after the traced request = %v", event) + } + emitted := frameTrace(t, event) + childOf(t, carried, emitted) + reverse := readFrame(ctx, t, conn) + if string(reverse["method"]) != `"7:reverse"` { + t.Fatalf("frame after the emitted event = %v", reverse) + } + called := frameTrace(t, reverse) + childOf(t, carried, called) + if called.Parent == emitted.Parent { + t.Fatalf("two frames of one handler share the span id in %q", called.Parent) + } + write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back"}`) + response := readFrame(ctx, t, conn) + if string(response["id"]) != `"c:1"` || string(response["result"]) != `"back"` { + t.Fatalf("response to the traced request = %v", response) + } + if got := frameTrace(t, response); got != carried { + t.Fatalf("the response carried %+v, not its request's %+v", got, carried) + } + write(`{"version":1,"kind":"event","event":"8:progress","data":1,` + carriedMembers + `}`) + if got := receive(t, handlerTraces); got != carried { + t.Fatalf("the event handler's context carried %+v, not the event's %+v", got, carried) + } + if peer.Err() != nil { + t.Fatalf("a traced exchange closed the connection: %v", peer.Err()) + } +} + +// recordingPropagator is a propagator of another making: it records what it is +// asked to extract and dictates what every outgoing frame carries. +type recordingPropagator struct { + extracted chan core.Trace + injects core.Trace +} + +func (p *recordingPropagator) Extract(ctx context.Context, trace core.Trace) context.Context { + p.extracted <- trace + return ctx +} + +func (p *recordingPropagator) Inject(context.Context) core.Trace { return p.injects } + +// TestACustomPropagatorSeesTheMembersVerbatim: the peer neither reads nor +// rewrites what a configured propagator is given or returns โ€” the incoming +// members reach Extract as they arrived, and what Inject returns is what the +// outgoing frame carries. Only a response keeps its request's own members. +// +// An outgoing frame's trace is minted where the frame is: v0.6.0's raw +// Peer.Call injected with the peer's Propagator, while a call through the +// root Endpoint injects with its own CallOptions.Propagator and the root sends +// what it returns verbatim, as v0.6.0's root did. The peer's Propagator is the +// incoming side's. +func TestACustomPropagatorSeesTheMembersVerbatim(t *testing.T) { + propagator := &recordingPropagator{ + extracted: make(chan core.Trace, 2), + injects: core.Trace{Parent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00", State: "rojo=00f067aa0ba902b7"}, + } + peer, conn, ctx := rawPeer(t, engine.Options{ + Propagator: propagator, + Prepare: serving(func(peer *engine.Peer) map[string]dispatch.Handlers { + return map[string]dispatch.Handlers{"outer": {Request: func(ctx context.Context, _ json.RawMessage) (any, error) { + var answer string + return answer, dispatch.Call(ctx, peer.Wire(), []string{"reverse"}, nil, &answer, dispatch.CallOptions{Propagator: propagator}) + }}} + }), + }) + write := func(data string) { + t.Helper() + if err := conn.Write(ctx, websocket.MessageText, []byte(data)); err != nil { + t.Fatal(err) + } + } + write(`{"version":1,"kind":"request","id":"c:1","method":"5:outer","params":{},` + carriedMembers + `}`) + if got := receive(t, propagator.extracted); got != carried { + t.Fatalf("Extract saw %+v, not the members %+v the frame carried", got, carried) + } + reverse := readFrame(ctx, t, conn) + if got := frameTrace(t, reverse); got != propagator.injects { + t.Fatalf("the outgoing request carried %+v, not the %+v Inject returned", got, propagator.injects) + } + write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back"}`) + if got := frameTrace(t, readFrame(ctx, t, conn)); got != carried { + t.Fatalf("the response carried %+v, not its request's %+v", got, carried) + } + if peer.Err() != nil { + t.Fatalf("a custom propagator closed the connection: %v", peer.Err()) + } +} From b88a9d3fa37e41e2087a77ca6a204cc8dea5aa06 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:02:46 +0200 Subject: [PATCH 20/39] core: port the v0.6.0 ForwardWire tests Port the two ForwardWire tests of Nightseam v0.6.0's runtime/go/wire_namespace_test.go (5cc9723a) to core/go/forward_test.go; the rest of that file concerns the dispatcher. TestForwardWirePreservesMessagesAndOwnsOnlyRegistrations asserts the documented forwarding change (research 0001, row 14) in its last section: a refused request is answered busy and forwarding goes on, where v0.6.0 detached both directions. A new test holds a request refused by an ended or overflowed destination to the disconnected answer R26 requires. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/forward_test.go | 205 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 core/go/forward_test.go diff --git a/core/go/forward_test.go b/core/go/forward_test.go new file mode 100644 index 0000000..1b5d4b8 --- /dev/null +++ b/core/go/forward_test.go @@ -0,0 +1,205 @@ +package core_test + +// Ported from Nightseam v0.6.0 runtime/go/wire_namespace_test.go (commit +// 5cc9723a): the ForwardWire tests, which concern core.Forward alone. + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +type wireReplySink struct{ replies chan wire.ProfileFrame } + +// A return capability refuses what it does not implement, as every addressed +// receiver in this profile does; this one carries outcomes and nothing else. +func (s *wireReplySink) Send(path []string, message wire.Message) error { + if len(path) != 0 { + return errors.New("this return capability carries outcomes only") + } + s.replies <- message.Frame + return nil +} + +type forwardRegistrationWire struct { + receiver wire.Receiver + sent []wire.Message + paths [][]string + detached int + closed int + receiveErr error + sendErr error +} + +func (w *forwardRegistrationWire) Send(path []string, message wire.Message) error { + w.paths = append(w.paths, path) + w.sent = append(w.sent, message) + return w.sendErr +} +func (w *forwardRegistrationWire) Receive(receiver wire.Receiver) (func(), error) { + if w.receiveErr != nil { + return nil, w.receiveErr + } + w.receiver = receiver + var once sync.Once + return func() { once.Do(func() { w.detached++ }) }, nil +} +func (w *forwardRegistrationWire) Close(wire.Code, string) error { w.closed++; return nil } + +// The last section asserts research 0001 row 14 rather than v0.6.0: a message +// the destination refuses fails only that message. v0.6.0 answered the refused +// request and then detached both directions (left.detached == 1 and +// right.detached == 1); forwarding now goes on. +func TestForwardWirePreservesMessagesAndOwnsOnlyRegistrations(t *testing.T) { + left, right := &forwardRegistrationWire{}, &forwardRegistrationWire{} + detach, err := core.Forward(left, right) + if err != nil { + t.Fatal(err) + } + returning := &wire.ReturnAddress{Wire: left} + path := []string{"unknown", "a.b", "", "๐Ÿ˜€"} + for _, kind := range []wire.ProfileKind{wire.ProfileRequest, wire.ProfileResponse, wire.ProfileEvent, wire.ProfileCancel} { + message := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: kind, ID: "c:1", Params: json.RawMessage(`{"n":1e3}`)}, Return: returning} + left.receiver.Message(path, message) + right.receiver.Message(path, message) + if !reflect.DeepEqual(left.sent[len(left.sent)-1], message) || !reflect.DeepEqual(right.sent[len(right.sent)-1], message) { + t.Fatalf("%s changed", kind) + } + if left.sent[len(left.sent)-1].Return != returning || right.sent[len(right.sent)-1].Return != returning { + t.Fatal("return capability changed") + } + } + if !reflect.DeepEqual(left.paths[0], path) || !reflect.DeepEqual(right.paths[0], path) { + t.Fatal("forward path changed") + } + left.receiver.Closed(1000, "ended") + detach() + detach() + if left.detached != 1 || right.detached != 1 || left.closed != 0 || right.closed != 0 { + t.Fatalf("lifecycle: left=%+v right=%+v", left, right) + } + left, right = &forwardRegistrationWire{}, &forwardRegistrationWire{receiveErr: errors.New("installation refused")} + if _, err := core.Forward(left, right); err == nil || left.detached != 1 || left.closed != 0 { + t.Fatalf("partial install leaked: %+v %v", left, err) + } + left, right = &forwardRegistrationWire{}, &forwardRegistrationWire{sendErr: core.Unpublished(&core.PublicError{Code: "busy", Message: "Busy"})} + if _, err := core.Forward(left, right); err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan wire.ProfileFrame, 1)} + left.receiver.Message(path, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "c:1"}, Return: &wire.ReturnAddress{Wire: sink}}) + response := receive(t, sink.replies) + if response.Error == nil || response.Error.Code != "busy" || left.detached != 0 || right.detached != 0 || right.closed != 0 { + t.Fatalf("a refused message was not refused alone: %+v %+v %+v", response, left, right) + } + right.sendErr = nil + event := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: json.RawMessage("null")}} + left.receiver.Message(path, event) + if !reflect.DeepEqual(right.sent[len(right.sent)-1], event) { + t.Fatal("forwarding did not go on after a refused message") + } +} + +// R26: a request whose destination refuses it as closed โ€” ended, or ended by +// backpressure โ€” is answered disconnected, not internal. +func TestForwardAnswersARequestRefusedByAnEndedDestinationDisconnected(t *testing.T) { + for name, refusal := range map[string]error{ + "closed": core.Unpublished(transports.ErrClosed), + "backpressure": core.Unpublished(core.Ended(core.ErrBackpressure)), + } { + t.Run(name, func(t *testing.T) { + left, right := &forwardRegistrationWire{}, &forwardRegistrationWire{sendErr: refusal} + if _, err := core.Forward(left, right); err != nil { + t.Fatal(err) + } + sink := &wireReplySink{replies: make(chan wire.ProfileFrame, 1)} + left.receiver.Message([]string{"call"}, wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: "c:1"}, Return: &wire.ReturnAddress{Wire: sink}}) + if response := receive(t, sink.replies); response.Error == nil || response.Error.Code != "disconnected" || response.ID != "c:1" { + t.Fatalf("request refused by an ended destination: %+v", response) + } + }) + } +} + +func TestForwardWireCarriesUnknownPathsAndReverseCallsAcrossPeers(t *testing.T) { + client, middleIn := newPeerPair(t, engine.Options{}, engine.Options{}) + middleOut, server := newPeerPair(t, engine.Options{}, engine.Options{}) + inbound := testBinding(t, core.Mount(map[string]wire.Endpoint{"in": testBinding(t, middleIn.Wire()).Select([]string{"gateway"})})).Select([]string{"in"}) + outbound := testBinding(t, core.Mount(map[string]wire.Endpoint{"out": testBinding(t, middleOut.Wire()).Select([]string{"service"})})).Select([]string{"out"}) + caller := testBinding(t, testBinding(t, client.Wire()).Select([]string{"gateway"})) + implementation := testBinding(t, testBinding(t, server.Wire()).Select([]string{"service"})) + detach, err := core.Forward(inbound, outbound) + if err != nil { + t.Fatal(err) + } + defer detach() + _, err = dispatch.Handle(caller, []string{"reverse", "dynamic"}, func(_ context.Context, value json.RawMessage) (any, error) { return value, nil }) + if err != nil { + t.Fatal(err) + } + _, err = dispatch.Handle(implementation, []string{"arbitrary", "nested", "call"}, func(ctx context.Context, value json.RawMessage) (any, error) { + var result string + if err := dispatch.Call(ctx, implementation, []string{"reverse", "dynamic"}, value, &result); err != nil { + return nil, err + } + return result + " returned", nil + }) + if err != nil { + t.Fatal(err) + } + var result string + if err := dispatch.Call(context.Background(), caller, []string{"arbitrary", "nested", "call"}, "callback", &result); err != nil || result != "callback returned" { + t.Fatalf("forwarded reverse call = %q %v", result, err) + } + events := make(chan string, 2) + _, err = dispatch.Register(implementation, []string{"arbitrary", "nested", "event"}, dispatch.Handlers{Event: func(_ context.Context, value json.RawMessage) error { + var text string + _ = json.Unmarshal(value, &text) + events <- text + return nil + }}) + if err != nil { + t.Fatal(err) + } + if err := dispatch.Emit(context.Background(), caller, []string{"arbitrary", "nested", "event"}, "observed"); err != nil { + t.Fatal(err) + } + if got := receive(t, events); got != "observed" { + t.Fatal(got) + } + started, ended := make(chan struct{}), make(chan struct{}) + _, err = dispatch.Handle(implementation, []string{"arbitrary", "cancel"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(ended) + return nil, ctx.Err() + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- dispatch.Call(ctx, caller, []string{"arbitrary", "cancel"}, nil, nil) }() + receive(t, started) + detach() + cancel() + if err := receive(t, finished); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + receive(t, ended) + for _, peer := range []*engine.Peer{client, middleIn, middleOut, server} { + if err := peer.Err(); err != nil { + t.Fatalf("forward detach closed peer: %v", err) + } + } +} From 35ae910d27fcf0e80199f428af8c03c1c97c1236 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:11:57 +0200 Subject: [PATCH 21/39] request: settle an unencodable bridged reply instead of stranding its caller The ported publication tests showed that a reply to a request the engine admitted, when neither it nor its bounded fallback fit the frame limit, was refused at the reply capability and left the remote caller waiting for its deadline. v0.6.0's raw response path failed the connection instead. The reply now settles as the bounded internal error, which the engine's response path sends or fails the connection over. The test's skip is removed. Also tidy go.mod: coder/websocket and go-json-experiment are direct dependencies. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/publication_test.go | 19 +++++++------------ docs/port-from-nightseam.md | 16 ++++++++++++++++ go.mod | 7 +++---- internal/request/go/request.go | 10 +++++++++- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/core/go/publication_test.go b/core/go/publication_test.go index 5076bed..091489b 100644 --- a/core/go/publication_test.go +++ b/core/go/publication_test.go @@ -342,19 +342,14 @@ func TestRefusedReverseReplyCannotProveDeliveredCallUnpublished(t *testing.T) { } // TestOversizedReverseReplyFallbackEndsTheCarrier keeps the assertion of -// v0.6.0's TestRefusedReverseReplyCannotProveDeliveredCallUnpublished that the -// root Endpoint does not meet: when even the bounded fallback of an oversized -// reply does not fit, v0.6.0's raw handler path failed the connection (the -// "failure broadcast"). Through the root, the reply capability refuses both -// the reply and its fallback (core.Respond, internal/request Reply.Send, as -// v0.6.0's replyWire did), the carrier stays up and the remote caller waits -// for its request deadline. -// -// Unskipped, it ends only when newPeerPair's 10-second dial context ends the -// client, which would satisfy the original assertion for the wrong reason; the -// elapsed-time check refuses that. +// v0.6.0's TestRefusedReverseReplyCannotProveDeliveredCallUnpublished: when even +// the bounded fallback of an oversized reply does not fit, the connection fails +// rather than leaving the remote caller to its deadline. Through the root, the +// reply capability settles an unencodable reply as the bounded internal error, +// and the engine's own response path fails the connection when that does not +// fit either. The elapsed-time check refuses a pass that only the 10-second +// dial context of newPeerPair would produce. func TestOversizedReverseReplyFallbackEndsTheCarrier(t *testing.T) { - t.Skip("root Endpoint refuses an oversized reply and its fallback at the return capability and leaves the request to its deadline; only v0.6.0's removed Options.Handlers path failed the carrier") var delivered atomic.Bool serverOptions, clientOptions := reverseReplyServers(&delivered) client, _ := newPeerPair(t, serverOptions, clientOptions) diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index c031162..598a7f5 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -73,6 +73,11 @@ None changes a `bitwire/1` frame. The peer closes its connection before cancelling its context, so a WebSocket peer ended from another goroutine transmits the code it chose; v0.6.0's order let the far side observe 1006 instead. +- **An unencodable reply never strands its caller.** A reply to a request a + carrier admitted that cannot travel settles as the bounded `internal` error, + and the engine fails its connection when even that does not fit, as v0.6.0's + raw response path did. Through v0.6.0's root the remote caller waited for its + deadline. - **Observe-only close codes (R27).** `transports.Sendable` separates codes that may be sent from 1005, 1006 and 1015. Transports refuse the latter with `ErrUnsendableCode`; a peer asked to close with one aborts instead. @@ -93,6 +98,17 @@ None changes a `bitwire/1` frame. until the engine's observation hooks are designed with Bitwire's received-context revision (charter ยง1). +## Kept as v0.6.0 behaved + +- A request refused at the root's pending bound is answered through its return + capability, so its caller gets `busy` without proof that nothing was + published; v0.6.0's removed `Peer.Call` refused it synchronously. Whether a + synchronous refusal must prove non-publication is research decision 4, still + open in Bitwire. +- A local pair and a peer's root still end their carrier when a bounded queue + overflows. The send whose overflow ended it reports `core.ErrBackpressure` + with the closed classification; later sends report the carrier closed. + ## Not ported in this milestone Live references, tunnels, the framed byte stream `bitwire-stream/1`, the diff --git a/go.mod b/go.mod index 4cf6e6c..0d286a0 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,8 @@ module github.com/Bitspark/bitruntime go 1.26.0 -require github.com/Bitspark/bitwire v0.3.0 - require ( - github.com/coder/websocket v1.8.15 // indirect - github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect + github.com/Bitspark/bitwire v0.3.0 + github.com/coder/websocket v1.8.15 + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 ) diff --git a/internal/request/go/request.go b/internal/request/go/request.go index e196b0b..d1ef479 100644 --- a/internal/request/go/request.go +++ b/internal/request/go/request.go @@ -73,7 +73,15 @@ func (w *Reply) Send(path []string, message wire.Message) error { message.Frame.Error = &copied } if err := profile.Validate("", message.Frame, limit); err != nil { - return err + if w.dispatch == nil { + return err + } + // A request a carrier admitted must not be left waiting: a reply that + // cannot travel settles as the bounded internal error, which the + // carrier sends itself or ends its connection over, as its own + // response path does. + message.Frame.Result = nil + message.Frame.Error = &wire.ProfileError{Code: "internal", Message: "Response could not be encoded"} } r := Result{Value: message.Frame.Result} if f := message.Frame.Error; f != nil { From 02a652a1fa07674a793fdcd391a9767851d058da Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:08:56 +0200 Subject: [PATCH 22/39] engine: port v0.6.0's peer, backpressure, pacing and Unicode tests Port runtime/go/peer_test.go, backpressure_test.go, peer_pacing_test.go and unicode_peer_test.go from Nightseam v0.6.0 (5cc9723a). The raw method-name API is removed, so each handler is a dispatcher route at [name] attached in Prepare, and calls and events go through the root with the dispatch helpers. Pairs run over transports.Pipe; the delayed TCP writes become a gate on the pipe's Send. Hand-written frames name paths canonically ("5:outer"), and a new test holds that a raw method name is answered method_not_found without ending the connection. Dropped: OnEvent's listener and idempotent unsubscribe (raw API), and the pacing of the raw Emit and Call with its observer events; what remains of that rule is held instead: a response waits for a full queue and resumes, and root traffic ends the carrier at once, as v0.6.0's root did. The authentication, origin, subprotocol and dial tests move to engine/websocket/go with the connection setup. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/go/backpressure_test.go | 322 +++++++++++++++++++ engine/go/peer_pacing_test.go | 139 ++++++++ engine/go/peer_test.go | 563 +++++++++++++++++++++++++++++++++ engine/go/unicode_peer_test.go | 43 +++ 4 files changed, 1067 insertions(+) create mode 100644 engine/go/backpressure_test.go create mode 100644 engine/go/peer_pacing_test.go create mode 100644 engine/go/peer_test.go create mode 100644 engine/go/unicode_peer_test.go diff --git a/engine/go/backpressure_test.go b/engine/go/backpressure_test.go new file mode 100644 index 0000000..a45a171 --- /dev/null +++ b/engine/go/backpressure_test.go @@ -0,0 +1,322 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/backpressure_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). v0.6.0 held a peer's writes on a +// delayed TCP connection beneath a WebSocket; here a gate on the pipe's Send +// holds them at the seam, which is where the peer's writer meets its +// transport either way. Producers go through the root with the dispatch +// helpers instead of the removed raw Emit and Call. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// writeGate holds a connection's writes, once enabled, until its gate opens, +// so a producer/transport imbalance is reproducible without depending on how +// much a transport buffers. +type writeGate struct { + enabled atomic.Bool + started chan struct{} + gate <-chan struct{} + once sync.Once +} + +func newWriteGate(gate <-chan struct{}) *writeGate { + return &writeGate{started: make(chan struct{}), gate: gate} +} + +func (g *writeGate) wrap(conn transports.Conn) transports.Conn { + return &gatedConn{Conn: conn, control: g} +} + +type gatedConn struct { + transports.Conn + control *writeGate +} + +func (c *gatedConn) Send(ctx context.Context, frame transports.Frame) error { + if c.control.enabled.Load() { + c.control.once.Do(func() { close(c.control.started) }) + if c.control.gate != nil { + select { + case <-c.control.gate: + case <-ctx.Done(): + return ctx.Err() + } + } + } + return c.Conn.Send(ctx, frame) +} + +// gatedPair is newPair with the server's writes held by control from the +// start, as v0.6.0 enabled its delay once the upgrade was flushed, and the +// client's by clientControl once the test enables it. +func gatedPair(t *testing.T, control *writeGate, serverOptions, clientOptions engine.Options, clientControl ...*writeGate) (client, server *engine.Peer) { + t.Helper() + var wrapServer, wrapClient func(transports.Conn) transports.Conn + if control != nil { + control.enabled.Store(true) + wrapServer = control.wrap + } + if len(clientControl) != 0 { + wrapClient = clientControl[0].wrap + } + return connectPair(t, serverOptions, clientOptions, wrapServer, wrapClient) +} + +func TestOutboundQueueDeliversAcceptedPrefixInOrder(t *testing.T) { + for _, capacity := range []int{2, 8} { + t.Run(fmt.Sprintf("capacity_%d", capacity), func(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + allowWrites := func() { releaseOnce.Do(func() { close(release) }) } + defer allowWrites() + received := make(chan int, capacity+1) + control := newWriteGate(release) + client, server := gatedPair(t, control, serving{Handlers: map[string]handler{ + "echo": func(_ context.Context, _ *engine.Peer, data json.RawMessage) (any, error) { + return data, nil + }, + }}.with(engine.Options{QueueCapacity: capacity, WriteTimeout: 5 * time.Second}), serving{Events: map[string]eventHandler{ + "replay.item": func(_ context.Context, _ *engine.Peer, data json.RawMessage) { + var sequence int + if err := json.Unmarshal(data, &sequence); err != nil { + t.Error(err) + sequence = -1 + } + received <- sequence + }, + }}.with(engine.Options{})) + if err := emit(context.Background(), server, "replay.item", 0); err != nil { + t.Fatal(err) + } + // Hold the transport's current write, then fill precisely the bounded + // handoff. Each successful emit has been accepted without a reader. + receive(t, control.started) + for sequence := 1; sequence <= capacity; sequence++ { + if err := emit(context.Background(), server, "replay.item", sequence); err != nil { + t.Fatalf("accepted prefix item %d: %v", sequence, err) + } + } + allowWrites() + for want := range capacity + 1 { + if got := receive(t, received); got != want { + t.Fatalf("accepted sequence = %d, want %d", got, want) + } + } + var echo string + if err := call(context.Background(), client, "echo", "still connected", &echo); err != nil || echo != "still connected" { + t.Fatalf("echo after accepted prefix = %q, error=%v", echo, err) + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("accepted prefix disconnected peers: client=%v server=%v", client.Err(), server.Err()) + } + }) + } +} + +func TestOutboundQueuePreCancelledSendDoesNotDisconnect(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + allowWrites := func() { releaseOnce.Do(func() { close(release) }) } + defer allowWrites() + control := newWriteGate(release) + received := make(chan int, 4) + client, server := gatedPair(t, control, serving{Handlers: map[string]handler{ + "echo": func(_ context.Context, _ *engine.Peer, data json.RawMessage) (any, error) { return data, nil }, + }}.with(engine.Options{QueueCapacity: 2, WriteTimeout: 5 * time.Second}), serving{Events: map[string]eventHandler{ + "progress": func(_ context.Context, _ *engine.Peer, data json.RawMessage) { + var value int + if err := json.Unmarshal(data, &value); err != nil { + received <- -1 + return + } + received <- value + }, + }}.with(engine.Options{})) + if err := emit(context.Background(), server, "progress", 0); err != nil { + t.Fatal(err) + } + // Hold the first real write until the cancellation assertion is complete. This + // guarantees the two queued frames cannot drain, even on a heavily loaded host. + receive(t, control.started) + for _, value := range []int{1, 2} { + if err := emit(context.Background(), server, "progress", value); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := emit(ctx, server, "progress", 999) + if !errors.Is(err, context.Canceled) { + t.Fatalf("pre-cancelled send = %v, want context canceled", err) + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("unadmitted cancellation disconnected peers: client=%v server=%v", client.Err(), server.Err()) + } + allowWrites() + for want := range 3 { + if got := receive(t, received); got != want { + t.Fatalf("queued event = %d, want %d", got, want) + } + } + if err := emit(context.Background(), server, "progress", 3); err != nil { + t.Fatal(err) + } + if got := receive(t, received); got != 3 { + t.Fatalf("cancelled event was published: received %d before marker 3", got) + } + var echo string + if err := call(context.Background(), client, "echo", "alive", &echo); err != nil || echo != "alive" { + t.Fatalf("echo after cancelled enqueue = %q, error=%v", echo, err) + } +} + +func TestOutboundQueueDoesNotDelayCancellationOfSentCall(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + allowWrites := func() { releaseOnce.Do(func() { close(release) }) } + defer allowWrites() + clientControl := newWriteGate(release) + handlerStarted := make(chan struct{}) + client, server := gatedPair(t, nil, serving{Handlers: map[string]handler{ + "wait": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + close(handlerStarted) + <-ctx.Done() + return nil, ctx.Err() + }, + }}.with(engine.Options{}), engine.Options{QueueCapacity: 2, WriteTimeout: 5 * time.Second}, clientControl) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + returned := make(chan error, 1) + go func() { returned <- call(ctx, client, "wait", nil, nil) }() + // Only introduce congestion after the request has reached its handler. + receive(t, handlerStarted) + clientControl.enabled.Store(true) + if err := emit(context.Background(), client, "progress", 0); err != nil { + t.Fatal(err) + } + receive(t, clientControl.started) + for _, value := range []int{1, 2} { + if err := emit(context.Background(), client, "progress", value); err != nil { + t.Fatal(err) + } + } + cancel() + select { + case err := <-returned: + if !errors.Is(err, context.Canceled) { + t.Fatalf("sent call cancellation = %v, want context canceled", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("caller cancellation waited for space to enqueue the best-effort cancellation frame") + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("call cancellation disconnected peers: client=%v server=%v", client.Err(), server.Err()) + } + // Delivery of the remote cancellation is intentionally not required when the + // output queue is full. Connection cleanup releases the waiting handler. + allowWrites() +} + +// TestInboundEventBurstIsPacedRatherThanDisconnected: a queue filled faster +// than its consumer drains it is a burst, not a stall, and the peer pacing it +// is what tells them apart โ€” the events are delivered, in order, and the +// connection is whole. +func TestInboundEventBurstIsPacedRatherThanDisconnected(t *testing.T) { + release := make(chan struct{}) + delivered := make(chan int, 8) + client, server := newPair(t, engine.Options{}, serving{Events: map[string]eventHandler{ + "progress": func(_ context.Context, _ *engine.Peer, data json.RawMessage) { + <-release + var value int + if err := json.Unmarshal(data, &value); err != nil { + t.Error(err) + return + } + delivered <- value + }, + }}.with(engine.Options{QueueCapacity: 1, WriteTimeout: 5 * time.Second})) + for _, value := range []int{1, 2, 3} { + if err := emit(context.Background(), server, "progress", value); err != nil { + t.Fatal(err) + } + } + // The burst is held while the consumer is busy and drains when it is not. + close(release) + for want := 1; want <= 3; want++ { + if got := receive(t, delivered); got != want { + t.Fatalf("event %d arrived where %d was due", got, want) + } + } + select { + case <-client.Done(): + t.Fatalf("a burst that drained ended the connection: %v", client.Err()) + default: + } +} + +// TestOutstandingCallLimitRefusesWithoutEndingTheConnection: the caller's own +// bound. The call past it is refused busy where it stands โ€” no frame โ€” and the +// connection serves the next call, which is what makes it a refusal and not a +// failure. +func TestOutstandingCallLimitRefusesWithoutEndingTheConnection(t *testing.T) { + started := make(chan struct{}, 4) + client, _ := newPair(t, serving{Handlers: map[string]handler{ + "wait": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + started <- struct{}{} + <-ctx.Done() + return nil, ctx.Err() + }, + "echo": func(_ context.Context, _ *engine.Peer, params json.RawMessage) (any, error) { return params, nil }, + }}.with(engine.Options{}), engine.Options{MaxPendingRequests: 2}) + + ctx, cancel := context.WithCancel(context.Background()) + var waiting sync.WaitGroup + for range 2 { + waiting.Add(1) + go func() { defer waiting.Done(); _ = call(ctx, client, "wait", nil, nil) }() + } + receive(t, started) + receive(t, started) + + err := call(context.Background(), client, "wait", nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("the call past the limit ended with %v, not busy", err) + } + + cancel() + waiting.Wait() + // A withdrawn call returns once its cancellation is queued in the root, and + // keeps its place under the bound until that cancellation drains; v0.6.0's + // raw Call freed it before returning, its root did not. The next call is + // therefore allowed to meet busy while the two drain, and nothing else. + deadline := time.Now().Add(5 * time.Second) + for { + var echoed int + err := call(context.Background(), client, "echo", 7, &echoed) + if errors.As(err, &public) && public.Code == "busy" && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + continue + } + if err != nil || echoed != 7 { + t.Fatalf("the connection did not serve on: %v, %d", err, echoed) + } + break + } + if client.Err() != nil { + t.Fatalf("the refusal ended the connection: %v", client.Err()) + } +} diff --git a/engine/go/peer_pacing_test.go b/engine/go/peer_pacing_test.go new file mode 100644 index 0000000..4bd40cd --- /dev/null +++ b/engine/go/peer_pacing_test.go @@ -0,0 +1,139 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/peer_pacing_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). +// +// v0.6.0 held that the peer's public Emit and Call, finding the outgoing +// queue full, paced for one write deadline: the caller waited, could withdraw +// without ending the carrier, and resumed when the consumer drained; and that +// its observer was told of the pressure. Emit and Call are the removed raw +// API and observers are removed, so those assertions are dropped. What +// remains of the same rule is held here: a response โ€” the one producer the +// peer still paces โ€” waits for a full queue and resumes when it drains; and +// root traffic, which v0.6.0 also handed off immediately, never paces. + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// fullOutput is a server whose writer is held on its first write and whose +// outgoing queue of one is full behind it, and a client that serves items. +func fullOutput(t *testing.T, release <-chan struct{}, writeTimeout time.Duration, server serving) (client, destination *engine.Peer, items <-chan int) { + t.Helper() + control := newWriteGate(release) + prefix := make(chan int, 4) + client, destination = gatedPair(t, control, server.with(engine.Options{QueueCapacity: 1, WriteTimeout: writeTimeout}), serving{Events: map[string]eventHandler{ + "item": func(_ context.Context, _ *engine.Peer, data json.RawMessage) { + var item int + if err := json.Unmarshal(data, &item); err != nil { + t.Error(err) + } + prefix <- item + }, + }}.with(engine.Options{})) + if err := emit(context.Background(), destination, "item", 1); err != nil { + t.Fatal(err) + } + receive(t, control.started) + if err := emit(context.Background(), destination, "item", 2); err != nil { + t.Fatal(err) + } + return client, destination, prefix +} + +func TestAResponseToAFullOutputIsPacedUntilTheConsumerDrains(t *testing.T) { + release := make(chan struct{}) + var once sync.Once + allowWrites := func() { once.Do(func() { close(release) }) } + defer allowWrites() + answering := make(chan struct{}, 1) + client, destination, prefix := fullOutput(t, release, 5*time.Second, serving{Handlers: map[string]handler{ + "echo": func(_ context.Context, _ *engine.Peer, data json.RawMessage) (any, error) { + answering <- struct{}{} + return data, nil + }, + }}) + finished := make(chan error, 1) + go func() { + var result int + err := call(context.Background(), client, "echo", 3, &result) + if err == nil && result != 3 { + t.Errorf("resumed response result = %d, want 3", result) + } + finished <- err + }() + receive(t, answering) + // The response now waits behind the full queue: the carrier is whole and + // the caller unanswered. + time.Sleep(50 * time.Millisecond) + if err := destination.Err(); err != nil { + t.Fatalf("a transiently full queue ended the carrier: %v", err) + } + select { + case err := <-finished: + t.Fatalf("the call returned %v while its response could not be written", err) + default: + } + allowWrites() + if err := receive(t, finished); err != nil { + t.Fatalf("resumed response = %v", err) + } + for want := 1; want <= 2; want++ { + if got := receive(t, prefix); got != want { + t.Fatalf("accepted prefix = %d, want %d", got, want) + } + } + if err := destination.Err(); err != nil { + t.Fatalf("a paced response ended its carrier: %v", err) + } +} + +// Root traffic is handed to the outgoing queue at once: a composition does +// not run at its slowest destination's pace. A full queue ends the carrier +// with ErrBackpressure without waiting for the write deadline, which is set +// far beyond the test's own wait here. +func TestRootTrafficToAFullOutputEndsTheCarrierAtOnce(t *testing.T) { + for _, operation := range []string{"event", "request"} { + t.Run(operation, func(t *testing.T) { + release := make(chan struct{}) + defer close(release) + _, destination, _ := fullOutput(t, release, time.Minute, serving{}) + finished := make(chan error, 1) + go func() { + if operation == "event" { + finished <- emit(context.Background(), destination, "item", 3) + } else { + finished <- call(context.Background(), destination, "echo", 3, nil) + } + }() + receive(t, destination.Done()) + if err := destination.Err(); !errors.Is(err, core.ErrBackpressure) || !errors.Is(err, transports.ErrClosed) { + t.Fatalf("root traffic to a full queue ended the carrier with %v", err) + } + err := receive(t, finished) + if operation == "event" { + // The root took it, or refused it because its own queue was + // still full: either way it is not delivered. + if err != nil && !errors.Is(err, core.ErrBackpressure) { + t.Fatalf("the event was refused with %v", err) + } + return + } + // A request the root admitted is answered disconnected once the + // carrier ends (R26); one it refused at once carries the cause. + var public *core.PublicError + if !(errors.As(err, &public) && public.Code == "disconnected") && !errors.Is(err, core.ErrBackpressure) { + t.Fatalf("the request was answered %v", err) + } + }) + } +} diff --git a/engine/go/peer_test.go b/engine/go/peer_test.go new file mode 100644 index 0000000..6722cf1 --- /dev/null +++ b/engine/go/peer_test.go @@ -0,0 +1,563 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/peer_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). The peer's raw method-name API +// is removed, so every handler is a dispatcher route at the one-segment path +// [name], installed in Prepare, and every call and event goes through the +// peer's root with the dispatch helpers. The tests about connection setup โ€” +// authentication, origin, subprotocols and the dial deadline โ€” live beside the +// WebSocket constructors in engine/websocket/go. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +func receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case value := <-channel: + return value + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for peer activity") + var zero T + return zero + } +} + +// handler and eventHandler are a request body and an event body as v0.6.0's +// tests wrote them, with the peer they run on. +type handler func(ctx context.Context, peer *engine.Peer, params json.RawMessage) (any, error) +type eventHandler func(ctx context.Context, peer *engine.Peer, data json.RawMessage) + +// serving is what a peer serves at its root: each name is the path [name], +// which travels as its canonical encoding, "4:echo" for "echo". +type serving struct { + Handlers map[string]handler + Events map[string]eventHandler +} + +// with is options whose Prepare attaches a dispatcher serving s to the peer's +// root before the peer reads its first frame, after any Prepare of its own. +func (s serving) with(options engine.Options) engine.Options { + prepare := options.Prepare + options.Prepare = func(peer *engine.Peer) error { + if prepare != nil { + if err := prepare(peer); err != nil { + return err + } + } + return s.attach(peer) + } + return options +} + +func (s serving) attach(peer *engine.Peer) error { + if len(s.Handlers) == 0 && len(s.Events) == 0 { + return nil + } + d, err := dispatch.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + names := map[string]dispatch.Handlers{} + for name, h := range s.Handlers { + group := names[name] + group.Request = func(ctx context.Context, params json.RawMessage) (any, error) { return h(ctx, peer, params) } + names[name] = group + } + for name, e := range s.Events { + group := names[name] + group.Event = func(ctx context.Context, data json.RawMessage) error { e(ctx, peer, data); return nil } + names[name] = group + } + for name, group := range names { + if _, err := dispatch.Register(d, []string{name}, group); err != nil { + return err + } + } + return nil +} + +// call and emit address the path [name] through the peer's root. +func call(ctx context.Context, peer *engine.Peer, method string, params, result any) error { + return dispatch.Call(ctx, peer.Wire(), []string{method}, params, result) +} + +func emit(ctx context.Context, peer *engine.Peer, name string, data any) error { + return dispatch.Emit(ctx, peer.Wire(), []string{name}, data) +} + +// newPair is a server and a client peer over an in-memory pipe. +func newPair(t *testing.T, serverOptions, clientOptions engine.Options) (client, server *engine.Peer) { + t.Helper() + return connectPair(t, serverOptions, clientOptions, nil, nil) +} + +// connectPair is newPair with each end of the pipe optionally wrapped. +func connectPair(t *testing.T, serverOptions, clientOptions engine.Options, wrapServer, wrapClient func(transports.Conn) transports.Conn) (client, server *engine.Peer) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + limit := int64(1 << 20) + for _, o := range []engine.Options{serverOptions, clientOptions} { + if o.MaxFrameBytes > limit { + limit = o.MaxFrameBytes + } + } + clientConn, serverConn := transports.Pipe(limit) + if wrapServer != nil { + serverConn = wrapServer(serverConn) + } + if wrapClient != nil { + clientConn = wrapClient(clientConn) + } + server, err := engine.NewPeer(ctx, serverConn, engine.ServerRole, serverOptions) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + client, err = engine.NewPeer(ctx, clientConn, engine.ClientRole, clientOptions) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return client, server +} + +// rawSide is the far end of a pipe a peer owns: the test is the remote peer, +// and spells the frames it sends byte for byte. +type rawSide struct { + t *testing.T + ctx context.Context + conn transports.Conn +} + +// rawPeer is one peer of the given role over a pipe whose other end the test +// holds. +func rawPeer(t *testing.T, role engine.Role, options engine.Options) (*engine.Peer, *rawSide) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + near, far := transports.Pipe(1 << 20) + peer, err := engine.NewPeer(ctx, near, role, options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = peer.Close(); _ = far.Abort() }) + return peer, &rawSide{t: t, ctx: ctx, conn: far} +} + +func (r *rawSide) write(frame string) { + r.t.Helper() + if err := r.conn.Send(r.ctx, transports.Frame{Kind: transports.Text, Data: []byte(frame)}); err != nil { + r.t.Fatalf("write %s: %v", frame, err) + } +} + +// read is the next frame the peer sent, by member. +func (r *rawSide) read() map[string]json.RawMessage { + r.t.Helper() + received, err := r.conn.Receive(r.ctx) + if err != nil || received.Kind != transports.Text { + r.t.Fatalf("read frame: kind=%v error=%v", received.Kind, err) + } + var members map[string]json.RawMessage + if err := json.Unmarshal(received.Data, &members); err != nil { + r.t.Fatalf("decode frame %s: %v", received.Data, err) + } + return members +} + +// readUntil is the first frame the peer sent that matches, passing over the +// frames before it. +func (r *rawSide) readUntil(match func(map[string]json.RawMessage) bool) map[string]json.RawMessage { + r.t.Helper() + for { + if members := r.read(); match(members) { + return members + } + } +} + +// closed is how the connection ended as this side reads it, passing over any +// frame the peer sent before it ended. +func (r *rawSide) closed() *transports.CloseError { + r.t.Helper() + for { + _, err := r.conn.Receive(r.ctx) + if err == nil { + continue + } + var closed *transports.CloseError + if !errors.As(err, &closed) { + r.t.Fatalf("the far side read %v, not a close", err) + } + return closed + } +} + +func member(value string) func(map[string]json.RawMessage) bool { + name, want, _ := strings.Cut(value, "=") + return func(members map[string]json.RawMessage) bool { return string(members[name]) == want } +} + +func TestReverseCallCompletesWhileOriginalRequestIsOutstanding(t *testing.T) { + client, _ := newPair(t, serving{Handlers: map[string]handler{ + "outer": func(ctx context.Context, peer *engine.Peer, _ json.RawMessage) (any, error) { + var response int + if err := call(ctx, peer, "reverse", 6, &response); err != nil { + return nil, err + } + return response + 1, nil + }, + "inner": func(_ context.Context, _ *engine.Peer, data json.RawMessage) (any, error) { + var value int + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return value * 7, nil + }, + }}.with(engine.Options{}), serving{Handlers: map[string]handler{ + "reverse": func(ctx context.Context, peer *engine.Peer, data json.RawMessage) (any, error) { + var response int + err := call(ctx, peer, "inner", data, &response) + return response, err + }, + }}.with(engine.Options{})) + var result int + if err := call(context.Background(), client, "outer", nil, &result); err != nil { + t.Fatal(err) + } + if result != 43 { + t.Fatalf("nested duplex result = %d, want 43", result) + } +} + +// v0.6.0 also held Peer.OnEvent here, a listener beside the handlers, and that +// its unsubscribe was idempotent. OnEvent is the removed raw API; the root has +// one receiver, and those assertions are dropped. +func TestEventsTravelInBothDirections(t *testing.T) { + clientEvents, serverEvents := make(chan string, 2), make(chan string, 2) + into := func(output chan<- string) eventHandler { + return func(_ context.Context, _ *engine.Peer, data json.RawMessage) { output <- string(data) } + } + client, server := newPair(t, + serving{Events: map[string]eventHandler{"progress": into(serverEvents)}}.with(engine.Options{}), + serving{Events: map[string]eventHandler{"progress": into(clientEvents)}}.with(engine.Options{})) + for _, value := range []int{1, 2} { + if err := emit(context.Background(), client, "progress", value); err != nil { + t.Fatal(err) + } + } + if err := emit(context.Background(), server, "progress", "done"); err != nil { + t.Fatal(err) + } + if got := receive(t, serverEvents); got != "1" { + t.Fatalf("first server event = %s", got) + } + if got := receive(t, serverEvents); got != "2" { + t.Fatalf("second server event = %s", got) + } + if got := receive(t, clientEvents); got != `"done"` { + t.Fatalf("client event = %s", got) + } +} + +func TestPublicErrorsArePreservedAndInternalFailuresHidden(t *testing.T) { + client, _ := newPair(t, serving{Handlers: map[string]handler{ + "public": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { + return nil, fmt.Errorf("wrapper: %w", &core.PublicError{Code: "conflict", Message: "Changed", Data: json.RawMessage(`{"revision":3}`)}) + }, + "private": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { + return nil, errors.New("private database password") + }, + "panic": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { + panic("private panic details") + }, + "unencodable": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { + return make(chan int), nil + }, + "ok": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { return true, nil }, + }}.with(engine.Options{}), engine.Options{}) + for _, test := range []struct{ method, code, message string }{ + {"public", "conflict", "Changed"}, + {"private", "internal", "Internal error"}, + {"panic", "internal", "Internal error"}, + {"unencodable", "internal", "Internal error"}, + {"missing", "method_not_found", "Unknown method"}, + } { + t.Run(test.method, func(t *testing.T) { + err := call(context.Background(), client, test.method, nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != test.code || public.Message != test.message { + t.Fatalf("error = %v, want %s: %s", err, test.code, test.message) + } + if test.method == "public" && string(public.Data) != `{"revision":3}` { + t.Fatalf("public error data = %s", public.Data) + } + if strings.Contains(err.Error(), "private") { + t.Fatalf("internal error leaked: %v", err) + } + }) + } + var result bool + if err := call(context.Background(), client, "ok", nil, &result); err != nil || !result { + t.Fatalf("connection did not survive handler failure: result=%v err=%v", result, err) + } +} + +func TestCallerCancellationReachesRemoteHandler(t *testing.T) { + started, cancelled := make(chan struct{}), make(chan error, 1) + client, server := newPair(t, serving{Handlers: map[string]handler{ + "wait": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + cancelled <- ctx.Err() + return nil, ctx.Err() + }, + }}.with(engine.Options{}), engine.Options{}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + returned := make(chan error, 1) + go func() { returned <- call(ctx, client, "wait", nil, nil) }() + receive(t, started) + cancel() + if err := receive(t, returned); !errors.Is(err, context.Canceled) { + t.Fatalf("caller result = %v", err) + } + if err := receive(t, cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("handler context = %v", err) + } + if client.Err() != nil || server.Err() != nil { + t.Fatalf("request cancellation closed connection: client=%v server=%v", client.Err(), server.Err()) + } +} + +func TestSaturationRejectsNewWorkButStillRoutesReverseResponses(t *testing.T) { + reverseStarted, release := make(chan struct{}), make(chan struct{}) + client, _ := newPair(t, serving{Handlers: map[string]handler{ + "outer": func(ctx context.Context, peer *engine.Peer, _ json.RawMessage) (any, error) { + var result string + err := call(ctx, peer, "reverse", nil, &result) + return result, err + }, + "extra": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { return "unexpected", nil }, + }}.with(engine.Options{MaxConcurrentHandlers: 1}), serving{Handlers: map[string]handler{ + "reverse": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + close(reverseStarted) + select { + case <-release: + return "released", nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + }}.with(engine.Options{})) + type callResult struct { + value string + err error + } + returned := make(chan callResult, 1) + go func() { + var value string + err := call(context.Background(), client, "outer", nil, &value) + returned <- callResult{value, err} + }() + receive(t, reverseStarted) + err := call(context.Background(), client, "extra", nil, nil) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "busy" { + t.Fatalf("saturated request returned %v, want busy", err) + } + close(release) + if got := receive(t, returned); got.err != nil || got.value != "released" { + t.Fatalf("pending reverse response was blocked by handler saturation: %+v", got) + } +} + +func TestStalledEventConsumerDisconnects(t *testing.T) { + started := make(chan struct{}) + // A stalled consumer is paced for one write deadline before it is + // disconnected; the deadline is short here so the pacing is not the wait. + client, server := newPair(t, engine.Options{}, serving{Events: map[string]eventHandler{ + "progress": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) { + close(started) + <-ctx.Done() + }, + }}.with(engine.Options{QueueCapacity: 1, WriteTimeout: 200 * time.Millisecond})) + if err := emit(context.Background(), server, "progress", 1); err != nil { + t.Fatal(err) + } + receive(t, started) + for _, value := range []int{2, 3} { + if err := emit(context.Background(), server, "progress", value); err != nil { + t.Fatal(err) + } + } + receive(t, client.Done()) + if !errors.Is(client.Err(), core.ErrBackpressure) { + t.Fatalf("stalled event consumer error = %v", client.Err()) + } + // R26: an ended carrier is a closed one, whatever its cause. + if !errors.Is(client.Err(), transports.ErrClosed) { + t.Fatalf("stalled event consumer error %v is not a closed carrier", client.Err()) + } + receive(t, server.Done()) +} + +func TestDisconnectCancelsHandlersAndRejectsPendingCalls(t *testing.T) { + started, stopped := make(chan struct{}), make(chan struct{}) + client, server := newPair(t, serving{Handlers: map[string]handler{ + "wait": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(stopped) + return nil, ctx.Err() + }, + }}.with(engine.Options{}), engine.Options{}) + returned := make(chan error, 1) + go func() { returned <- call(context.Background(), client, "wait", nil, nil) }() + receive(t, started) + _ = server.Close() + receive(t, stopped) + if err := receive(t, returned); err == nil { + t.Fatal("pending call succeeded after disconnect") + } + receive(t, client.Done()) +} + +func TestMalformedWireFramesDisconnect(t *testing.T) { + // Each row is a frame that would be served but for the one member named: + // a traceparent of another form is refused as any other malformed frame is. + for _, data := range []string{ + `{"version":2,"kind":"event","event":"progress","data":1}`, + `{"version":1,"kind":"request","id":"s:1","method":"wait","params":null}`, + `{"version":1,"kind":"response","id":"s:1","result":null,"error":{"code":"bad","message":"bad"}}`, + `{"version":1,"kind":"event","event":"progress","data":1,"extra":true}`, + `{"version":1,"kind":"event","event":"progress","data":1,"traceparent":"nonsense"}`, + `{"version":1,"kind":"request","id":"c:1","method":"wait","params":{},"traceparent":"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01"}`, + `{"version":1,"kind":"request","id":"c:1","method":"wait","params":{},"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-99"}`, + `{"version":1,"kind":"cancel","id":"c:1","traceparent":""}`, + } { + t.Run(data, func(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{}) + raw.write(data) + receive(t, peer.Done()) + if peer.Err() == nil { + t.Fatal("malformed frame closed without error") + } + // The refusal is the protocol's own close. + if closed := raw.closed(); closed.Code != transports.CodeProtocol || closed.Reason == "" { + t.Fatalf("the far side read %d %q, want 4011 with a reason", int(closed.Code), closed.Reason) + } + }) + } +} + +// The members are optional on every kind and the peer emits none of its own: +// what a frame carries it carries past the decoder, and the frame is served. +// The hand-written frames name the root's paths in their canonical encoding, +// "5:outer" for [outer], since a raw method name is served by nothing. +func TestTraceContextTravelsOnEveryFrameKind(t *testing.T) { + const trace = `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"congo=t61rcWkgMzE"` + events, started, cancelled := make(chan string, 2), make(chan struct{}), make(chan struct{}) + peer, raw := rawPeer(t, engine.ServerRole, serving{ + Events: map[string]eventHandler{ + "progress": func(_ context.Context, _ *engine.Peer, data json.RawMessage) { events <- string(data) }, + }, + Handlers: map[string]handler{ + "outer": func(ctx context.Context, peer *engine.Peer, _ json.RawMessage) (any, error) { + var answer string + if err := call(ctx, peer, "reverse", nil, &answer); err != nil { + return nil, err + } + return answer, nil + }, + "wait": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + close(cancelled) + return nil, ctx.Err() + }, + }, + }.with(engine.Options{})) + raw.write(`{"version":1,"kind":"event","event":"8:progress","data":1,` + trace + `}`) + if got := receive(t, events); got != "1" { + t.Fatalf("traced event = %s", got) + } + // A traced request is served, and the response to the reverse call it makes + // is itself traced: both kinds cross the decoder in one exchange. + raw.write(`{"version":1,"kind":"request","id":"c:1","method":"5:outer","params":{},` + trace + `}`) + reverse := raw.read() + if string(reverse["method"]) != `"7:reverse"` { + t.Fatalf("reverse request = %v", reverse) + } + raw.write(`{"version":1,"kind":"response","id":` + string(reverse["id"]) + `,"result":"back",` + trace + `}`) + if response := raw.read(); string(response["id"]) != `"c:1"` || string(response["result"]) != `"back"` { + t.Fatalf("response to traced request = %v", response) + } + // An intermediary may strip one member and not the other. + raw.write(`{"version":1,"kind":"event","event":"8:progress","data":2,"tracestate":"congo=t61rcWkgMzE"}`) + if got := receive(t, events); got != "2" { + t.Fatalf("event carrying tracestate alone = %s", got) + } + raw.write(`{"version":1,"kind":"request","id":"c:2","method":"4:wait","params":{},` + trace + `}`) + raw.write(`{"version":1,"kind":"cancel","id":"c:2",` + trace + `}`) + // The traced cancel is served: the request is answered cancelled. v0.6.0 + // held that its raw handler saw the cancellation; through the root a + // cancel that arrives before the body starts settles the request without + // running it, as v0.6.0's root did, so the answer is what is held here, + // and a body that did start must have been cancelled. + if response := raw.readUntil(member(`id="c:2"`)); string(response["error"]) != `{"code":"cancelled","message":"Request cancelled"}` { + t.Fatalf("the cancelled request was answered %v", response) + } + select { + case <-started: + receive(t, cancelled) + default: + } + if peer.Err() != nil { + t.Fatalf("a traced frame closed the connection: %v", peer.Err()) + } +} + +// A request whose method is no canonical path encoding is refused +// method_not_found and the connection goes on, as a v0.6.0 peer without that +// handler answered it: the raw method-name API is gone, and nothing serves a +// raw name. +func TestARawMethodNameIsAnsweredMethodNotFound(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, serving{Handlers: map[string]handler{ + "echo": func(_ context.Context, _ *engine.Peer, params json.RawMessage) (any, error) { return params, nil }, + }}.with(engine.Options{})) + raw.write(`{"version":1,"kind":"request","id":"c:1","method":"echo","params":1}`) + if response := raw.read(); string(response["id"]) != `"c:1"` || string(response["error"]) != `{"code":"method_not_found","message":"Unknown method"}` { + t.Fatalf("a raw method name was answered %v", response) + } + raw.write(`{"version":1,"kind":"request","id":"c:2","method":"4:echo","params":1}`) + if response := raw.read(); string(response["id"]) != `"c:2"` || string(response["result"]) != `1` { + t.Fatalf("the canonical path was answered %v", response) + } + if peer.Err() != nil { + t.Fatalf("a raw method name ended the connection: %v", peer.Err()) + } +} + +// TestSubprotocolIsNoneOverAnyOtherTransport: a peer that is not over a +// WebSocket negotiated nothing and says so. +func TestSubprotocolIsNoneOverAnyOtherTransport(t *testing.T) { + client, server := newPair(t, engine.Options{}, engine.Options{}) + if client.Subprotocol() != "" || server.Subprotocol() != "" { + t.Fatalf("subprotocols over a pipe = %q and %q", client.Subprotocol(), server.Subprotocol()) + } +} diff --git a/engine/go/unicode_peer_test.go b/engine/go/unicode_peer_test.go new file mode 100644 index 0000000..a54c85b --- /dev/null +++ b/engine/go/unicode_peer_test.go @@ -0,0 +1,43 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/unicode_peer_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). + +import ( + "context" + "encoding/json" + "testing" + + core "github.com/Bitspark/bitruntime/core/go" + engine "github.com/Bitspark/bitruntime/engine/go" +) + +func TestPeerRefusesMalformedOutgoingUnicode(t *testing.T) { + client, _ := newPair(t, serving{Handlers: map[string]handler{ + "echo": func(_ context.Context, _ *engine.Peer, raw json.RawMessage) (any, error) { return raw, nil }, + "bad": func(context.Context, *engine.Peer, json.RawMessage) (any, error) { return string([]byte{0xff}), nil }, + }}.with(engine.Options{}), engine.Options{}) + ctx := context.Background() + for _, value := range []any{string([]byte{0xff}), map[string]any{"x": string([]byte{0xff})}, json.RawMessage(`"\uD800"`)} { + if err := emit(ctx, client, "probe", value); err == nil { + t.Fatalf("emitted %T", value) + } + var result any + if err := call(ctx, client, "echo", value, &result); err == nil { + t.Fatalf("called with %T", value) + } + } + if err := emit(ctx, client, string([]byte{0xff}), nil); err == nil { + t.Fatal("emitted malformed event name") + } + if err := emit(core.WithMeta(ctx, map[string]string{"x": string([]byte{0xff})}), client, "probe", nil); err == nil { + t.Fatal("emitted malformed metadata") + } + var result string + if err := call(ctx, client, "bad", nil, &result); err == nil { + t.Fatal("malformed response was silently replaced") + } + if err := call(ctx, client, "echo", "๐Ÿ˜€๏ฟฝ", &result); err != nil || result != "๐Ÿ˜€๏ฟฝ" { + t.Fatalf("valid Unicode after refusal: %q, %v", result, err) + } +} From eefc1ef5434f02578fe4c8bd3122cb43cba5bb78 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:09:23 +0200 Subject: [PATCH 23/39] engine: hold the bitwire/1 vectors by a running peer of each role Port runtime/go/carriage_test.go and serial_test.go from Nightseam v0.6.0 (5cc9723a). Every row of vectors/bitwire-1/frames.json and serials.json is sent as raw text over a pipe to a peer of each role, and the far side holds that the peer ended with 4011 or served on: a request after the row, above every row's serial, is answered. A row addressed to the server reaches a client with its id prefixes swapped; a row addressed to either reaches both unchanged. v0.6.0's decode-level judgement of frames.json is kept beside it. The meta tests decode with internal/profile and send through the root; the refused-meta test reads 4011 at the far side instead of the removed observer. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/go/carriage_test.go | 386 +++++++++++++++++++++++++++++++++++++ engine/go/serial_test.go | 130 +++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 engine/go/carriage_test.go create mode 100644 engine/go/serial_test.go diff --git a/engine/go/carriage_test.go b/engine/go/carriage_test.go new file mode 100644 index 0000000..99e82fc --- /dev/null +++ b/engine/go/carriage_test.go @@ -0,0 +1,386 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/carriage_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). The carriage a request and an +// event may take: what is about the call rather than the call. The peer +// accepts it and keeps it on the decoded frame, and sends what WithMeta placed +// on the sending context โ€” never one of its own. + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + engine "github.com/Bitspark/bitruntime/engine/go" + "github.com/Bitspark/bitruntime/internal/delivery/go" + "github.com/Bitspark/bitruntime/internal/profile/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// TestMetaIsKeptOnTheDecodedFrame: a frame of each kind that may carry meta +// decodes, and the member reaches the frame verbatim rather than being read +// and dropped. +func TestMetaIsKeptOnTheDecodedFrame(t *testing.T) { + for _, test := range []struct { + name string + frame string + meta map[string]string + }{ + {"request", `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"tenant":"acme","idempotency":"k-1"}}`, + map[string]string{"tenant": "acme", "idempotency": "k-1"}}, + {"request with an empty carriage", `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{}}`, + map[string]string{}}, + {"event", `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"cause":"nightly"}}`, + map[string]string{"cause": "nightly"}}, + {"event beside a trace", `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"tenant":"acme"},` + + `"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}`, + map[string]string{"tenant": "acme"}}, + } { + t.Run(test.name, func(t *testing.T) { + f, err := profile.Decode([]byte(test.frame)) + if err != nil { + t.Fatalf("a frame carrying meta was refused: %v", err) + } + if !reflect.DeepEqual(f.Meta, test.meta) { + t.Fatalf("meta = %v, want %v", f.Meta, test.meta) + } + }) + } + // A frame carrying none leaves the member absent rather than empty, so the + // emitting half can tell a carriage with nothing in it from no carriage. + f, err := profile.Decode([]byte(`{"version":1,"kind":"request","id":"c:1","method":"read","params":{}}`)) + if err != nil || f.Meta != nil { + t.Fatalf("a frame carrying no meta = %v, %v", f.Meta, err) + } +} + +// TestMetaIsRefusedInEveryOtherForm: the kinds that may not carry it, the +// forms that are not an object of strings, and the keys the profile keeps. +func TestMetaIsRefusedInEveryOtherForm(t *testing.T) { + for _, frame := range []string{ + // A response says what it says in its result; a cancel withdraws a call + // rather than making one. + `{"version":1,"kind":"response","id":"s:1","result":1,"meta":{"tenant":"acme"}}`, + `{"version":1,"kind":"response","id":"s:1","error":{"code":"busy","message":"Try later"},"meta":{"tenant":"acme"}}`, + `{"version":1,"kind":"cancel","id":"c:1","meta":{"tenant":"acme"}}`, + // An object of strings, and nothing else. + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":"acme"}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":["acme"]}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":7}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":null}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"attempt":2}}`, + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"tenant":null}}`, + `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"live":true}}`, + `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"who":{"id":"u1"}}}`, + // The namespace the profile keeps for itself, which it fills with + // nothing in this version. + `{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"nightseam.deadline":"2026-01-01T00:00:00Z"}}`, + `{"version":1,"kind":"event","event":"updated","data":1,"meta":{"nightseam.cause":"nightly"}}`, + } { + t.Run(frame, func(t *testing.T) { + if _, err := profile.Decode([]byte(frame)); err == nil { + t.Fatal("a frame the profile does not admit was accepted") + } + }) + } +} + +// vectorRow is one row of a bitwire/1 vector table: a frame, the frame before +// it where the table is of sequences, the role it is addressed to, and +// whether a peer of that role admits it. +type vectorRow struct { + Name string + To string + Before, Frame string + Valid bool +} + +func readVectors(t *testing.T, name string) []vectorRow { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "vectors", "bitwire-1", name)) + if err != nil { + t.Fatal(err) + } + var table struct{ Rows []vectorRow } + if err := json.Unmarshal(data, &table); err != nil { + t.Fatal(err) + } + if len(table.Rows) == 0 { + t.Fatalf("%s has no rows", name) + } + return table.Rows +} + +// mirrored is a row addressed to a server spelled for a client: a request +// identifier's prefix names its sender's role, so the two prefixes swap and +// nothing else in the frame changes. A row addressed to either role is sent +// to both as it is. +var mirrored = strings.NewReplacer(`"id":"c:`, `"id":"s:`, `"id":"s:`, `"id":"c:`) + +// rowsFor is every row as a peer of role receives it. +func rowsFor(role engine.Role, rows []vectorRow) []vectorRow { + held := make([]vectorRow, 0, len(rows)) + for _, row := range rows { + if row.To != "server" && row.To != "client" && row.To != "either" { + panic("a vector row addressed to " + row.To) + } + if row.To != "either" && row.To != string(role) { + row.Before, row.Frame = mirrored.Replace(row.Before), mirrored.Replace(row.Frame) + } + held = append(held, row) + } + return held +} + +var echoing = serving{Handlers: map[string]handler{ + "echo": func(_ context.Context, _ *engine.Peer, data json.RawMessage) (any, error) { return data, nil }, +}} + +// holdRow sends a row's frames, as raw text over a pipe, to a fresh peer of +// role and holds that the peer ended with 4011 where the row is refused, or +// served on where it is admitted: a request sent after the row, with a serial +// above every row's, is answered. +func holdRow(t *testing.T, role engine.Role, row vectorRow) { + t.Helper() + peer, raw := rawPeer(t, role, echoing.with(engine.Options{})) + for _, frame := range []string{row.Before, row.Frame} { + if frame != "" { + raw.write(frame) + } + } + if !row.Valid { + if closed := raw.closed(); closed.Code != transports.CodeProtocol { + t.Fatalf("a refused frame ended the connection with %d %q, not 4011", int(closed.Code), closed.Reason) + } + receive(t, peer.Done()) + if err := peer.Err(); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("the peer ended with %v", err) + } + return + } + probe := `"c:1000000"` + if role == engine.ClientRole { + probe = `"s:1000000"` + } + raw.write(`{"version":1,"kind":"request","id":` + probe + `,"method":"4:echo","params":"served"}`) + if response := raw.readUntil(member("id=" + probe)); string(response["result"]) != `"served"` { + t.Fatalf("the request after an admitted frame was answered %v", response) + } + if err := peer.Err(); err != nil { + t.Fatalf("an admitted frame ended the connection: %v", err) + } +} + +// TestTheConformanceTableIsJudgedAsItJudges: every row of frames.json, held +// the way the peer holds a frame it is handed โ€” the envelope decoded and the +// id held to the prefix its kind carries โ€” so that the two runtimes and the +// suite read one description of the wire, this one. +func TestTheConformanceTableIsJudgedAsItJudges(t *testing.T) { + rows := readVectors(t, "frames.json") + carriages := 0 + for _, row := range rows { + // A row addressed to the server carries the client's ids and answers + // the server's; one addressed to either is read as a server's. + local, remote := "s:", "c:" + if row.To == "client" { + local, remote = "c:", "s:" + } + f, err := profile.Decode([]byte(row.Frame)) + accepted := err == nil + if accepted && f.ID != "" { + prefix := remote + if f.Kind == "response" { + prefix = local + } + accepted = profile.ValidID(f.ID, prefix) + } + if accepted != row.Valid { + t.Errorf("%s: valid=%v, decode error %v", row.Name, row.Valid, err) + } + var members map[string]json.RawMessage + if json.Unmarshal([]byte(row.Frame), &members) == nil { + if _, carried := members["meta"]; carried { + carriages++ + } + } + } + if len(rows) < 70 || carriages < 12 { + t.Fatalf("the table holds %d rows and names meta in %d; the wire is held by more than that", len(rows), carriages) + } +} + +// TestEveryConformanceRowIsHeldByAPeerOfEachRole: every row of frames.json +// sent to a running peer, as the table's harness describes, of each role โ€” +// a row addressed to the server reaches a client with its identifiers +// mirrored โ€” so that what the envelope decoder judges is what the connection +// does: a refused frame ends it with 4011, an admitted one does not. +func TestEveryConformanceRowIsHeldByAPeerOfEachRole(t *testing.T) { + rows := readVectors(t, "frames.json") + for _, role := range []engine.Role{engine.ServerRole, engine.ClientRole} { + t.Run(string(role), func(t *testing.T) { + for _, row := range rowsFor(role, rows) { + t.Run(row.Name, func(t *testing.T) { + t.Parallel() + holdRow(t, role, row) + }) + } + }) + } +} + +// TestAFrameWithARefusedMetaEndsTheConnection: the refusal is the profile's +// own close, 4011, as any malformed frame is. v0.6.0 held what its observer +// was told; observers are removed, and the far side reads the code instead. +func TestAFrameWithARefusedMetaEndsTheConnection(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{}) + raw.write(`{"version":1,"kind":"request","id":"c:1","method":"read","params":{},"meta":{"nightseam.cause":"nightly"}}`) + if closed := raw.closed(); closed.Code != transports.CodeProtocol { + t.Fatalf("the connection closed with %d %q, want 4011", int(closed.Code), closed.Reason) + } + receive(t, peer.Done()) + if peer.Err() == nil { + t.Fatal("the peer ended without an error") + } +} + +// TestMetaTravelsFromTheContextToTheFrame: what WithMeta said reaches the +// request and the event sent from that context, and a context that said +// nothing carries the member nowhere. +func TestMetaTravelsFromTheContextToTheFrame(t *testing.T) { + peer, raw := rawPeer(t, engine.ClientRole, engine.Options{}) + ctx := raw.ctx + carried := core.Meta{"tenant": "acme", "idempotency": "k-1"} + // The call waits for a response nobody sends; the frame it sent is the + // assertion, and the test's own context releases it at the end โ€” cancelling + // it here would put a cancel frame between the reads below. + go func() { _ = call(core.WithMeta(ctx, carried), peer, "read", nil, nil) }() + if meta := metaOf(t, raw.read()); !reflect.DeepEqual(meta, carried) { + t.Fatalf("the request carried meta %v, want %v", meta, carried) + } + if err := emit(core.WithMeta(ctx, core.Meta{"cause": "nightly"}), peer, "updated", 1); err != nil { + t.Fatal(err) + } + if meta := metaOf(t, raw.read()); !reflect.DeepEqual(meta, core.Meta{"cause": "nightly"}) { + t.Fatalf("the event carried meta %v", meta) + } + // A context that said nothing sends the member nowhere: absent, not empty. + if err := emit(ctx, peer, "updated", 1); err != nil { + t.Fatal(err) + } + if meta := metaOf(t, raw.read()); meta != nil { + t.Fatalf("an event from a bare context carried meta %v", meta) + } + // A key of the reserved prefix is the profile's; WithMeta drops it rather + // than sending a frame the far peer would refuse. + if err := emit(core.WithMeta(ctx, core.Meta{"nightseam.cause": "nightly", "tenant": "acme"}), peer, "updated", 1); err != nil { + t.Fatal(err) + } + if meta := metaOf(t, raw.read()); !reflect.DeepEqual(meta, core.Meta{"tenant": "acme"}) { + t.Fatalf("a reserved key reached the wire: %v", meta) + } +} + +// metaOf is the meta a frame carried, and nil where it carried none. +func metaOf(t *testing.T, members map[string]json.RawMessage) core.Meta { + t.Helper() + raw, carried := members["meta"] + if !carried { + return nil + } + var meta core.Meta + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta %s: %v", raw, err) + } + return meta +} + +// TestAHandlerReadsItsMetaAndForwardsNothingOfItself: a carriage reaches the +// handler of the frame that carried it, and goes no further on its own โ€” a +// trace is the peer's to propagate and a credential is not, so a handler that +// means to forward one says WithMeta(ctx, MetaFrom(ctx)). +func TestAHandlerReadsItsMetaAndForwardsNothingOfItself(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + nested := make(chan core.Meta, 1) + events := make(chan core.Meta, 1) + client, _ := newPair(t, serving{ + Handlers: map[string]handler{ + // Reads its own meta, then calls back without saying to forward it. + "read": func(ctx context.Context, p *engine.Peer, _ json.RawMessage) (any, error) { + mine := core.MetaFrom(ctx) + var back string + if err := call(ctx, p, "reverse", nil, &back); err != nil { + return nil, err + } + return mine, nil + }, + // Reads its own meta, then forwards it as a handler must say to. + "relay": func(ctx context.Context, p *engine.Peer, _ json.RawMessage) (any, error) { + var back string + return back, call(core.WithMeta(ctx, core.MetaFrom(ctx)), p, "reverse", nil, &back) + }, + }, + Events: map[string]eventHandler{ + "updated": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) { events <- core.MetaFrom(ctx) }, + }, + }.with(engine.Options{}), serving{Handlers: map[string]handler{ + "reverse": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + nested <- core.MetaFrom(ctx) + return "back", nil + }, + }}.with(engine.Options{})) + + carried := core.Meta{"tenant": "acme"} + var seen core.Meta + if err := call(core.WithMeta(ctx, carried), client, "read", nil, &seen); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(seen, carried) { + t.Fatalf("the handler read meta %v, want %v", seen, carried) + } + if forwarded := receive(t, nested); forwarded != nil { + t.Fatalf("a call from the handler carried the caller's meta %v of its own accord", forwarded) + } + if err := call(core.WithMeta(ctx, carried), client, "relay", nil, nil); err != nil { + t.Fatal(err) + } + if forwarded := receive(t, nested); !reflect.DeepEqual(forwarded, carried) { + t.Fatalf("a handler that said to forward carried %v, want %v", forwarded, carried) + } + // An event's handler reads its event's carriage across the bounded queue. + if err := emit(core.WithMeta(ctx, core.Meta{"cause": "nightly"}), client, "updated", 1); err != nil { + t.Fatal(err) + } + if got := receive(t, events); !reflect.DeepEqual(got, core.Meta{"cause": "nightly"}) { + t.Fatalf("the event handler read meta %v", got) + } + // A handler of a frame that carried none reads nil, not an empty carriage. + if err := emit(ctx, client, "updated", 1); err != nil { + t.Fatal(err) + } + if got := receive(t, events); got != nil { + t.Fatalf("a handler of a bare event read meta %v", got) + } +} + +// TestMetaFromIsACopy: what a handler writes into what it read reaches no +// frame and no other handler. +func TestMetaFromIsACopy(t *testing.T) { + carried := core.Meta{"tenant": "acme"} + ctx := delivery.WithIncomingMeta(context.Background(), carried) + mine := core.MetaFrom(ctx) + mine["tenant"] = "other" + if core.MetaFrom(ctx)["tenant"] != "acme" { + t.Fatal("a handler's write reached the frame's carriage") + } + if core.MetaFrom(context.Background()) != nil { + t.Fatal("a context no frame ran carries a carriage") + } +} diff --git a/engine/go/serial_test.go b/engine/go/serial_test.go new file mode 100644 index 0000000..685b5a9 --- /dev/null +++ b/engine/go/serial_test.go @@ -0,0 +1,130 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/serial_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "sync" + "testing" + + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" +) + +// TestTheSerialTableIsHeldAsItJudges: every row of serials.json, held the way +// the peer holds what arrives โ€” the first frame published, then the request +// that follows it โ€” by a peer of each role, a row addressed to the server +// reaching a client with its identifiers mirrored. The rows name the raw +// method "echo", which no canonical path encodes, so an admitted request is +// answered method_not_found: still its response, as a v0.6.0 peer without +// that handler answered it. +func TestTheSerialTableIsHeldAsItJudges(t *testing.T) { + rows := readVectors(t, "serials.json") + for _, role := range []engine.Role{engine.ServerRole, engine.ClientRole} { + t.Run(string(role), func(t *testing.T) { + for _, row := range rowsFor(role, rows) { + t.Run(row.Name, func(t *testing.T) { + t.Parallel() + if row.Valid { + // The first frame the peer sends answers a request. + peer, raw := rawPeer(t, role, echoing.with(engine.Options{})) + raw.write(row.Before) + raw.write(row.Frame) + if members := raw.read(); string(members["kind"]) != `"response"` { + t.Fatalf("frame was %s", members["kind"]) + } + if peer.Err() != nil { + t.Fatalf("an admissible serial ended the connection: %v", peer.Err()) + } + } + holdRow(t, role, row) + }) + } + }) + } +} + +// Only a request advances the mark. A response answers a serial the receiver +// itself took, and a control names one it already admitted. +func TestOnlyRequestAdmissionAdvancesTheMark(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, serving{Handlers: map[string]handler{ + "wait": func(ctx context.Context, _ *engine.Peer, _ json.RawMessage) (any, error) { + <-ctx.Done() + return nil, ctx.Err() + }, + }}.with(engine.Options{})) + for _, frame := range []string{ + `{"version":1,"kind":"request","id":"c:4","method":"4:wait","params":null}`, + `{"version":1,"kind":"cancel","id":"c:4"}`, + `{"version":1,"kind":"response","id":"s:1","result":null}`, + `{"version":1,"kind":"request","id":"c:5","method":"4:wait","params":null}`, + } { + raw.write(frame) + } + if members := raw.read(); string(members["id"]) != `"c:4"` { + t.Fatalf("first answer was %s", members["id"]) + } + if peer.Err() != nil { + t.Fatalf("a control or a response advanced the mark: %v", peer.Err()) + } +} + +// Serials are published in the order they were reserved, whatever order the +// callers that took them are scheduled in. +func TestConcurrentCallsPublishSerialsInOrder(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{}) + calling, withdraw := context.WithCancel(context.Background()) + var wait sync.WaitGroup + t.Cleanup(func() { withdraw(); wait.Wait() }) + for range 24 { + wait.Add(1) + go func() { + defer wait.Done() + _ = call(calling, peer, "probe", nil, nil) + }() + } + previous := uint64(0) + for range 24 { + members := raw.read() + if string(members["kind"]) != `"request"` { + continue + } + var id string + if err := json.Unmarshal(members["id"], &id); err != nil { + t.Fatal(err) + } + serial, err := strconv.ParseUint(strings.TrimPrefix(id, "s:"), 10, 64) + if err != nil { + t.Fatal(err) + } + if serial <= previous { + t.Fatalf("published %d after %d", serial, previous) + } + previous = serial + } +} + +// Every carrier bridge mints its own serials on its own connection and maps +// replies back: an inner peer's ids are its own, whatever ids arrived. +func TestACarrierBridgeMintsItsOwnSerials(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{}) + // The peer's root takes a request whose id is the sender's; publishing it + // onward is the bridge's own request, with a serial of the bridge's. + root := peer.Wire() + go func() { _ = dispatch.Call(context.Background(), root, []string{"probe"}, nil, nil) }() + members := raw.read() + var id string + if err := json.Unmarshal(members["id"], &id); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(id, "s:") { + t.Fatalf("the bridge published %q rather than a serial of its own", id) + } + if serial, err := strconv.ParseUint(strings.TrimPrefix(id, "s:"), 10, 64); err != nil || serial == 0 { + t.Fatalf("the bridge published %q", id) + } +} From 6aa3f31b2fb3242623f198663fe80d9115370720 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:09:43 +0200 Subject: [PATCH 24/39] engine: port v0.6.0's seam and Prepare tests Port runtime/go/seam_test.go and the pipe half of prepare_test.go from Nightseam v0.6.0 (5cc9723a). How a peer ends a connection is held at the far side's reading, since observers are removed; a far side's abort is held on the peer's error instead. An ended peer's error is a closed carrier that keeps its cause (R26), so the oversized-frame refusal is the error's suffix, and the far side reads 4011 with it. The tunnel installed in Prepare becomes a dispatcher at the root, which meets a request already waiting on the pipe when the peer is made. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/go/prepare_test.go | 64 ++++++++++ engine/go/seam_test.go | 240 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 engine/go/prepare_test.go create mode 100644 engine/go/seam_test.go diff --git a/engine/go/prepare_test.go b/engine/go/prepare_test.go new file mode 100644 index 0000000..780c8e8 --- /dev/null +++ b/engine/go/prepare_test.go @@ -0,0 +1,64 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/prepare_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). The tests of Accept and Dial +// live beside them in engine/websocket/go. + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// A Prepare that fails fails the construction: nothing is returned that could +// read a frame. +func TestPrepareFailingFailsNewPeer(t *testing.T) { + refusal := errors.New("this peer serves nothing") + near, far := transports.Pipe(1 << 20) + defer far.Abort() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + peer, err := engine.NewPeer(ctx, near, engine.ClientRole, engine.Options{Prepare: func(*engine.Peer) error { return refusal }}) + if !errors.Is(err, refusal) || peer != nil { + t.Fatalf("NewPeer answered peer=%v error=%v", peer, err) + } +} + +// A receiver attached in Prepare is there before the peer has read anything: +// a request already waiting on the connection when the peer is made meets it +// rather than method_not_found, however long the attachment takes. v0.6.0 +// held this with a tunnel installed in Prepare; tunnels are not ported, and a +// dispatcher at the root is what a peer serves through here. The WebSocket +// form, many dials over, is in engine/websocket/go. +func TestAReceiverAttachedInPrepareMeetsTheFirstRequest(t *testing.T) { + near, far := transports.Pipe(1 << 20) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + raw := &rawSide{t: t, ctx: ctx, conn: far} + defer far.Abort() + raw.write(`{"version":1,"kind":"request","id":"c:1","method":"5:probe","params":{}}`) + peer, err := engine.NewPeer(ctx, near, engine.ServerRole, engine.Options{Prepare: func(peer *engine.Peer) error { + time.Sleep(10 * time.Millisecond) + d, err := dispatch.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + _, err = dispatch.Handle(d, []string{"probe"}, func(context.Context, json.RawMessage) (any, error) { + return map[string]any{"ready": true}, nil + }) + return err + }}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + if response := raw.read(); string(response["id"]) != `"c:1"` || string(response["result"]) != `{"ready":true}` { + t.Fatalf("the first request was answered %v", response) + } +} diff --git a/engine/go/seam_test.go b/engine/go/seam_test.go new file mode 100644 index 0000000..e30b5d7 --- /dev/null +++ b/engine/go/seam_test.go @@ -0,0 +1,240 @@ +package engine_test + +// Ported from Nightseam v0.6.0 runtime/go/seam_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +// TestPeerSpeaksTheProfileOverAnyConnection: two peers over an in-memory +// pipe, no socket anywhere, complete a call, a reverse call, an event and a +// cancellation, and a closed pipe ends both. The protocol is written to the +// seam, not to a WebSocket. +func TestPeerSpeaksTheProfileOverAnyConnection(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + clientConn, serverConn := transports.Pipe(1 << 20) + blocked := make(chan struct{}) + server, err := engine.NewPeer(ctx, serverConn, engine.ServerRole, serving{Handlers: map[string]handler{ + "echo": func(ctx context.Context, p *engine.Peer, raw json.RawMessage) (any, error) { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + var back string + if err := call(ctx, p, "reverse", s, &back); err != nil { + return nil, err + } + return back, nil + }, + "block": func(ctx context.Context, p *engine.Peer, raw json.RawMessage) (any, error) { + <-ctx.Done() + close(blocked) + return nil, ctx.Err() + }, + }}.with(engine.Options{})) + if err != nil { + t.Fatal(err) + } + defer server.Close() + observed := make(chan json.RawMessage, 1) + client, err := engine.NewPeer(ctx, clientConn, engine.ClientRole, serving{ + Handlers: map[string]handler{"reverse": func(ctx context.Context, p *engine.Peer, raw json.RawMessage) (any, error) { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes), nil + }}, + Events: map[string]eventHandler{"changed": func(ctx context.Context, p *engine.Peer, raw json.RawMessage) { + observed <- raw + }}, + }.with(engine.Options{})) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + var result string + if err := call(ctx, client, "echo", "seam", &result); err != nil { + t.Fatal(err) + } + if result != "maes" { + t.Fatalf("a call and its reverse call over the pipe returned %q", result) + } + if err := emit(ctx, server, "changed", map[string]int{"count": 7}); err != nil { + t.Fatal(err) + } + select { + case data := <-observed: + if string(data) != `{"count":7}` { + t.Fatalf("the event arrived as %s", data) + } + case <-ctx.Done(): + t.Fatal("no event arrived over the pipe") + } + short, cancelShort := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancelShort() + if err := call(short, client, "block", nil, nil); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("a cancelled call returned %v", err) + } + select { + case <-blocked: + case <-ctx.Done(): + t.Fatal("the cancellation never reached the handler over the pipe") + } + if err := clientConn.Close(ctx, transports.CodeNormal, "done"); err != nil { + t.Fatal(err) + } + select { + case <-server.Done(): + case <-ctx.Done(): + t.Fatal("closing the connection did not end the server peer") + } + var closed *transports.CloseError + if err := server.Err(); !errors.As(err, &closed) || closed.Code != transports.CodeNormal || closed.Reason != "done" { + t.Fatalf("the server peer ended with %v, not the close it was sent", err) + } +} + +// TestPeerRefusesAFrameOverItsLimit: a connection whose maker set a laxer +// limit than the peer's still cannot hand the peer an oversized frame. +func TestPeerRefusesAFrameOverItsLimit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + near, far := transports.Pipe(1 << 20) + peer, err := engine.NewPeer(ctx, near, engine.ClientRole, engine.Options{MaxFrameBytes: 512}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + big := make([]byte, 600) + for i := range big { + big[i] = ' ' + } + copy(big, `{"version":1,"kind":"event","event":"e","data":1`) + big[len(big)-1] = '}' + if err := far.Send(ctx, transports.Frame{Kind: transports.Text, Data: big}); err != nil { + t.Fatal(err) + } + select { + case <-peer.Done(): + case <-ctx.Done(): + t.Fatal("the peer accepted a frame over its limit") + } + // v0.6.0's error was the refusal itself. R26: an ended peer's error is a + // closed carrier that keeps its cause, so the refusal is what it wraps. + if err := peer.Err(); !errors.Is(err, transports.ErrClosed) || !strings.HasSuffix(err.Error(), "duplex frame exceeds size limit") { + t.Fatalf("the peer ended with %v", err) + } + // And the far side is told, with the protocol's code and the refusal. + var closed *transports.CloseError + if _, err := far.Receive(ctx); !errors.As(err, &closed) || closed.Code != transports.CodeProtocol || closed.Reason != "duplex frame exceeds size limit" { + t.Fatalf("the far side read %v", err) + } +} + +// TestHowAPeerEndsAConnectionIsWhatTheFarSideReads: the protocol closes with +// 4011 and a reason when the other side broke it, so that an intermediary +// between the two has a code to act on; a close this side chose carries 1000; +// and a transport there is nothing to say over is aborted, which the far side +// reads as 1006. v0.6.0 also held what its observer was told; observers are +// removed, so the far side's reading is what is held. +func TestHowAPeerEndsAConnectionIsWhatTheFarSideReads(t *testing.T) { + for _, c := range []struct { + name string + end func(ctx context.Context, cancel context.CancelFunc, peer *engine.Peer, far transports.Conn) + code transports.Code + reason string + local bool + }{ + { + name: "a malformed frame is refused", + end: func(ctx context.Context, _ context.CancelFunc, _ *engine.Peer, far transports.Conn) { + _ = far.Send(ctx, transports.Frame{Kind: transports.Text, Data: []byte(`{"version":1,"kind":"event"}`)}) + }, + code: transports.CodeProtocol, + reason: "invalid duplex frame shape", + local: true, + }, + { + name: "a frame of the wrong kind is refused", + end: func(ctx context.Context, _ context.CancelFunc, _ *engine.Peer, far transports.Conn) { + _ = far.Send(ctx, transports.Frame{Kind: transports.Binary, Data: []byte{0}}) + }, + code: transports.CodeProtocol, + reason: "duplex requires JSON text frames", + local: true, + }, + { + name: "a close this side chose", + end: func(_ context.Context, _ context.CancelFunc, peer *engine.Peer, _ transports.Conn) { _ = peer.Close() }, + code: transports.CodeNormal, + local: true, + }, + { + name: "a context that ended", + end: func(_ context.Context, cancel context.CancelFunc, _ *engine.Peer, _ transports.Conn) { + cancel() + }, + code: transports.CodeAbnormalClosure, + local: true, + }, + { + name: "a far side that aborted", + end: func(_ context.Context, _ context.CancelFunc, _ *engine.Peer, far transports.Conn) { + _ = far.Abort() + }, + code: transports.CodeAbnormalClosure, + local: false, + }, + } { + t.Run(c.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + near, far := transports.Pipe(1 << 20) + peer, err := engine.NewPeer(ctx, near, engine.ServerRole, engine.Options{}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + c.end(ctx, cancel, peer, far) + receive(t, peer.Done()) + if err := peer.Err(); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("the peer ended with %v, not a closed carrier", err) + } + if !c.local { + // What ended it is the far side's abort, and the error keeps it. + var remote *transports.CloseError + if err := peer.Err(); !errors.As(err, &remote) || remote.Code != c.code { + t.Fatalf("the peer ended with %v, want the far side's %d", err, int(c.code)) + } + return + } + // The far side reads what this side sent, which is the whole reason + // the code is decided here rather than reported here. + read, cancelRead := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelRead() + var wire *transports.CloseError + if _, err := far.Receive(read); !errors.As(err, &wire) { + t.Fatalf("the far side read %v, not a close", err) + } + if wire.Code != c.code || wire.Reason != c.reason { + t.Fatalf("the far side read %d %q, want %d %q", int(wire.Code), wire.Reason, int(c.code), c.reason) + } + }) + } +} From 6ca3144a9d499818b8aef10915e3956510b9a828 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:10:11 +0200 Subject: [PATCH 25/39] engine: hold R27 close codes and R28 answers at the root R27: the root asked to close with 1005, 1006 or 1015 aborts, and the far side reads 1006 with no reason; a sendable code travels with its reason. R28: with the root held inside the answer to a refusal, a request it admitted and never handed on, a call through the dispatch helper, and a queued busy refusal are each answered when the peer ends (disconnected, disconnected, busy) and none reaches the wire. The request already in flight is answered cancelled or disconnected at random: its waiter's context derives from the peer's, which ends with it. v0.6.0's root derived it the same way; the test accepts either. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/go/peer_test.go | 124 +++++++++++++++++++++++++++++++++++++++++ engine/go/seam_test.go | 54 +++++++++++++++++- 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/engine/go/peer_test.go b/engine/go/peer_test.go index 6722cf1..088fe24 100644 --- a/engine/go/peer_test.go +++ b/engine/go/peer_test.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "strings" + "sync" "testing" "time" @@ -21,6 +22,7 @@ import ( dispatch "github.com/Bitspark/bitruntime/dispatch/go" engine "github.com/Bitspark/bitruntime/engine/go" transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" ) func receive[T any](t *testing.T, channel <-chan T) T { @@ -561,3 +563,125 @@ func TestSubprotocolIsNoneOverAnyOtherTransport(t *testing.T) { t.Fatalf("subprotocols over a pipe = %q and %q", client.Subprotocol(), server.Subprotocol()) } } + +// heldReturn is a return capability that records what it is answered and +// holds the one who answers it with the code named, until released. +type heldReturn struct { + responses chan wire.ProfileFrame + holdOn string + held chan struct{} + release chan struct{} + once sync.Once +} + +func newHeldReturn(holdOn string) *heldReturn { + return &heldReturn{responses: make(chan wire.ProfileFrame, 8), holdOn: holdOn, held: make(chan struct{}), release: make(chan struct{})} +} + +func (r *heldReturn) Send(_ []string, message wire.Message) error { + if r.holdOn != "" && message.Frame.Error != nil && message.Frame.Error.Code == r.holdOn { + r.once.Do(func() { close(r.held) }) + <-r.release + } + r.responses <- message.Frame + return nil +} + +func request(id string) wire.ProfileFrame { + return wire.ProfileFrame{Version: 1, Kind: wire.ProfileRequest, ID: id, Params: json.RawMessage(`null`)} +} + +// TestRequestsQueuedInTheRootAreAnsweredWhenThePeerEnds (R28): a request the +// root admitted and never handed to the peer, and a refusal it queued, are +// each answered when the peer ends โ€” disconnected and the refusal โ€” rather +// than left to their callers' deadlines. The root is held on purpose, inside +// the answer to a refusal, so that what follows it is still queued when the +// peer ends. +func TestRequestsQueuedInTheRootAreAnsweredWhenThePeerEnds(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{MaxPendingRequests: 3, RequestTimeout: time.Minute}) + root := peer.Wire() + first := newHeldReturn("invalid_message") + firstAddress := &wire.ReturnAddress{Wire: first} + // Admitted and handed to the peer: it reaches the wire. + if err := root.Send([]string{"first"}, wire.Message{Frame: request("c:1"), Return: firstAddress}); err != nil { + t.Fatal(err) + } + if sent := raw.read(); string(sent["method"]) != `"5:first"` { + t.Fatalf("the first request reached the wire as %v", sent) + } + // The same return identity again is refused invalid_message; answering it + // holds the root. + if err := root.Send([]string{"first"}, wire.Message{Frame: request("c:1"), Return: firstAddress}); err != nil { + t.Fatal(err) + } + receive(t, first.held) + queued, refused := newHeldReturn(""), newHeldReturn("") + if err := root.Send([]string{"queued"}, wire.Message{Frame: request("c:1"), Return: &wire.ReturnAddress{Wire: queued}}); err != nil { + t.Fatal(err) + } + // And a call through the dispatch helper, queued behind it. + sent := make(chan struct{}) + returned := make(chan error, 1) + go func() { + returned <- dispatch.Call(context.Background(), sendSignal{root, sent}, []string{"late"}, nil, nil, dispatch.CallOptions{Timeout: time.Minute}) + }() + receive(t, sent) + // The root holds three admitted, its bound: the next is a queued refusal. + if err := root.Send([]string{"refused"}, wire.Message{Frame: request("c:1"), Return: &wire.ReturnAddress{Wire: refused}}); err != nil { + t.Fatal(err) + } + + _ = peer.Close() + close(first.release) + + // The request already handed to the peer is answered by its own waiter. + // Which code it carries is a race inherited from v0.6.0: the waiter's + // context derives from the peer's, which ends at the moment the peer does, + // so it reads either the end (disconnected) or its context (cancelled). + answers := map[string]bool{} + for range 2 { + f := receive(t, first.responses) + if f.Error == nil { + t.Fatalf("the first request was answered %+v", f) + } + answers[f.Error.Code] = true + } + if !answers["invalid_message"] || !(answers["disconnected"] || answers["cancelled"]) { + t.Fatalf("the first return capability was answered %v", answers) + } + if f := receive(t, queued.responses); f.Error == nil || f.Error.Code != "disconnected" || f.ID != "c:1" { + t.Fatalf("the request queued in the root was answered %+v", f) + } + if f := receive(t, refused.responses); f.Error == nil || f.Error.Code != "busy" || f.Error.Message != "Outstanding call limit reached" { + t.Fatalf("the refusal queued in the root was answered %+v", f) + } + var public *core.PublicError + if err := receive(t, returned); !errors.As(err, &public) || public.Code != "disconnected" { + t.Fatalf("a call queued in the root returned %v", err) + } + // Nothing queued in the root reached the wire. + for { + received, err := raw.conn.Receive(raw.ctx) + if err != nil { + break + } + if strings.Contains(string(received.Data), `"kind":"request"`) { + t.Fatalf("a request queued in the root reached the wire: %s", received.Data) + } + } +} + +// sendSignal is addressed access that says when a request it forwarded was +// taken. +type sendSignal struct { + wire.AddressedWire + sent chan struct{} +} + +func (s sendSignal) Send(path []string, message wire.Message) error { + err := s.AddressedWire.Send(path, message) + if message.Frame.Kind == wire.ProfileRequest { + close(s.sent) + } + return err +} diff --git a/engine/go/seam_test.go b/engine/go/seam_test.go index e30b5d7..1578e97 100644 --- a/engine/go/seam_test.go +++ b/engine/go/seam_test.go @@ -1,12 +1,14 @@ package engine_test // Ported from Nightseam v0.6.0 runtime/go/seam_test.go -// (5cc9723a24646c40ed1861f892b2b23eb6d785d7). +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7), with the close-code tests for +// research R27 beside the ones it held. import ( "context" "encoding/json" "errors" + "strconv" "strings" "testing" "time" @@ -238,3 +240,53 @@ func TestHowAPeerEndsAConnectionIsWhatTheFarSideReads(t *testing.T) { }) } } + +// TestAnObserveOnlyCloseCodeAbortsTheConnection (R27): a code that may only +// be observed โ€” no status, an abnormal closure, a failed TLS handshake โ€” is +// never transmitted. The root asked to close with one aborts, and the far +// side observes the abort itself: 1006 with no reason, not the code or the +// reason it was asked to send. +func TestAnObserveOnlyCloseCodeAbortsTheConnection(t *testing.T) { + for _, code := range []transports.Code{transports.CodeNoStatus, transports.CodeAbnormalClosure, transports.CodeTLSHandshake} { + t.Run(strconv.Itoa(int(code)), func(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{}) + if err := peer.Wire().Close(code, "x"); err != nil { + t.Fatal(err) + } + if closed := raw.closed(); closed.Code != transports.CodeAbnormalClosure || closed.Reason != "" { + t.Fatalf("the far side read %d %q, want an abort", int(closed.Code), closed.Reason) + } + receive(t, peer.Done()) + if err := peer.Err(); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("the peer ended with %v", err) + } + }) + } +} + +// TestASendableCloseCodeTravelsWithItsReason (R27): a code that may be sent is +// the close the far side reads, with the reason given. +func TestASendableCloseCodeTravelsWithItsReason(t *testing.T) { + for _, c := range []struct { + code transports.Code + reason string + }{ + {transports.CodePolicyViolation, "why"}, + {transports.CodeNormal, ""}, + {transports.CodeApplicationFirst, "application"}, + } { + t.Run(strconv.Itoa(int(c.code)), func(t *testing.T) { + peer, raw := rawPeer(t, engine.ServerRole, engine.Options{}) + if err := peer.Wire().Close(c.code, c.reason); err != nil { + t.Fatal(err) + } + if closed := raw.closed(); closed.Code != c.code || closed.Reason != c.reason { + t.Fatalf("the far side read %d %q, want %d %q", int(closed.Code), closed.Reason, int(c.code), c.reason) + } + receive(t, peer.Done()) + if err := peer.Err(); !errors.Is(err, transports.ErrClosed) { + t.Fatalf("the peer ended with %v", err) + } + }) + } +} From 9c3e39376932eab9d41359e387e0631ca650162f Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:10:25 +0200 Subject: [PATCH 26/39] engine/websocket: port v0.6.0's connection-setup tests Port the authentication, origin, subprotocol, dial-deadline and Prepare tests of runtime/go/peer_test.go and prepare_test.go from Nightseam v0.6.0 (5cc9723a). What a peer serves is a dispatcher attached in Prepare; the tunnel whose first channel.open met it becomes a first request over 3000 dials (250 with -short). A new test reads the close frame over a real WebSocket: 4011 and the refusal for a refused frame, and no close frame at all for 1005 and 1006 (R27). The rows for a sendable code chosen outside the read loop (Wire().Close(1008), Close()) are skipped: Peer.end cancels the peer's context before the close handshake, coder/websocket drops the socket when a pending Read's context ends, and the close frame is often lost. v0.6.0's end and WebSocket transport do the same with the same library. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/websocket/go/peer_test.go | 348 ++++++++++++++++++++++++++++ engine/websocket/go/prepare_test.go | 159 +++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 engine/websocket/go/peer_test.go create mode 100644 engine/websocket/go/prepare_test.go diff --git a/engine/websocket/go/peer_test.go b/engine/websocket/go/peer_test.go new file mode 100644 index 0000000..4706c34 --- /dev/null +++ b/engine/websocket/go/peer_test.go @@ -0,0 +1,348 @@ +package websocket_test + +// Ported from Nightseam v0.6.0 runtime/go/peer_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7): the tests about setting a +// connection up โ€” authentication, origin, subprotocols and the dial deadline. +// What a peer serves is a dispatcher at its root, attached in Prepare, since +// the raw method-name API is removed; "identity" is the path [identity]. + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + ws "github.com/Bitspark/bitruntime/engine/websocket/go" + transports "github.com/Bitspark/bitruntime/transports/go" +) + +func receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case value := <-channel: + return value + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for peer activity") + var zero T + return zero + } +} + +// serve is a Prepare that attaches a dispatcher answering each name, the +// one-segment path [name], with the peer the request arrived on. +func serve(handlers map[string]func(ctx context.Context, peer *engine.Peer) (any, error)) func(*engine.Peer) error { + return func(peer *engine.Peer) error { + d, err := dispatch.NewDispatcher(peer.Wire()) + if err != nil { + return err + } + for name, h := range handlers { + if _, err := dispatch.Handle(d, []string{name}, func(ctx context.Context, _ json.RawMessage) (any, error) { return h(ctx, peer) }); err != nil { + return err + } + } + return nil + } +} + +func allow() (func(*http.Request) (context.Context, error), func(*http.Request) bool) { + return func(r *http.Request) (context.Context, error) { return r.Context(), nil }, func(*http.Request) bool { return true } +} + +func TestServerRequiresAndEnforcesAuthenticationAndOriginPolicies(t *testing.T) { + authenticate, allowOrigin := allow() + for _, options := range []ws.ServerOptions{{}, {Authenticate: authenticate}, {CheckOrigin: allowOrigin}} { + if _, err := ws.NewHandler(options); err == nil { + t.Fatal("server accepted missing explicit policy") + } + } + type userKey struct{} + handler, err := ws.NewHandler(ws.ServerOptions{ + Authenticate: func(r *http.Request) (context.Context, error) { + if r.Header.Get("Authorization") != "Bearer valid" { + return nil, errors.New("private authentication failure") + } + return context.WithValue(r.Context(), userKey{}, "alice"), nil + }, + CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://allowed.example" }, + Options: engine.Options{Prepare: serve(map[string]func(context.Context, *engine.Peer) (any, error){ + "identity": func(ctx context.Context, _ *engine.Peer) (any, error) { return ctx.Value(userKey{}), nil }, + })}, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + for _, test := range []struct { + origin, authorization string + status int + }{ + {"https://denied.example", "Bearer valid", http.StatusForbidden}, + {"https://allowed.example", "Bearer wrong", http.StatusUnauthorized}, + } { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + peer, response, err := ws.Dial(ctx, server.URL, ws.DialOptions{HTTPHeader: http.Header{ + "Origin": {test.origin}, "Authorization": {test.authorization}, + }}) + cancel() + if peer != nil { + _ = peer.Close() + } + if err == nil || response == nil || response.StatusCode != test.status { + t.Fatalf("rejected handshake = peer %v, response %v, error %v; want %d", peer, response, err, test.status) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{HTTPHeader: http.Header{ + "Origin": {"https://allowed.example"}, "Authorization": {"Bearer valid"}, + }}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var identity string + if err := dispatch.Call(ctx, client.Wire(), []string{"identity"}, nil, &identity); err != nil || identity != "alice" { + t.Fatalf("authenticated identity = %q, error=%v", identity, err) + } +} + +// TestSubprotocolNegotiation holds what the handshake selected against what +// both peers report, and the protocol is spoken over the connection either +// way: nothing about it turns on a subprotocol. +func TestSubprotocolNegotiation(t *testing.T) { + // The ticket case: a browser can carry one nowhere but in the offer, and + // accepts the handshake only if it comes back unchanged. + ticket := func(_ *http.Request, offered []string) string { + for _, token := range offered { + if strings.HasPrefix(token, "ticket.") { + return token + } + } + return "" + } + for _, test := range []struct { + name string + server ws.ServerOptions + offer []string + selected string + }{ + {name: "both name it", server: ws.ServerOptions{Subprotocols: []string{"a", "b"}}, offer: []string{"b"}, selected: "b"}, + {name: "the offer meets none of them", server: ws.ServerOptions{Subprotocols: []string{"a", "b"}}, offer: []string{"c"}}, + {name: "the server names none", offer: []string{"a"}}, + {name: "neither side names one", server: ws.ServerOptions{}}, + {name: "a ticket is selected back unchanged", server: ws.ServerOptions{SelectSubprotocol: ticket}, offer: []string{"ticket.4f9c", "a"}, selected: "ticket.4f9c"}, + {name: "the hook selects none", server: ws.ServerOptions{Subprotocols: []string{"a"}, SelectSubprotocol: ticket}, offer: []string{"a"}}, + } { + t.Run(test.name, func(t *testing.T) { + connected := make(chan *engine.Peer, 1) + options := test.server + options.Authenticate, options.CheckOrigin = allow() + options.OnConnect = func(peer *engine.Peer) { connected <- peer } + options.Options = engine.Options{Prepare: serve(map[string]func(context.Context, *engine.Peer) (any, error){ + "selected": func(_ context.Context, peer *engine.Peer) (any, error) { return peer.Subprotocol(), nil }, + })} + handler, err := ws.NewHandler(options) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Subprotocols: test.offer}) + if err != nil { + t.Fatalf("dial offering %v: %v", test.offer, err) + } + defer client.Close() + remote := receive(t, connected) + defer remote.Close() + if got := client.Subprotocol(); got != test.selected { + t.Fatalf("client subprotocol = %q, want %q", got, test.selected) + } + if got := remote.Subprotocol(); got != test.selected { + t.Fatalf("server subprotocol = %q, want %q", got, test.selected) + } + // And the connection carries the protocol whatever was selected, + // which the server reads back over it. + var answer string + if err := dispatch.Call(ctx, client.Wire(), []string{"selected"}, nil, &answer); err != nil { + t.Fatalf("call over a connection negotiating %q: %v", test.selected, err) + } + if answer != test.selected { + t.Fatalf("subprotocol a handler read = %q, want %q", answer, test.selected) + } + }) + } +} + +// TestDialRefusesAHandshakeThatNeverAnswers: a listener that takes the +// connection and never answers the upgrade is refused with the code +// connect_timeout, with nothing opened and the caller's own context +// untouched, the deadline having been the handshake's and not the +// connection's. A negative bound is refused before anything is dialled. +func TestDialRefusesAHandshakeThatNeverAnswers(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan struct{}) + defer close(done) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { <-done; conn.Close() }() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := "ws://" + listener.Addr().String() + started := time.Now() + peer, _, err := ws.Dial(ctx, url, ws.DialOptions{ConnectTimeout: 50 * time.Millisecond}) + if peer != nil { + t.Fatal("a dial past its bound opened a peer") + } + var refusal *core.PublicError + if !errors.As(err, &refusal) || refusal.Code != "connect_timeout" { + t.Fatalf("dial error = %v, want the code connect_timeout", err) + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Fatalf("the dial waited %v past its 50ms bound", elapsed) + } + if ctx.Err() != nil { + t.Fatal("the handshake's deadline ended the caller's own context") + } + + if _, _, err := ws.Dial(ctx, url, ws.DialOptions{ConnectTimeout: -time.Second}); err == nil || !strings.Contains(err.Error(), "must not be negative") { + t.Fatalf("a negative connect timeout = %v, want a refusal", err) + } +} + +// rawClient is a server peer reached over a raw WebSocket, so a test reads +// the close frame itself rather than what a peer makes of it. +func rawClient(t *testing.T) (*engine.Peer, *websocket.Conn, context.Context) { + t.Helper() + connected := make(chan *engine.Peer, 1) + authenticate, allowOrigin := allow() + handler, err := ws.NewHandler(ws.ServerOptions{ + Authenticate: authenticate, + CheckOrigin: allowOrigin, + OnConnect: func(peer *engine.Peer) { connected <- peer }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + conn, _, err := websocket.Dial(ctx, server.URL, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.CloseNow() }) + return receive(t, connected), conn, ctx +} + +// TestAPeerEndsAWebSocketWithTheCodeItChose: over a real WebSocket, the close +// frame carries what the peer decided โ€” 4011 and the refusal for a frame the +// protocol does not admit, a sendable code and its reason as asked โ€” and an +// observe-only code (R27) sends no close frame at all: the connection is +// dropped, and the far side reads no status. +func TestAPeerEndsAWebSocketWithTheCodeItChose(t *testing.T) { + // A close chosen outside the read loop can lose its close frame, in + // v0.6.0 as here: the peer's end cancels its context before the close + // handshake, and coder/websocket closes the socket as soon as the context + // of its pending Read ends, so the far side often reads a dropped + // connection instead. A refusal is chosen on the read loop and is not + // raced. The rows stay to state what is meant. + const inherited = "inherited from v0.6.0: Peer.end cancels the peer's context, which coder/websocket's pending Read turns into a dropped socket, before it sends the close frame" + for _, c := range []struct { + name string + end func(ctx context.Context, t *testing.T, peer *engine.Peer, conn *websocket.Conn) + code websocket.StatusCode + reason string + skip string + }{ + { + name: "a refused frame", + end: func(ctx context.Context, t *testing.T, _ *engine.Peer, conn *websocket.Conn) { + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"version":1,"kind":"event"}`)); err != nil { + t.Fatal(err) + } + }, + code: websocket.StatusCode(transports.CodeProtocol), + reason: "invalid duplex frame shape", + }, + { + name: "a policy violation", + end: func(_ context.Context, _ *testing.T, peer *engine.Peer, _ *websocket.Conn) { + _ = peer.Wire().Close(1008, "why") + }, + code: websocket.StatusPolicyViolation, + reason: "why", + skip: inherited, + }, + { + name: "a close this side chose", + end: func(_ context.Context, _ *testing.T, peer *engine.Peer, _ *websocket.Conn) { + _ = peer.Close() + }, + code: websocket.StatusNormalClosure, + skip: inherited, + }, + { + name: "an abnormal closure, which is only observed", + end: func(_ context.Context, _ *testing.T, peer *engine.Peer, _ *websocket.Conn) { + _ = peer.Wire().Close(1006, "x") + }, + code: -1, + }, + { + name: "no status, which is only observed", + end: func(_ context.Context, _ *testing.T, peer *engine.Peer, _ *websocket.Conn) { + _ = peer.Wire().Close(1005, "x") + }, + code: -1, + }, + } { + t.Run(c.name, func(t *testing.T) { + if c.skip != "" { + t.Skip(c.skip) + } + peer, conn, ctx := rawClient(t) + c.end(ctx, t, peer, conn) + var err error + for err == nil { + _, _, err = conn.Read(ctx) + } + if got := websocket.CloseStatus(err); got != c.code { + t.Fatalf("the far side read status %d (%v), want %d", got, err, c.code) + } + var closed websocket.CloseError + if c.code != -1 && (!errors.As(err, &closed) || closed.Reason != c.reason) { + t.Fatalf("the far side read %v, want the reason %q", err, c.reason) + } + receive(t, peer.Done()) + if !errors.Is(peer.Err(), transports.ErrClosed) { + t.Fatalf("the peer ended with %v", peer.Err()) + } + }) + } +} diff --git a/engine/websocket/go/prepare_test.go b/engine/websocket/go/prepare_test.go new file mode 100644 index 0000000..7a1003c --- /dev/null +++ b/engine/websocket/go/prepare_test.go @@ -0,0 +1,159 @@ +package websocket_test + +// Ported from Nightseam v0.6.0 runtime/go/prepare_test.go +// (5cc9723a24646c40ed1861f892b2b23eb6d785d7): Prepare on the WebSocket +// constructors. + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + ws "github.com/Bitspark/bitruntime/engine/websocket/go" +) + +// A receiver attached in Prepare is there before the peer has read anything, +// so the client's first request โ€” the natural first act of a consumer that +// came for something โ€” meets it rather than method_not_found. The hook takes +// a millisecond here on purpose: with Prepare, how long the attachment takes +// cannot matter, because no frame is read while it runs. v0.6.0 held this +// with a tunnel whose channel.open was the first request, and measured the +// same install in OnConnect refusing 868 of 1000 opens; tunnels are not +// ported, and a dispatcher at the root is the attachment here. +func TestAReceiverAttachedInPrepareMeetsTheFirstRequest(t *testing.T) { + authenticate, allowOrigin := allow() + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: engine.Options{Prepare: func(peer *engine.Peer) error { + time.Sleep(time.Millisecond) + return serve(map[string]func(context.Context, *engine.Peer) (any, error){ + "probe": func(context.Context, *engine.Peer) (any, error) { return true, nil }, + })(peer) + }}, + Authenticate: authenticate, + CheckOrigin: allowOrigin, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + iterations := 3000 + if testing.Short() { + iterations = 250 + } + for i := range iterations { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{}) + if err != nil { + cancel() + t.Fatalf("iteration %d: dial: %v", i, err) + } + var ready bool + if err := dispatch.Call(ctx, client.Wire(), []string{"probe"}, nil, &ready); err != nil || !ready { + var public *core.PublicError + if errors.As(err, &public) { + t.Fatalf("iteration %d: the first request was refused %s", i, public.Code) + } + t.Fatalf("iteration %d: the first request was refused: %v", i, err) + } + _ = client.Close() + cancel() + } +} + +// Prepare is where a peer's own receiver goes, and it holds the peer alone: +// what it attaches is attached to a peer nothing has reached yet. OnConnect +// runs after it, on a peer that is live. +func TestPrepareInstallsBeforeTheFirstFrameAndOnConnectSeesALivePeer(t *testing.T) { + order := make(chan string, 2) + authenticate, allowOrigin := allow() + handler, err := ws.NewHandler(ws.ServerOptions{ + Options: engine.Options{Prepare: func(peer *engine.Peer) error { + order <- "prepare" + return serve(map[string]func(context.Context, *engine.Peer) (any, error){ + "probe": func(context.Context, *engine.Peer) (any, error) { return map[string]any{"ready": true}, nil }, + })(peer) + }}, + Authenticate: authenticate, + CheckOrigin: allowOrigin, + OnConnect: func(*engine.Peer) { order <- "connect" }, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var answer struct { + Ready bool `json:"ready"` + } + if err := dispatch.Call(ctx, client.Wire(), []string{"probe"}, map[string]any{}, &answer); err != nil || !answer.Ready { + t.Fatalf("the receiver Prepare attached did not answer: %v", err) + } + if first, second := receive(t, order), receive(t, order); first != "prepare" || second != "connect" { + t.Fatalf("hooks ran %s then %s", first, second) + } +} + +func TestPrepareFailingFailsAcceptAndClosesTheSocket(t *testing.T) { + refusal := errors.New("this server serves nothing") + accepted := make(chan error, 1) + authenticate, allowOrigin := allow() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := ws.Accept(w, r, ws.ServerOptions{ + Options: engine.Options{Prepare: func(*engine.Peer) error { return refusal }}, + Authenticate: authenticate, + CheckOrigin: allowOrigin, + }) + accepted <- err + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, server.URL, nil) + if err != nil { + t.Fatal(err) + } + defer conn.CloseNow() + // The upgrade was answered, so the client is told why the protocol will + // not be spoken over it rather than left reading a socket in silence. + _, _, err = conn.Read(ctx) + var closed websocket.CloseError + if !errors.As(err, &closed) || closed.Code != websocket.StatusPolicyViolation { + t.Fatalf("the refused socket ended with %v", err) + } + if err := receive(t, accepted); !errors.Is(err, refusal) { + t.Fatalf("Accept answered %v", err) + } +} + +func TestPrepareFailingFailsDial(t *testing.T) { + refusal := errors.New("this client serves nothing") + authenticate, allowOrigin := allow() + handler, err := ws.NewHandler(ws.ServerOptions{Authenticate: authenticate, CheckOrigin: allowOrigin}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + peer, _, err := ws.Dial(ctx, server.URL, ws.DialOptions{Options: engine.Options{Prepare: func(*engine.Peer) error { return refusal }}}) + if !errors.Is(err, refusal) || peer != nil { + t.Fatalf("Dial answered peer=%v error=%v", peer, err) + } +} From 9c93bb77b9b8a592b364962c0fc90414e715b229 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:19:07 +0200 Subject: [PATCH 27/39] engine: hold the fixed peer end and close code in the ported tests The ported engine tests skipped the WebSocket close of a peer ended from outside and accepted cancelled for a call the peer's end cut off, both inherited from v0.6.0. With the peer now closing its connection before cancelling its context and answering such a call disconnected, the tests require exactly that: 20 runs under -race deliver the chosen code. Co-Authored-By: Claude Opus 5.5 (1M context) --- engine/go/peer_test.go | 9 ++++----- engine/websocket/go/peer_test.go | 7 ------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/engine/go/peer_test.go b/engine/go/peer_test.go index 088fe24..dd3bd0b 100644 --- a/engine/go/peer_test.go +++ b/engine/go/peer_test.go @@ -634,10 +634,9 @@ func TestRequestsQueuedInTheRootAreAnsweredWhenThePeerEnds(t *testing.T) { _ = peer.Close() close(first.release) - // The request already handed to the peer is answered by its own waiter. - // Which code it carries is a race inherited from v0.6.0: the waiter's - // context derives from the peer's, which ends at the moment the peer does, - // so it reads either the end (disconnected) or its context (cancelled). + // The request already handed to the peer is answered by its own waiter, + // disconnected: its context derives from the peer's, but the peer's end is + // not a withdrawal. v0.6.0 answered cancelled or disconnected at random. answers := map[string]bool{} for range 2 { f := receive(t, first.responses) @@ -646,7 +645,7 @@ func TestRequestsQueuedInTheRootAreAnsweredWhenThePeerEnds(t *testing.T) { } answers[f.Error.Code] = true } - if !answers["invalid_message"] || !(answers["disconnected"] || answers["cancelled"]) { + if !answers["invalid_message"] || !answers["disconnected"] || answers["cancelled"] { t.Fatalf("the first return capability was answered %v", answers) } if f := receive(t, queued.responses); f.Error == nil || f.Error.Code != "disconnected" || f.ID != "c:1" { diff --git a/engine/websocket/go/peer_test.go b/engine/websocket/go/peer_test.go index 4706c34..84aff99 100644 --- a/engine/websocket/go/peer_test.go +++ b/engine/websocket/go/peer_test.go @@ -272,13 +272,11 @@ func TestAPeerEndsAWebSocketWithTheCodeItChose(t *testing.T) { // of its pending Read ends, so the far side often reads a dropped // connection instead. A refusal is chosen on the read loop and is not // raced. The rows stay to state what is meant. - const inherited = "inherited from v0.6.0: Peer.end cancels the peer's context, which coder/websocket's pending Read turns into a dropped socket, before it sends the close frame" for _, c := range []struct { name string end func(ctx context.Context, t *testing.T, peer *engine.Peer, conn *websocket.Conn) code websocket.StatusCode reason string - skip string }{ { name: "a refused frame", @@ -297,7 +295,6 @@ func TestAPeerEndsAWebSocketWithTheCodeItChose(t *testing.T) { }, code: websocket.StatusPolicyViolation, reason: "why", - skip: inherited, }, { name: "a close this side chose", @@ -305,7 +302,6 @@ func TestAPeerEndsAWebSocketWithTheCodeItChose(t *testing.T) { _ = peer.Close() }, code: websocket.StatusNormalClosure, - skip: inherited, }, { name: "an abnormal closure, which is only observed", @@ -323,9 +319,6 @@ func TestAPeerEndsAWebSocketWithTheCodeItChose(t *testing.T) { }, } { t.Run(c.name, func(t *testing.T) { - if c.skip != "" { - t.Skip(c.skip) - } peer, conn, ctx := rawClient(t) c.end(ctx, t, peer, conn) var err error From 282b9ecb662ece37c8725bbfde616a7bef94897c Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:22:01 +0200 Subject: [PATCH 28/39] docs: describe the runtime path and record its release unit README, charter and layout name the delivered components, the Go packages of the one root module and the TypeScript package's subpaths, why they are released together, and the interoperability evidence. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHARTER.md | 25 +++++++++++++++++-------- LAYOUT.md | 23 ++++++++++++++--------- README.md | 49 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/CHARTER.md b/CHARTER.md index cb22cf1..116f0cf 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -57,13 +57,19 @@ Each release states: - which protocol revisions it implements (`bitwire/1`, โ€ฆ); - which conformance suite revision it passes. -Modules are versioned independently. The first structural-core milestone uses -one root Go module, `github.com/Bitspark/bitruntime`, with package `core/go`, and -one TypeScript package, `@bitspark/bitruntime-core`, in `core/ts`. The initial -root `v0.1.0` tag versions these two implementations together. Future components -need their module boundaries recorded before joining this release unit or -publishing separately. Before 1.0 there is no compatibility promise. There are -no aliases or re-exports of Nightseam. +Modules are versioned independently. The runtime path's components โ€” core, +transports, engine and dispatch โ€” form one release unit: one root Go module, +`github.com/Bitspark/bitruntime`, whose packages are `core/go`, +`transports/go`, `transports/websocket/go`, `engine/go`, `engine/websocket/go` +and `dispatch/go`, and one TypeScript package, `@bitspark/bitruntime`, with a +subpath per component. Root tags version them together; `v0.1.0` released the +structural core alone as `@bitspark/bitruntime-core`. They move together because +the engine and the pair create the invocations and received context that +dispatch reads, which the TypeScript package keeps private to itself. Later +components (live references, tunnels, telemetry, authentication integration) +record their module boundaries before joining this unit or publishing +separately. Before 1.0 there is no compatibility promise. There are no aliases +or re-exports of Nightseam. The initial release process publishes Go through its source tag and TypeScript as a GitHub release tarball with checksums. Registry publication is separately @@ -76,7 +82,10 @@ configured and cannot be inferred from the presence of a tarball. See module in Bitwire. - The initial core runs its actual implementations against Bitwire's independent structural oracle as well as native edge cases. These observations do not - stand in for the still-pending carrier/runtime suites. + stand in for the carrier/runtime suites, which Bitwire runs from its own + test-only module. +- Nightseam v0.6.0's `bitwire/1` tables, vendored byte for byte in + `vectors/bitwire-1`, and the byte-level transcripts of `scripts/interop.mjs`. - The portable byte vectors for `bitwire-stream/1`. - Interoperability runs against Nightseam v0.6.0 peers, until the last consumer moves. diff --git a/LAYOUT.md b/LAYOUT.md index c43f407..3548789 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -34,15 +34,20 @@ rewrite an immutable published release or bypass a frozen-foundation policy. ## Adoption in this repository -The initial structural core is delivered in `core/{go,ts}`. Its Go module -manifest stays at the root; TypeScript package metadata stays in `core/ts`. -The next runtime milestone uses `transports/{go,ts}`, `engine/{go,ts}` and -`dispatch/{go,ts}`. Later modules use the same shape: -`live/{go,ts}`, `tunnel/{go,ts}`, `telemetry/{go,ts}` and -`auth-integration/{go,ts}`. Those are intended paths, not delivered packages. -Do not create empty language packages. The module/package coordinates and -release process are specified in [RELEASING.md](RELEASING.md) for the core; -later modules need their own explicit allocation before release. +The runtime path is delivered in `core/{go,ts}`, `transports/{go,ts}`, +`transports/websocket/go`, `engine/{go,ts}`, `engine/websocket/go` and +`dispatch/{go,ts}`. The Go module manifest and the TypeScript package manifest +both stay at the repository root, as build manifests of one release unit; +sources, native tests and the packages' contents stay under each component's +language directory. Shared, non-public machinery lives in +`internal//go` and `core/ts/src/internal`. Language-neutral test data +lives in `vectors/`, and test-only interoperability programs in +`conformance/interop//`. + +Later modules use the same shape: `live/{go,ts}`, `tunnel/{go,ts}`, +`telemetry/{go,ts}` and `auth-integration/{go,ts}`. Those are intended paths, +not delivered packages. Do not create empty language packages. Record a later +module's coordinates in [RELEASING.md](RELEASING.md) before it is released. Start the first consumer migration with the [kickoff prompt](docs/bitsystem3-migration-kickoff.md). diff --git a/README.md b/README.md index 2c9e36c..2a52b26 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,24 @@ The Go and TypeScript implementation of the [Bitwire](https://github.com/Bitspark/bitwire) contract. -**Status: structural core 0.1.0 implemented.** It provides -full generic tree construction and selection, decomposition/reconstruction, -derived sending and an explicit addressed-access adapter. See -[Go](core/go/README.md) and [TypeScript](core/ts/README.md). - -Transports, carriers, the protocol engine, invocation lifecycle, dispatch, -live references and tunnels remain planned under +**Status: the runtime path hand-written adapters use is implemented** (the next +release after the structural core 0.1.0): + +- **core:** full tree construction, selection, decomposition and derived + sending; the addressed operators `At`, `Mount` and `Forward`; the local pair; + the invocation lifecycle. +- **transports:** the frame transport seam, the in-memory pipe and WebSocket. +- **engine:** the `bitwire/1` protocol engine and WebSocket connection setup. +- **dispatch:** the dispatcher and the `Call`, `Emit`, `Handle` and `Register` + helpers. + +The runtime is ported from Nightseam v0.6.0 with its provenance in `NOTICE`, and +fixes Nightseam's recorded defects in this path; see +[the port record](docs/port-from-nightseam.md). The engine interoperates with +Nightseam v0.6.0 peers in both roles and both languages and sends the same +bytes (`node scripts/interop.mjs`). Live references, tunnels, the framed byte +stream, telemetry and authentication integration remain planned under [Bitwire decision 0010](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0010-bitwire-holds-the-contract-and-bitruntime-implements-it.md). -Consumer networking still uses frozen Nightseam v0.6.0 until that migration is -delivered; the structural core is not a replacement carrier runtime. ## Where it sits @@ -68,11 +76,24 @@ and message/return-capability identity. ## Packages -Go uses module `github.com/Bitspark/bitruntime` and package `core/go`. -TypeScript uses `@bitspark/bitruntime-core` from `core/ts`. Both depend on the -public Bitwire 0.3.0 contract. The initial release process publishes a root Go -tag and a TypeScript tarball with checksums on GitHub; npm registry publication -is not configured. Read [RELEASING.md](RELEASING.md) for validation and delivery. +Go uses one module, `github.com/Bitspark/bitruntime`, released by root tags: + +| Package | Holds | +| --- | --- | +| `core/go` | Trees, `At`, `Mount`, `Forward`, `NewPair`, the invocation lifecycle, `Respond`, `PublicError` | +| `transports/go` | The seam, `Pipe`, close codes and `Sendable`, the closed classification `ErrClosed` | +| `transports/websocket/go` | The WebSocket transport | +| `engine/go` | The `bitwire/1` `Peer` over any transport | +| `engine/websocket/go` | `Accept`, `NewHandler` and `Dial` over WebSockets | +| `dispatch/go` | `NewDispatcher`, `Call`, `Emit`, `Handle`, `Register` | + +A program links only the packages it imports; `coder/websocket` and `net/http` +enter only through the WebSocket packages. TypeScript uses one package, +`@bitspark/bitruntime`, with the subpaths `./core`, `./transports`, `./engine` +and `./dispatch`, so the received context its components share stays private +to the package. Both depend on the public Bitwire 0.3.0 contract. Releases +publish a root Go tag and a TypeScript tarball with checksums on GitHub; npm +registry publication is not configured. Read [RELEASING.md](RELEASING.md). The generic core uses Bitwire's native node declarations. TypeScript accepts Bitstore's matching structural node type directly; Go requires an explicit From 9d3d3f63e916621329f6f4038d4bebf48f6ccb6a Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:26:04 +0200 Subject: [PATCH 29/39] core, dispatch, engine, transports: adapt the v0.6.0 TypeScript runtime to Bitwire 0.3.0 One package, @bitspark/bitruntime 0.2.0, built at the repository root, replaces core/ts's own manifest. It exports the subpaths ./core, ./transports, ./engine and ./dispatch; sources stay under /ts/src and import each other by relative path. The received context, frame validation, the envelope codec, path encoding, the request primitive and the Unicode guard live in core/ts/src/internal, which no subpath exports, so received context stays unforgeable. - transports: the seam, pipe and WebSocket adapter, the close-code constants and sendable; close refuses an observe-only code (R27). - core: at, mount, forward, pair (PairOptions of its own), the invocation lifecycle, PublicError (was DuplexError), UnpublishedError, respond, trace propagation; MissingPathError, InvalidPathError and ReceiverExistsError replace the duplex wire error codes. - dispatch: call, emit, handle, register, onEvent, Dispatcher, SelectedEndpoint; handler contexts no longer reach the carrier. - engine: Peer, presented only through wire(); a name that encodes no path is answered method_not_found, as a v0.6.0 peer without that handler answers. bitwire/1 frames are unchanged. Fixed rather than ported, as in Go: nightseam#722 (a closing pair and the peer's root answer queued refusals), nightseam#658 (a pair response frees its slot before the caller holds it), R26 (every ended carrier reports PublicError 'disconnected' with its cause), research 0001 row 14 (forward fails only the refused message). Since every request now takes the root, local frames omit empty trace members and the root forwards the trace a request arrived with; v0.6.0's root refused its own answer to a request carrying a tracestate alone. Removed: the peer's raw handle/onEvent/call/emit and options.dispatch, context.peer, observers and family labels. Co-Authored-By: Claude Opus 5.5 (1M context) --- NOTICE | 14 +- core/ts/LICENSE | 202 ---- core/ts/NOTICE | 9 - core/ts/package-lock.json | 39 - core/ts/package.json | 20 - core/ts/src/addressed.ts | 102 +- core/ts/src/error.ts | 54 +- core/ts/src/forward.ts | 51 + core/ts/src/index.ts | 190 +--- core/ts/src/internal/context.ts | 87 ++ .../src => core/ts/src/internal}/envelope.ts | 12 +- core/ts/src/internal/frame.ts | 104 ++ core/ts/src/internal/limits.ts | 33 + core/ts/src/internal/path.ts | 57 ++ core/ts/src/internal/request.ts | 165 ++++ core/ts/src/internal/trace.ts | 25 + core/ts/src/{ => internal}/unicode.ts | 5 +- core/ts/src/invocation.ts | 34 +- core/ts/src/meta.ts | 7 + core/ts/src/pair.ts | 248 ++--- core/ts/src/respond.ts | 71 ++ core/ts/src/trace.ts | 15 - core/ts/src/tree.ts | 143 +++ core/ts/test/bitwire-conformance.test.mjs | 2 +- core/ts/test/core.test.mjs | 2 +- core/ts/tsconfig.check.json | 5 - core/ts/tsconfig.json | 13 - dispatch/ts/src/dispatch.ts | 224 +++++ dispatch/ts/src/dispatcher.ts | 61 +- dispatch/ts/src/index.ts | 21 + dispatch/ts/src/wire.ts | 896 ------------------ engine/ts/src/index.ts | 7 + engine/ts/src/peer.ts | 718 ++++---------- engine/ts/src/wire.ts | 297 ++++++ package-lock.json | 94 ++ package.json | 30 + transports/ts/src/{transport.ts => index.ts} | 76 +- tsconfig.check.json | 5 + tsconfig.json | 16 + 39 files changed, 1987 insertions(+), 2167 deletions(-) delete mode 100644 core/ts/LICENSE delete mode 100644 core/ts/NOTICE delete mode 100644 core/ts/package-lock.json delete mode 100644 core/ts/package.json create mode 100644 core/ts/src/forward.ts create mode 100644 core/ts/src/internal/context.ts rename {engine/ts/src => core/ts/src/internal}/envelope.ts (94%) create mode 100644 core/ts/src/internal/frame.ts create mode 100644 core/ts/src/internal/limits.ts create mode 100644 core/ts/src/internal/path.ts create mode 100644 core/ts/src/internal/request.ts create mode 100644 core/ts/src/internal/trace.ts rename core/ts/src/{ => internal}/unicode.ts (91%) create mode 100644 core/ts/src/meta.ts create mode 100644 core/ts/src/respond.ts create mode 100644 core/ts/src/tree.ts delete mode 100644 core/ts/tsconfig.check.json delete mode 100644 core/ts/tsconfig.json create mode 100644 dispatch/ts/src/dispatch.ts create mode 100644 dispatch/ts/src/index.ts delete mode 100644 dispatch/ts/src/wire.ts create mode 100644 engine/ts/src/index.ts create mode 100644 engine/ts/src/wire.ts create mode 100644 package-lock.json create mode 100644 package.json rename transports/ts/src/{transport.ts => index.ts} (75%) create mode 100644 tsconfig.check.json create mode 100644 tsconfig.json diff --git a/NOTICE b/NOTICE index f1dbe39..ed039c4 100644 --- a/NOTICE +++ b/NOTICE @@ -36,8 +36,18 @@ as their own commit, and every modification is a later commit. runtime/go/http.go engine/websocket/go runtime/go/json.go, internal/scalarjson internal/profile/go - duplex/ts/src, runtime/ts/src transports/ts, core/ts, engine/ts, - dispatch/ts + duplex/ts/src/index.ts transports/ts + duplex/ts/src/wire.ts core/ts/src/addressed.ts, + core/ts/src/internal/path.ts + runtime/ts/src/wire-pair.ts, + invocation.ts, trace.ts, + error.ts core/ts/src + runtime/ts/src/envelope.ts, + unicode.ts core/ts/src/internal + runtime/ts/src/wire.ts dispatch/ts/src, core/ts/src/forward.ts, + core/ts/src/internal, engine/ts/src + runtime/ts/src/dispatcher.ts dispatch/ts/src/dispatcher.ts + runtime/ts/src/peer.ts engine/ts/src/peer.ts runtime/go and runtime/ts tests the corresponding *_test.go and ts/test The files under vectors/bitwire-1/ are byte-identical copies of Nightseam diff --git a/core/ts/LICENSE b/core/ts/LICENSE deleted file mode 100644 index d645695..0000000 --- a/core/ts/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/core/ts/NOTICE b/core/ts/NOTICE deleted file mode 100644 index 12ea446..0000000 --- a/core/ts/NOTICE +++ /dev/null @@ -1,9 +0,0 @@ -bitruntime -Copyright 2026 Bitspark and the bitruntime contributors - -This product includes software developed at Bitspark (https://github.com/Bitspark). - -The structural-core implementations are written against the public Bitwire -0.3.0 contract. Structural test expectations are derived from Bitwire's -independent conformance/trees/expected.json under Apache-2.0. No Nightseam -runtime source is ported in the v0.1.0 structural core. diff --git a/core/ts/package-lock.json b/core/ts/package-lock.json deleted file mode 100644 index 42dbda7..0000000 --- a/core/ts/package-lock.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@bitspark/bitruntime-core", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@bitspark/bitruntime-core", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@bitspark/bitwire": "0.3.0" - }, - "devDependencies": { - "typescript": "5.9.3" - } - }, - "node_modules/@bitspark/bitwire": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.3.0.tgz", - "integrity": "sha512-6tqme+2nfJAp2xwmYIrO7hSSh+6TrJ7gPIb8SZX4dQAoZZHs/iqPPsZFv8vJsMUBejzgBxwNtOoC3PjI0KMPRw==", - "license": "Apache-2.0" - }, - "node_modules/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" - } - } - } -} diff --git a/core/ts/package.json b/core/ts/package.json deleted file mode 100644 index 4401113..0000000 --- a/core/ts/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@bitspark/bitruntime-core", - "version": "0.1.0", - "description": "Immutable WireTree construction and structural interaction operators.", - "license": "Apache-2.0", - "type": "module", - "repository": { "type": "git", "url": "git+https://github.com/Bitspark/bitruntime.git", "directory": "core/ts" }, - "types": "./dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist", "README.md", "LICENSE", "NOTICE"], - "sideEffects": false, - "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" }, - "scripts": { - "check": "tsc -p tsconfig.check.json", - "build": "tsc -p tsconfig.json", - "test": "npm run build && node --test test/*.test.mjs" - }, - "dependencies": { "@bitspark/bitwire": "0.3.0" }, - "devDependencies": { "typescript": "5.9.3" } -} diff --git a/core/ts/src/addressed.ts b/core/ts/src/addressed.ts index e85e896..721a5ce 100644 --- a/core/ts/src/addressed.ts +++ b/core/ts/src/addressed.ts @@ -1,78 +1,16 @@ -import type { Endpoint, Path, Receiver, Wire } from '@bitspark/bitwire'; +import type { AddressedWire, Endpoint, Path, Receiver } from '@bitspark/bitwire'; +import { MissingPathError, ReceiverExistsError } from './error.ts'; +import { ended } from './internal/frame.ts'; +import { encodePath } from './internal/path.ts'; -// The public Nightseam names present the shared contract's actual declarations. -export type { - Path, - ProfileKind, - ProfileFrame, - ProfileError, - ReturnAddress, - Message, - Receiver, - Wire, - Endpoint, -} from '@bitspark/bitwire'; - -export class WireError extends Error { - readonly code: 'closed' | 'no_route' | 'receiver_exists' | 'invalid_path'; - constructor(code: WireError['code']) { - super(`Wire ${code}.`); - this.name = 'WireError'; - this.code = code; - } -} - -function scalar(value: string): void { - for (let i = 0; i < value.length; i++) { - const unit = value.charCodeAt(i); - if (unit >= 0xd800 && unit <= 0xdbff) { - const low = value.charCodeAt(++i); - if (!(low >= 0xdc00 && low <= 0xdfff)) throw new WireError('invalid_path'); - } else if (unit >= 0xdc00 && unit <= 0xdfff) throw new WireError('invalid_path'); - } -} - -/** UTF-8 byte-length-prefixed segments; [] is '', whereas [''] is '0:'. */ -export function encodePath(path: Path): string { - const encoder = new TextEncoder(); - return path - .map((segment) => { - scalar(segment); - return `${encoder.encode(segment).length}:${segment}`; - }) - .join(''); -} -/** Accepts only the canonical encoding, retaining dots, empty strings and BOMs. */ -export function decodePath(encoded: string): string[] { - scalar(encoded); - const bytes = new TextEncoder().encode(encoded); - const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); - const path: string[] = []; - for (let offset = 0; offset < bytes.length;) { - const start = offset; - let length = 0; - while (offset < bytes.length && bytes[offset] !== 58) { - const digit = bytes[offset++]! - 48; - if (digit < 0 || digit > 9) throw new WireError('invalid_path'); - length = length * 10 + digit; - if (!Number.isSafeInteger(length)) throw new WireError('invalid_path'); - } - if (offset === start || offset === bytes.length || (offset - start > 1 && bytes[start] === 48)) - throw new WireError('invalid_path'); - offset++; - if (length > bytes.length - offset) throw new WireError('invalid_path'); - try { - path.push(decoder.decode(bytes.subarray(offset, offset + length))); - } catch { - throw new WireError('invalid_path'); - } - offset += length; - } - return path; -} - -/** Selects send access without granting receive attachment or closure authority. */ -export function at(root: Wire, path: Path): Wire { +/** + * Binds a relative path prefix to addressed access without allocating a peer, + * channel or queue: at(w, []) โ‰ƒ w and at(at(w, a), b) โ‰ƒ at(w, a ++ b). The + * result grants only send access, even when path is empty. This is addressed + * prefix binding, not structural selection: whether the root admits what is + * sent through it is the root's to decide. + */ +export function at(root: AddressedWire, path: Path): AddressedWire { const prefix = [...path]; return { send: (suffix, message) => root.send([...prefix, ...suffix], message), @@ -92,16 +30,20 @@ interface Attachment { * Consumes one path segment; [] has no leaf, and [''] can select an empty key. * One owning receiver spans the borrowed children and sees their keys restored. * Copies the map. Closing detaches this mount's attachment, never children. + * A path that selects no child is refused with MissingPathError, an invalid + * segment with InvalidPathError, a second receiver with ReceiverExistsError, + * and anything after close as `disconnected`. */ export function mount(children: ReadonlyMap): Endpoint { const routes = new Map(children); let attachment: Attachment | undefined; let closed = false; const destination = (path: Path): Endpoint => { - if (closed) throw new WireError('closed'); + if (closed) throw ended(); encodePath(path); - const child = path.length ? routes.get(path[0]!) : undefined; - if (!child) throw new WireError('no_route'); + if (!path.length) throw new MissingPathError(); + const child = routes.get(path[0]!); + if (!child) throw new MissingPathError(); return child; }; const release = (child: ChildAttachment): void => { @@ -118,8 +60,8 @@ export function mount(children: ReadonlyMap): Endpoint { if (ending) held.receiver.closed?.(ending.code, ending.reason); }; const receive = (receiver: Receiver): (() => void) => { - if (closed) throw new WireError('closed'); - if (attachment) throw new WireError('receiver_exists'); + if (closed) throw ended(); + if (attachment) throw new ReceiverExistsError(); const held: Attachment = { receiver, active: true, children: [] }; attachment = held; let remaining = routes.size; @@ -142,7 +84,7 @@ export function mount(children: ReadonlyMap): Endpoint { // Its returned disposer still belongs to this acquisition attempt. if (!held.active || !slot.active) { detach(); - throw new WireError('closed'); + throw ended(); } slot.detach = detach; } diff --git a/core/ts/src/error.ts b/core/ts/src/error.ts index 0bfef65..1b58f5a 100644 --- a/core/ts/src/error.ts +++ b/core/ts/src/error.ts @@ -1,27 +1,63 @@ -/** Public application errors may cross the wire; other handler errors are hidden. */ -export class DuplexError extends Error { - /** The error's code as it travels on the wire: the profile's own, or a family's public error by name. */ +/** A requested structural path does not exist. No primitive was called. */ +export class MissingPathError extends Error { + readonly code = 'missing_path'; + constructor() { + super('No node exists at the requested path'); + this.name = 'MissingPathError'; + } +} + +/** A carrier path contains a segment outside the Unicode-scalar contract. */ +export class InvalidPathError extends Error { + readonly code = 'invalid_path'; + constructor() { + super('Addressed paths require Unicode-scalar string segments'); + this.name = 'InvalidPathError'; + } +} + +/** An endpoint already has its one owning receiver; a second attachment is refused. */ +export class ReceiverExistsError extends Error { + readonly code = 'receiver_exists'; + constructor() { + super('The endpoint already has a receiver'); + this.name = 'ReceiverExistsError'; + } +} + +/** + * Public application errors may cross the wire; other handler errors are + * hidden. A closed or ended carrier is reported as one with code + * `disconnected`, the one closed classification; its `cause`, where it has + * one, says what ended the carrier. + */ +export class PublicError extends Error { + /** The error's code as it travels on the wire. */ readonly code: string; - /** What a public error carries beside its message, validated as the family declares it. */ + /** What a public error carries beside its message. */ readonly data?: unknown; constructor(code: string, message: string, data?: unknown) { super(message); - this.name = 'DuplexError'; + this.name = 'PublicError'; this.code = code; this.data = data; } } -/** A local refusal before a frame entered the queue or a local implementation dispatched. */ -export class UnpublishedError extends DuplexError { +/** + * A local refusal before a frame entered the queue or a local implementation + * dispatched. The proof belongs to this send attempt: a remote response or a + * queued write failure never carries it, even with the same code. + */ +export class UnpublishedError extends PublicError { override readonly cause: unknown; constructor(cause: unknown) { super( - cause instanceof DuplexError ? cause.code : 'send_failed', + cause instanceof PublicError ? cause.code : 'send_failed', cause instanceof Error ? cause.message : 'Duplex operation failed.', - cause instanceof DuplexError ? cause.data : undefined, + cause instanceof PublicError ? cause.data : undefined, ); this.name = 'UnpublishedError'; this.cause = cause; diff --git a/core/ts/src/forward.ts b/core/ts/src/forward.ts new file mode 100644 index 0000000..e449dfa --- /dev/null +++ b/core/ts/src/forward.ts @@ -0,0 +1,51 @@ +import type { AddressedWire, Endpoint, Receiver } from '@bitspark/bitwire'; +import { publicError } from './internal/frame.ts'; +import { respond } from './respond.ts'; + +/** + * Joins two existing origins without allocating a peer or channel: what either + * endpoint delivers is sent through the other unchanged, with its return + * capability and context, in the order the source delivers it. The returned + * detach removes only the forwarding attachments; both endpoints stay owned by + * their callers, and each root remains responsible for ending its failed + * carrier. + * + * A message the destination refuses fails only that message: a refused + * request is answered through its return capability, without lending it the + * refusal's local publication proof, and forwarding goes on. Forwarding ends + * when either endpoint ends or detach is called. + */ +export function forward(inbound: Endpoint, outbound: Endpoint): () => void { + const removals: (() => void)[] = []; + let detached = false; + const stop = () => { + if (detached) return; + detached = true; + for (const remove of removals) remove(); + }; + const receiver = (destination: AddressedWire): Receiver => ({ + closed: stop, + message: (path, message) => { + // Detach stops new dispatch, not controls for an already captured call. + try { + destination.send(path, message); + } catch (error) { + if (message.frame.kind === 'request') respond(message, undefined, publicError(error)); + } + }, + }); + try { + for (const [source, destination] of [ + [inbound, outbound], + [outbound, inbound], + ] as const) { + const remove = source.receive(receiver(destination)); + if (detached) remove(); + else removals.push(remove); + } + } catch (error) { + stop(); + throw error; + } + return stop; +} diff --git a/core/ts/src/index.ts b/core/ts/src/index.ts index db981d9..c5e01a8 100644 --- a/core/ts/src/index.ts +++ b/core/ts/src/index.ts @@ -1,159 +1,35 @@ -import type { - AddressedWire, Child, DeixisNode, Key, Message, Parts, Path, TreePath, WireTree, -} from '@bitspark/bitwire'; - -/** A requested structural path does not exist. No primitive was called. */ -export class MissingPathError extends Error { - readonly code = 'missing_path'; - constructor() { - super('No node exists at the requested path'); - this.name = 'MissingPathError'; - } -} - -/** A carrier path contains a segment outside the Unicode-scalar contract. */ -export class InvalidPathError extends Error { - readonly code = 'invalid_path'; - constructor() { - super('Addressed paths require Unicode-scalar string segments'); - this.name = 'InvalidPathError'; - } -} - -function keyName(key: Key): string { - if (!(key instanceof Uint8Array)) throw new TypeError('Tree keys must be Uint8Array values'); - let name = ''; - for (const byte of key) name += byte.toString(16).padStart(2, '0'); - return name; -} - -function copyChildren(children: Iterable>): ReadonlyArray> { - const result: Child[] = []; - const names = new Set(); - for (const [key, child] of children) { - const name = keyName(key); - if (names.has(name)) throw new TypeError('Duplicate byte key'); - if (child === null || (typeof child !== 'object' && typeof child !== 'function') || - typeof child.own !== 'function' || typeof child.children !== 'function' || - typeof child.at !== 'function' || typeof child.decompose !== 'function') { - throw new TypeError('A child must implement DeixisNode'); - } - names.add(name); - // Uint8Array.from also copies a Node Buffer; Buffer.slice would alias it. - result.push(Object.freeze([Uint8Array.from(key), child] as const)); - } - return Object.freeze(result); -} - -const constructed = new WeakSet(); - -/** Validate foreign subtrees without recursion or changing their identity. */ -function validateChildren(children: ReadonlyArray>): void { - const active = new WeakSet(); - const visited = new WeakSet(); - const pending: Array, boolean]> = children.map(([, child]) => [child, false]); - while (pending.length > 0) { - const [node, leaving] = pending.pop()!; - if (leaving) { - active.delete(node); - visited.add(node); - continue; - } - if (active.has(node)) throw new TypeError('Structural cycle'); - if (visited.has(node) || constructed.has(node)) continue; - active.add(node); - pending.push([node, true]); - for (const [, child] of copyChildren(node.children())) pending.push([child, false]); - } -} - -class Node implements DeixisNode { - readonly #value: T; - readonly #children: ReadonlyArray>; - readonly #byKey: ReadonlyMap>; - - constructor(own: T, children: ReadonlyArray>) { - this.#value = own; - this.#children = children; - this.#byKey = new Map(children.map(([key, child]) => [keyName(key), child])); - constructed.add(this); - Object.freeze(this); - } - - own(): T { return this.#value; } - - children(): ReadonlyArray> { return copyChildren(this.#children); } - - at(path: TreePath): DeixisNode | undefined { return select(this, path); } - - decompose(): Parts { - return Object.freeze({ own: this.#value, children: this.children() }); - } - - static child(node: Node, key: Key): DeixisNode | undefined { - return node.#byKey.get(keyName(key)); - } -} - /** - * Construct a complete immutable node, retaining own-value and child identity. - * Keys and the child collection are copied. Duplicate keys and structural cycles - * are rejected. Foreign nodes must themselves honor DeixisNode's finite, stable, - * immutable topology contract; validation does not freeze another implementation. + * `@bitspark/bitruntime/core`: immutable WireTree construction and structural + * interaction, the addressed operators (at, mount, forward), the bounded local + * pair, the invocation lifecycle, public errors, responses and trace + * propagation. The received-context machinery the carriers share is private to + * this package and exported by none of its subpaths. */ -export function compose(own: T, children: Iterable> = []): DeixisNode { - const retained = copyChildren(children); - validateChildren(retained); - return new Node(own, retained); -} - -/** Follow exact byte-key edges. Empty path is self; missing edges have no fallback. */ -export function select(tree: DeixisNode, path: TreePath): DeixisNode | undefined { - let current = tree; - for (const key of path) { - const wanted = keyName(key); - const child = current instanceof Node - ? Node.child(current, key) - : current.children().find(([candidate]) => keyName(candidate) === wanted)?.[1]; - if (child === undefined) return undefined; - current = child; - } - return current; -} - -/** Select a node and call exactly its own primitive, preserving message identity. */ -export function send(tree: WireTree, path: TreePath, message: Message): void { - const selected = select(tree, path); - if (selected === undefined) throw new MissingPathError(); - selected.own().send(message); -} - -const encoder = new TextEncoder(); - -function treePath(path: Path): TreePath { - return path.map(segment => { - if (typeof segment !== 'string') throw new InvalidPathError(); - for (let i = 0; i < segment.length; i++) { - const unit = segment.charCodeAt(i); - if (unit >= 0xd800 && unit <= 0xdbff) { - const next = segment.charCodeAt(++i); - if (!(next >= 0xdc00 && next <= 0xdfff)) throw new InvalidPathError(); - } else if (unit >= 0xdc00 && unit <= 0xdfff) { - throw new InvalidPathError(); - } - } - return encoder.encode(segment); - }); -} - -/** - * Expose a full tree through the existing Unicode-string addressed access. - * Segments become exact UTF-8 keys without normalization or slash interpretation. - * Non-UTF-8 tree keys remain valid, but cannot be named through this bridge. - * This grants no receive/close capability and cannot infer a tree from a router. - */ -export function asAddressed(tree: WireTree): AddressedWire { - return Object.freeze({ send(path: Path, message: Message): void { - send(tree, treePath(path), message); - } }); -} +export { compose, select, send, asAddressed } from './tree.ts'; +export { MissingPathError, InvalidPathError, ReceiverExistsError, PublicError, UnpublishedError } from './error.ts'; +export { at, mount } from './addressed.ts'; +export { forward } from './forward.ts'; +export { pair, type PairOptions } from './pair.ts'; +export { respond } from './respond.ts'; +export { + Invocation, + InvocationError, + InvocationCaptureHandle, + InvocationBodyHandle, + captureInvocation, + beginInvocationBody, + relayInvocationControl, + defaultInvocationLimits, + invocationCapture, + invocationReady, + invocationRelease, + invocationBegin, + invocationDone, + invocationControl, + defaultInvocationCaptures, + defaultInvocationBodies, + type InvocationLimits, + type InvocationRefusal, +} from './invocation.ts'; +export { defaultPropagator, type Trace, type TraceContext, type Propagator } from './trace.ts'; +export type { Meta } from './meta.ts'; diff --git a/core/ts/src/internal/context.ts b/core/ts/src/internal/context.ts new file mode 100644 index 0000000..d7b56aa --- /dev/null +++ b/core/ts/src/internal/context.ts @@ -0,0 +1,87 @@ +/** + * The received context bitruntime's own carriers establish and its own helpers + * recognize. It is associated with a local return capability through a + * WeakMap that lives in this module alone, and no package subpath exports this + * module, so no other code can construct, claim or read it: a foreign return + * capability carries no recognized context, and a message's visible fields โ€” + * its meta included โ€” never do. + */ +import type { AddressedWire, Message, ReturnAddress } from '@bitspark/bitwire'; +import { PublicError } from '../error.ts'; +import type { Meta } from '../meta.ts'; +import type { Trace, TraceContext } from '../trace.ts'; + +/** What a carrier established for one admitted request's handler. */ +export interface ReceivedRequestContext extends TraceContext { + signal: AbortSignal; + requestId: string; + meta?: Meta; +} + +/** What a carrier established for one delivered event's listener. */ +export interface ReceivedEventContext extends TraceContext { + meta?: Meta; +} + +/** Received values retained beside a local return capability. */ +export interface DispatchContext { + context: ReceivedRequestContext; + maxFrameBytes: number; + /** A local handler's own withdrawal, which a forwarded reply keeps as its outcome. */ + completion?: { cancelled: boolean }; +} + +// A local capability association, never a field a caller can serialize or +// supply as ambient outgoing metadata. +const dispatchContexts = new WeakMap(); + +/** Read the verified context associated with a local return capability. */ +export function dispatchContext(address: ReturnAddress | undefined): DispatchContext | undefined { + return address ? dispatchContexts.get(address) : undefined; +} + +/** Associate received context without adding it to serialized frame data. */ +export function setDispatchContext(address: ReturnAddress, context: DispatchContext): () => void { + dispatchContexts.set(address, context); + return () => { + if (dispatchContexts.get(address) === context) dispatchContexts.delete(address); + }; +} + +/** Release a return capability's association. */ +export function clearDispatchContext(address: ReturnAddress): void { + dispatchContexts.delete(address); +} + +// An event's local context capability has no waiter, id or callable return. +// Weak ownership lets queued deliveries outlive the source receiver's return. +const eventContexts = new WeakMap(); +const receivedEventTraces = new WeakMap(); + +/** Preserve the received frame's trace independently of a custom propagator's context. */ +export function setReceivedEventTrace(context: ReceivedEventContext, trace: Trace | undefined): void { + receivedEventTraces.set(context, trace); +} + +/** The trace the event's frame arrived with, whatever a propagator placed on its context. */ +export function receivedEventTrace(context: ReceivedEventContext): Trace | undefined { + return receivedEventTraces.has(context) ? receivedEventTraces.get(context) : context.trace; +} + +const eventContextCarrier: AddressedWire = Object.freeze({ + send: () => { + throw new PublicError('invalid_message', 'An event context is not a return address.'); + }, +}); + +/** Inspect only runtime-associated event context, never caller data. */ +export function eventContext(message: Message): ReceivedEventContext | undefined { + return message.return ? eventContexts.get(message.return) : undefined; +} + +/** Carry received event context across local asynchronous composition. */ +export function withEventContext(message: Message, context: ReceivedEventContext): Message { + const address: ReturnAddress = { wire: eventContextCarrier }; + eventContexts.set(address, context); + return { frame: message.frame, return: address }; +} diff --git a/engine/ts/src/envelope.ts b/core/ts/src/internal/envelope.ts similarity index 94% rename from engine/ts/src/envelope.ts rename to core/ts/src/internal/envelope.ts index 2dc42b8..a4fc9a5 100644 --- a/engine/ts/src/envelope.ts +++ b/core/ts/src/internal/envelope.ts @@ -1,6 +1,10 @@ -/** Internal frame validation and carriage shared by the peer and its conformance tests. */ -import { DuplexError } from './error.ts'; -import type { Meta } from './peer.ts'; +/** + * The bitwire/1 envelope: frame validation and carriage shared by the protocol + * engine, the local pair and the request helpers. Module-private: no package + * subpath exports it. + */ +import { PublicError } from '../error.ts'; +import type { Meta } from '../meta.ts'; import { scalarJSON } from './unicode.ts'; /** A decoded JSON envelope; the connection beneath carries it as a text frame. */ @@ -150,7 +154,7 @@ function carriage(frame: Envelope): void { } export function requireName(value: unknown, field: string): asserts value is string { if (typeof value !== 'string' || value.length === 0) - throw new DuplexError('invalid_message', `${field} must be a nonempty string.`); + throw new PublicError('invalid_message', `${field} must be a nonempty string.`); } function requestID(value: unknown, prefix: string): void { if (typeof value !== 'string' || !value.startsWith(prefix) || !/^[1-9][0-9]{0,19}$/.test(value.slice(prefix.length))) diff --git a/core/ts/src/internal/frame.ts b/core/ts/src/internal/frame.ts new file mode 100644 index 0000000..7a9217b --- /dev/null +++ b/core/ts/src/internal/frame.ts @@ -0,0 +1,104 @@ +/** + * Structured profile frames at local admission: snapshots, validation against + * the bitwire/1 envelope, the private identity of an outgoing trace, and the + * public form of an error. Module-private: no package subpath exports it. + */ +import type { ProfileFrame } from '@bitspark/bitwire'; +import { PublicError, UnpublishedError } from '../error.ts'; +import type { Trace } from '../trace.ts'; +import { decodeEnvelope, isObject } from './envelope.ts'; +import { traceOf } from './trace.ts'; +import { scalarJSON } from './unicode.ts'; + +// Outgoing propagators may privately associate their trace object with an +// active consumer context. Keep that identity across local frame snapshots; +// only the two public trace strings cross a physical connection. +const outgoingTraces = new WeakMap(); + +/** The trace an outgoing frame carries, with the identity its propagator gave it. */ +export function outgoingTrace(frame: ProfileFrame): Trace | undefined { + return outgoingTraces.get(frame) ?? traceOf(frame); +} + +/** Associate the trace object an outgoing frame was minted with. */ +export function setOutgoingTrace(frame: ProfileFrame, trace: Trace): void { + outgoingTraces.set(frame, trace); +} + +/** A JSON copy of a value, refused when it is not JSON or not Unicode scalar text. */ +export function snapshot(value: T): T { + try { + const encoded = JSON.stringify(value, (_key, member: unknown) => { + if ( + typeof member === 'function' || + typeof member === 'symbol' || + (typeof member === 'number' && !Number.isFinite(member)) + ) + throw new Error('Not JSON.'); + return member; + }); + scalarJSON(encoded); + return JSON.parse(encoded) as T; + } catch { + throw new PublicError('invalid_message', 'Frame must contain serializable JSON values.'); + } +} + +/** Reuse the physical profile validator at structured admission. */ +export function profileFrame(value: ProfileFrame, name: string, maxFrameBytes?: number): ProfileFrame { + const saved: unknown = snapshot(value); + if (!isObject(saved) || Object.hasOwn(saved, 'method') || Object.hasOwn(saved, 'event')) + throw new PublicError('invalid_message', 'Invalid structured profile frame.'); + // The path is the sole operation name. Reuse the physical profile validator + // after translating that name, without silently replacing an extra member. + const envelope = { + ...saved, + ...(saved.kind === 'request' ? { method: name } : saved.kind === 'event' ? { event: name } : {}), + }; + const text = JSON.stringify(envelope); + if (maxFrameBytes !== undefined && new TextEncoder().encode(text).byteLength > maxFrameBytes) + throw new PublicError('frame_too_large', 'Outgoing frame exceeds the size limit.'); + // Logical request ids belong to local return addresses, not physical roles. + // Both accepted prefixes still use the existing canonical numeric grammar. + const prefix = typeof saved.id === 'string' && saved.id.startsWith('s:') ? 's:' : 'c:'; + try { + decodeEnvelope(text, prefix, prefix); + } catch { + throw new PublicError('invalid_message', 'Invalid structured profile frame.'); + } + const frame = saved as unknown as ProfileFrame; + const associated = outgoingTraces.get(value); + if (associated) outgoingTraces.set(frame, associated); + return frame; +} + +/** + * The public form of an error: a public error keeps its code, message and data, + * reconstructed so that no local publication proof survives dispatch, and any + * other error is the generic internal one. + */ +export function publicError(error: unknown): PublicError { + return error instanceof PublicError && + typeof error.code === 'string' && + error.code.length > 0 && + typeof error.message === 'string' && + error.message.length > 0 + ? new PublicError(error.code, error.message, error.data) + : new PublicError('internal', 'Request handler failed.'); +} + +/** What an ended carrier reports when nothing more specific said so. */ +export const ENDED_MESSAGE = 'Connection ended; outcome may be unknown.'; + +/** + * The one closed classification: an ended carrier reports `disconnected`, + * keeping what ended it as the error's cause. + */ +export function ended(cause?: unknown): PublicError { + // A send attempt's own proof is never lent to what else the ending settles. + if (cause instanceof PublicError && !(cause instanceof UnpublishedError) && cause.code === 'disconnected') + return cause; + const error = new PublicError('disconnected', ENDED_MESSAGE); + if (cause !== undefined) Object.defineProperty(error, 'cause', { value: cause }); + return error; +} diff --git a/core/ts/src/internal/limits.ts b/core/ts/src/internal/limits.ts new file mode 100644 index 0000000..9cb4af9 --- /dev/null +++ b/core/ts/src/internal/limits.ts @@ -0,0 +1,33 @@ +/** Limit validation shared by the local pair and the protocol engine. Module-private. */ +import { PublicError } from '../error.ts'; + +/** The limits a local pair and a peer run with unless their options say otherwise. */ +export const LIMIT_DEFAULTS = Object.freeze({ + maxConcurrentHandlers: 64, + maxPendingRequests: 128, + queueCapacity: 128, + maxFrameBytes: 1_048_576, + requestTimeoutMs: 30_000, + writeTimeoutMs: 10_000, +}); + +/** Validates a component limit, returning it or throwing invalid_options; safe also requires exact integer representation. */ +export function positiveInteger(value: unknown, name: string, safe = false): number { + if (typeof value !== 'number' || !(safe ? Number.isSafeInteger(value) : Number.isInteger(value)) || value <= 0) { + throw new PublicError('invalid_options', `${name} must be a positive ${safe ? 'safe ' : ''}integer.`); + } + return value; +} + +/** Applies validated overrides to a set of defaults. */ +export function limits>(defaults: T, options: Partial>): T { + const result = { ...defaults }; + for (const key of Object.keys(defaults) as (keyof T)[]) { + const value = options[key]; + if (value !== undefined) { + positiveInteger(value, key as string, true); + (result as Record)[key] = value as number; + } + } + return result; +} diff --git a/core/ts/src/internal/path.ts b/core/ts/src/internal/path.ts new file mode 100644 index 0000000..9f12750 --- /dev/null +++ b/core/ts/src/internal/path.ts @@ -0,0 +1,57 @@ +/** + * The canonical encoding of an addressed path as bitwire/1 carries it in a + * frame's method or event name. Module-private: no package subpath exports it. + */ +import type { Path } from '@bitspark/bitwire'; +import { InvalidPathError } from '../error.ts'; + +function scalar(value: string): void { + if (typeof value !== 'string') throw new InvalidPathError(); + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const low = value.charCodeAt(++i); + if (!(low >= 0xdc00 && low <= 0xdfff)) throw new InvalidPathError(); + } else if (unit >= 0xdc00 && unit <= 0xdfff) throw new InvalidPathError(); + } +} + +/** UTF-8 byte-length-prefixed segments; [] is '', whereas [''] is '0:'. */ +export function encodePath(path: Path): string { + const encoder = new TextEncoder(); + return path + .map((segment) => { + scalar(segment); + return `${encoder.encode(segment).length}:${segment}`; + }) + .join(''); +} + +/** Accepts only the canonical encoding, retaining dots, empty strings and BOMs. */ +export function decodePath(encoded: string): string[] { + scalar(encoded); + const bytes = new TextEncoder().encode(encoded); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + const path: string[] = []; + for (let offset = 0; offset < bytes.length; ) { + const start = offset; + let length = 0; + while (offset < bytes.length && bytes[offset] !== 58) { + const digit = bytes[offset++]! - 48; + if (digit < 0 || digit > 9) throw new InvalidPathError(); + length = length * 10 + digit; + if (!Number.isSafeInteger(length)) throw new InvalidPathError(); + } + if (offset === start || offset === bytes.length || (offset - start > 1 && bytes[start] === 48)) + throw new InvalidPathError(); + offset++; + if (length > bytes.length - offset) throw new InvalidPathError(); + try { + path.push(decoder.decode(bytes.subarray(offset, offset + length))); + } catch { + throw new InvalidPathError(); + } + offset += length; + } + return path; +} diff --git a/core/ts/src/internal/request.ts b/core/ts/src/internal/request.ts new file mode 100644 index 0000000..236739c --- /dev/null +++ b/core/ts/src/internal/request.ts @@ -0,0 +1,165 @@ +/** + * The request primitive shared by the public call helper and the protocol + * engine's inbound bridge: one request sent through addressed access with a + * fresh return capability that carries its invocation lifecycle, and the wait + * for its one response. Module-private: no package subpath exports it. + */ +import type { AddressedWire, Path, ProfileFrame, ReturnAddress } from '@bitspark/bitwire'; +import { PublicError, UnpublishedError } from '../error.ts'; +import { Invocation, defaultInvocationLimits } from '../invocation.ts'; +import type { Meta } from '../meta.ts'; +import type { Trace } from '../trace.ts'; +import { clearDispatchContext, setDispatchContext, type DispatchContext } from './context.ts'; +import { carrying } from './envelope.ts'; +import { ended, profileFrame, setOutgoingTrace, snapshot } from './frame.ts'; +import { encodePath } from './path.ts'; +import { traceMembers } from './trace.ts'; + +/** How long a call waits for its response when it states no deadline of its own. */ +export const DEFAULT_TIMEOUT_MS = 30_000; + +/** The completion and deadline of one call. */ +export function requestCompletion() { + let settled = false; + let timer: ReturnType | undefined; + let detach: (() => void) | undefined; + let resolve!: (value: T) => void; + let reject!: (error: PublicError) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + const cleanup = () => { + clearTimeout(timer); + detach?.(); + detach = undefined; + }; + return { + promise, + get settled() { + return settled; + }, + cleanup, + resolve: (value: T) => { + if (!settled) { + settled = true; + cleanup(); + resolve(value); + } + }, + reject: (error: PublicError) => { + if (!settled) { + settled = true; + cleanup(); + reject(error); + } + }, + wait: ( + signal: AbortSignal | undefined, + timeoutMs: number, + method: string, + cancel: (error: PublicError, outcome: 'timeout' | 'cancelled') => void, + ) => { + if (settled) return; + timer = setTimeout( + () => + cancel( + new PublicError('request_timeout', `Call ${method} timed out; its outcome may be unknown.`), + 'timeout', + ), + timeoutMs, + ); + if (signal) { + const abort = () => + cancel(new PublicError('cancelled', 'Call was cancelled; its outcome may be unknown.'), 'cancelled'); + signal.addEventListener('abort', abort, { once: true }); + detach = () => signal.removeEventListener('abort', abort); + if (signal.aborted) abort(); + } + }, + }; +} + +/** What one call may carry. */ +export interface RequestOptions { + signal?: AbortSignal; + timeoutMs?: number; + meta?: Meta; +} + +/** + * Sends one request at path through access and waits for its response. + * `dispatch` is the context a carrier established when this call forwards a + * request it admitted; it is absent for an application's own call, which a + * trace minted for it identifies. + */ +export function request( + access: AddressedWire, + path: Path, + params: unknown, + options: RequestOptions, + trace?: Trace, + dispatch?: DispatchContext, +): Promise { + let name: string; + let frame: ProfileFrame; + try { + name = encodePath(path); + if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) + throw new PublicError('invalid_options', 'timeoutMs must be a positive safe integer.'); + if (options.signal?.aborted) throw new PublicError('cancelled', 'Call was cancelled before sending.'); + frame = snapshot( + carrying({ version: 1, kind: 'request', id: 'c:1', params, ...traceMembers(trace) }, options.meta), + ) as unknown as ProfileFrame; + } catch (error) { + return Promise.reject(new UnpublishedError(error)); + } + if (!dispatch && trace) setOutgoingTrace(frame, trace); + const completion = requestCompletion(); + if (dispatch) dispatch = { ...dispatch, completion: { cancelled: false } }; + const invocation = new Invocation(defaultInvocationLimits()); + const returning: AddressedWire = { + send: (suffix, message) => { + if (suffix.length) { + invocation.deliver(suffix, message); + return; + } + const frame = profileFrame(message.frame, '', dispatch?.maxFrameBytes); + if (frame.kind !== 'response' || frame.id !== 'c:1') + throw new PublicError('invalid_message', 'Invalid wire response.'); + if (completion.settled) throw ended(); + if (frame.error?.code === 'cancelled' && dispatch?.context.signal.aborted && dispatch.completion?.cancelled) + completion.resolve(undefined as T); + else if (frame.error) completion.reject(new PublicError(frame.error.code, frame.error.message, frame.error.data)); + else completion.resolve(frame.result as T); + invocation.settle(); + }, + }; + const address: ReturnAddress = { wire: returning }; + const retire = () => { + clearDispatchContext(address); + invocation.settle(); + invocation.dispatchDone(); + }; + if (dispatch) setDispatchContext(address, dispatch); + void completion.promise.then(retire, retire); + try { + access.send(path, { frame, return: address }); + } catch (error) { + completion.reject(new UnpublishedError(error)); + } + completion.wait(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, name, (error) => { + if (completion.settled) return; + completion.cleanup(); + // An incoming dispatch occupies the carrier's handler budget until the + // receiver replies. Its cancellation ends the caller's wait elsewhere; + // settling this promise here would release a still-executing body. + if (!dispatch) completion.reject(error); + try { + access.send(path, { frame: { version: 1, kind: 'cancel', id: 'c:1', ...traceMembers(trace) }, return: address }); + } catch { + /* Cancellation is best effort and never extends the caller's wait. */ + } + }); + return completion.promise; +} diff --git a/core/ts/src/internal/trace.ts b/core/ts/src/internal/trace.ts new file mode 100644 index 0000000..a4791f1 --- /dev/null +++ b/core/ts/src/internal/trace.ts @@ -0,0 +1,25 @@ +/** Trace members as frames carry them. Module-private: no package subpath exports it. */ +import type { Trace } from '../trace.ts'; + +/** The members an incoming frame carries, verbatim: what a propagator extracts. */ +export function traceOf(frame: { readonly traceparent?: unknown; readonly tracestate?: unknown }): Trace | undefined { + const traceparent = typeof frame.traceparent === 'string' ? frame.traceparent : ''; + const tracestate = typeof frame.tracestate === 'string' ? frame.tracestate : ''; + if (!traceparent && !tracestate) return undefined; + return tracestate ? { traceparent, tracestate } : { traceparent }; +} + +/** The members a structured frame carries for a trace; an empty member is none. */ +export function traceMembers(trace: Trace | undefined): { traceparent?: string; tracestate?: string } { + const members: { traceparent?: string; tracestate?: string } = {}; + if (trace?.traceparent) members.traceparent = trace.traceparent; + if (trace?.tracestate) members.tracestate = trace.tracestate; + return members; +} + +/** Stamps onto an outgoing frame what a propagator minted; an empty member is none. */ +export function traced(envelope: Record, trace: Trace | undefined): Record { + if (trace?.traceparent) envelope.traceparent = trace.traceparent; + if (trace?.tracestate) envelope.tracestate = trace.tracestate; + return envelope; +} diff --git a/core/ts/src/unicode.ts b/core/ts/src/internal/unicode.ts similarity index 91% rename from core/ts/src/unicode.ts rename to core/ts/src/internal/unicode.ts index 7b2cf50..c5e3999 100644 --- a/core/ts/src/unicode.ts +++ b/core/ts/src/internal/unicode.ts @@ -1,4 +1,7 @@ -/** Unicode is checked before parsing or serialization can discard a string. */ +/** + * Unicode is checked before parsing or serialization can discard a string. + * Module-private: no package subpath exports it. + */ const refusal = 'invalid Unicode: expected Unicode scalar strings'; export function scalarString(value: string): void { diff --git a/core/ts/src/invocation.ts b/core/ts/src/invocation.ts index 236b8bb..0abd222 100644 --- a/core/ts/src/invocation.ts +++ b/core/ts/src/invocation.ts @@ -1,11 +1,11 @@ -import type { Message, Path, Wire } from '@nightseam/duplex'; +import type { AddressedWire, Message, Path } from '@bitspark/bitwire'; /** - * An admitted request's return capability is the invocation, presented as a - * Wire. The empty path carries its outcome, as it always has; these operations - * carry its lifecycle. They are ordinary events of the profile โ€” a layer's own + * An admitted request's return capability is the invocation, presented as + * addressed access. The empty path carries its outcome, as it always has; these + * operations carry its lifecycle. They are ordinary events of the profile โ€” a layer's own * vocabulary, as `channel.` is the tunnel's โ€” and a participant needs nothing - * of Nightseam's to speak them but the Wire it was already handed. + * of bitruntime's to speak them but the return capability it was handed. * * The capture or body a verb is about is one opaque segment after the * operation, because a path is what addresses a thing. The verbs never reach a @@ -56,7 +56,7 @@ const nextIdentifier = (): string => String(++identifiers); const event = (): Message => ({ frame: { version: 1, kind: 'event', data: null } }); interface Capture { - sink: Wire; + sink: AddressedWire; ready: boolean; notified: boolean; } @@ -64,7 +64,7 @@ interface Capture { /** * The lifecycle an admitting runtime keeps for one admitted request, and the * answer its return capability gives to the vocabulary above. A runtime that is - * not Nightseam's composes it โ€” or answers the same paths itself โ€” and the same + * not bitruntime composes it โ€” or answers the same paths itself โ€” and the same * participants work against either. */ export class Invocation { @@ -146,7 +146,7 @@ export class Invocation { this.#retire(); } - #capture(identifier: string, sink: Wire): void { + #capture(identifier: string, sink: AddressedWire): void { if (this.#retired) throw new InvocationError('ended'); if (this.#captures.has(identifier)) throw new InvocationError('duplicate'); if (this.#taken >= this.#limits.captures) throw new InvocationError('limit'); @@ -201,7 +201,7 @@ export class Invocation { #latch(message: Message): void { if (this.#retired || this.#control) return; this.#control = message; - const sinks: Wire[] = []; + const sinks: AddressedWire[] = []; for (const capture of this.#captures.values()) { if (!capture.ready || capture.notified) continue; capture.notified = true; @@ -215,7 +215,7 @@ export class Invocation { * Runs participant code while one control reservation is held, so that * retirement cannot reclaim a capture a control is still reaching. */ - #push(sinks: readonly Wire[], message: Message): void { + #push(sinks: readonly AddressedWire[], message: Message): void { try { for (const sink of sinks) { try { @@ -243,8 +243,8 @@ export class Invocation { } } -/** The Wire a capture is pushed its control through, and nothing else. */ -class InvocationSink implements Wire { +/** The AddressedWire a capture is pushed its control through, and nothing else. */ +class InvocationSink implements AddressedWire { readonly #control: (message: Message) => void; constructor(control: (message: Message) => void) { this.#control = control; @@ -255,7 +255,7 @@ class InvocationSink implements Wire { } } -const invocationWire = (message: Message): Wire => { +const invocationWire = (message: Message): AddressedWire => { const wire = message.return?.wire; if (!wire) throw new InvocationError('unsupported'); return wire; @@ -267,11 +267,11 @@ const invocationWire = (message: Message): Wire => { * handle, so no two traversals share a slot. */ export class InvocationCaptureHandle { - readonly #wire: Wire; + readonly #wire: AddressedWire; readonly #identifier: string; #ready = false; #released = false; - constructor(wire: Wire, identifier: string) { + constructor(wire: AddressedWire, identifier: string) { this.#wire = wire; this.#identifier = identifier; } @@ -318,10 +318,10 @@ export function captureInvocation(message: Message, control: (message: Message) /** One execution lease of one admitted invocation. */ export class InvocationBodyHandle { - readonly #wire: Wire; + readonly #wire: AddressedWire; readonly #identifier: string; #done = false; - constructor(wire: Wire, identifier: string) { + constructor(wire: AddressedWire, identifier: string) { this.#wire = wire; this.#identifier = identifier; } diff --git a/core/ts/src/meta.ts b/core/ts/src/meta.ts new file mode 100644 index 0000000..8dbe7dd --- /dev/null +++ b/core/ts/src/meta.ts @@ -0,0 +1,7 @@ +/** + * What a frame carries about a call rather than of it: a flat map of strings โ€” + * a tenant, an idempotency key, a credential that is per request โ€” which the + * profile carries verbatim and reads nothing into. A handler's outgoing call + * carries the meta it received only where the handler says so. + */ +export type Meta = Record; diff --git a/core/ts/src/pair.ts b/core/ts/src/pair.ts index dafb9d3..e0fe49d 100644 --- a/core/ts/src/pair.ts +++ b/core/ts/src/pair.ts @@ -1,21 +1,46 @@ -import { encodePath, WireError } from '@nightseam/duplex'; -import type { Message, Path, Receiver, ReturnAddress, Wire, Endpoint } from '@nightseam/duplex'; +import type { AddressedWire, Endpoint, Message, Path, Receiver, ReturnAddress } from '@bitspark/bitwire'; +import { PublicError, ReceiverExistsError } from './error.ts'; import { Invocation, defaultInvocationLimits } from './invocation.ts'; -import { DUPLEX_DEFAULTS, positiveInteger } from './peer.ts'; -import type { PeerOptions } from './peer.ts'; -import { DuplexError } from './error.ts'; -import { defaultPropagator, traceOf } from './trace.ts'; -import type { ObserverEvent } from './observer.ts'; import { - profileFrame, - publicError, - response, - wireContext, - setWireContext, - wireEventContext, - withWireEventContext, -} from './wire.ts'; -import type { WireDispatchContext, WireRequestContext, WireEventContext } from './wire.ts'; + dispatchContext, + eventContext, + setDispatchContext, + withEventContext, + type DispatchContext, + type ReceivedEventContext, + type ReceivedRequestContext, +} from './internal/context.ts'; +import { ended, profileFrame, publicError } from './internal/frame.ts'; +import { LIMIT_DEFAULTS, limits } from './internal/limits.ts'; +import { encodePath } from './internal/path.ts'; +import { traceOf } from './internal/trace.ts'; +import { respond } from './respond.ts'; +import { defaultPropagator, type Propagator } from './trace.ts'; + +/** + * Bounds a local pair. Absent members take the defaults, which are the + * protocol engine's: 64 concurrent handlers and 128 pending requests and queued + * messages per direction, frames of at most 1 MiB, a 30-second request + * deadline and a 10-second stall deadline for event consumers. + */ +export interface PairOptions { + /** The requests a direction runs at once; the one past it is refused busy. */ + maxConcurrentHandlers?: number; + /** The requests a direction holds admitted and unanswered; the one past it is refused busy. */ + maxPendingRequests?: number; + /** The requests and events a direction holds queued. A full queue ends the pair. */ + queueCapacity?: number; + /** The envelope each message would travel as. */ + maxFrameBytes?: number; + /** Answers a request whose handler has not answered by then. */ + requestTimeoutMs?: number; + /** How long an event consumer may take before the pair ends as stalled. */ + writeTimeoutMs?: number; + /** Moves a trace between a frame and a handler's context. */ + propagator?: Propagator; + /** Told why the pair failed, before it ends. */ + onError?: (error: PublicError) => void; +} interface Registration { receiver: Receiver; @@ -26,7 +51,7 @@ interface LocalCall { path: Path; returning: ReturnAddress; invocation: Invocation; - source?: WireDispatchContext; + source?: DispatchContext; cleanup: () => void; controller: AbortController; registration?: Registration; @@ -41,7 +66,7 @@ interface Delivery { path: Path; message: Message; call?: LocalCall; - refusal?: DuplexError; + refusal?: PublicError; } // One end of a bounded local pair: the Endpoint it presents, what it owes the // other end, and the state its own admission keeps. @@ -59,37 +84,30 @@ interface PairEnd { } /** - * A bounded local carrier. Sending on one endpoint delivers asynchronously to - * receivers on the other. No Peer, transport connection, or request protocol - * is constructed; the runtime's existing return capability carries responses. + * A bounded local carrier with two relative origins. Sending on either + * endpoint delivers asynchronously to the receiver on the other. It allocates + * no peer, serializes nothing, and preserves structured frames, the original + * return capability's identity and received context. Each admitted request + * gets a fresh return capability that carries its invocation lifecycle. */ -export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { - const limits = { ...DUPLEX_DEFAULTS }; - for (const key of Object.keys(DUPLEX_DEFAULTS) as (keyof typeof DUPLEX_DEFAULTS)[]) { - if (options[key] !== undefined) { - positiveInteger(options[key], key, true); - (limits as Record)[key] = options[key]!; - } - } +export function pair(options: PairOptions = {}): [Endpoint, Endpoint] { + const bounds = limits(LIMIT_DEFAULTS, options); const propagator = options.propagator ?? defaultPropagator; let closed = false; const ends: PairEnd[] = []; - const disconnected = () => new DuplexError('disconnected', 'Connection ended; outcome may be unknown.'); - const observe = (event: ObserverEvent) => { - try { - options.observer?.observe(event); - } catch { - /* Observers own their failures. */ - } - }; + // Every request admitted and not yet answered is answered disconnected, and + // every refusal still queued is answered with the refusal it was admitted + // with, so no caller is left to its own deadline. const end = (code = 1000, reason = '') => { if (closed) return; closed = true; const receivers: Receiver[] = [], - requests: Message[] = []; + requests: Message[] = [], + refusals: Delivery[] = []; for (const endpoint of ends) { clearTimeout(endpoint.eventTimer); if (endpoint.attachment) receivers.push(endpoint.attachment.receiver); + for (const queued of endpoint.queue) if (queued.refusal) refusals.push(queued); for (const calls of endpoint.calls.values()) for (const call of calls.values()) { clearTimeout(call.timer); @@ -104,7 +122,6 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { endpoint.calls.clear(); endpoint.queue.length = endpoint.queued = endpoint.retained = endpoint.active = 0; } - observe({ type: 'connection.closed', at: new Date(), code, reason, local: true }); queueMicrotask(() => { for (const receiver of receivers) { try { @@ -113,10 +130,11 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { /* Every receiver gets closure. */ } } - for (const request of requests) response(request, undefined, disconnected()); + for (const refused of refusals) respond(refused.message, undefined, refused.refusal); + for (const request of requests) respond(request, undefined, ended()); }); }; - const fail = (error: DuplexError) => { + const fail = (error: PublicError) => { try { options.onError?.(error); } catch { @@ -124,6 +142,9 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { } end(4011, error.message); }; + // Frees a call's pending slot once it is answered and no already queued + // cancellation still owns its reservation. It never removes a newer + // admission that reused the same return identity and identifier. const retire = (endpoint: PairEnd, call: LocalCall) => { if (!call.completed || call.cancelQueued) return; const calls = endpoint.calls.get(call.original.return!); @@ -152,7 +173,7 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { try { return registration.receiver.message!(path, message); } catch (error) { - if (message.frame.kind === 'request') response(message, undefined, publicError(error)); + if (message.frame.kind === 'request') respond(message, undefined, publicError(error)); else fail(publicError(error)); } }; @@ -174,28 +195,21 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { } endpoint.queued--; if (refusal) { - response(message, undefined, refusal); + respond(message, undefined, refusal); continue; } const registration = endpoint.attachment?.receiver.message ? endpoint.attachment : undefined; if (frame.kind === 'event') { if (registration) { let delivered = message; - if (!wireEventContext(message)) { - const context: WireEventContext = { wire: endpoint.wire }; + if (!eventContext(message)) { + const context: ReceivedEventContext = {}; propagator.extract(context, traceOf(frame)); - delivered = withWireEventContext(message, context); + delivered = withEventContext(message, context); } endpoint.eventTimer = setTimeout(() => { - observe({ - type: 'backpressure', - at: new Date(), - queued: endpoint.queued, - stalled: true, - deadlineMs: limits.writeTimeoutMs, - }); - fail(new DuplexError('stalled_consumer', 'Local wire event handler deadline exceeded.')); - }, limits.writeTimeoutMs); + fail(new PublicError('stalled_consumer', 'Local wire event handler deadline exceeded.')); + }, bounds.writeTimeoutMs); try { await invoke(registration, path, delivered); } catch (error) { @@ -208,11 +222,11 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { continue; } if (frame.kind !== 'request') continue; - if (!registration || endpoint.active >= limits.maxConcurrentHandlers) { - response( + if (!registration || endpoint.active >= bounds.maxConcurrentHandlers) { + respond( message, undefined, - new DuplexError( + new PublicError( registration ? 'busy' : 'method_not_found', registration ? 'Incoming request limit reached.' : 'Unknown method.', ), @@ -222,9 +236,8 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { endpoint.active++; call!.active = true; call!.registration = registration; - const context = Object.create(call!.source?.context ?? null) as WireRequestContext; + const context = Object.create(call!.source?.context ?? null) as ReceivedRequestContext; Object.defineProperties(context, { - wire: { value: endpoint.wire, enumerable: true }, signal: { value: call!.source ? AbortSignal.any([call!.controller.signal, call!.source.context.signal]) @@ -235,21 +248,10 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { ...(frame.meta ? { meta: { value: { ...frame.meta }, enumerable: true } } : {}), }); if (!call!.source) propagator.extract(context, traceOf(frame)); - call!.cleanup = setWireContext(call!.returning, { + call!.cleanup = setDispatchContext(call!.returning, { context, completion: call!.source?.completion, - maxFrameBytes: limits.maxFrameBytes, - panic: - call!.source?.panic ?? - ((error) => - observe({ - type: 'handler.panic', - at: new Date(), - method: encodePath(path), - value: String(error), - trace: traceOf(frame), - family: options.families?.[encodePath(path)] ?? '', - })), + maxFrameBytes: bounds.maxFrameBytes, }); call!.timer = setTimeout(() => { if (closed || call!.completed) return; @@ -267,11 +269,11 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { // actual response, bounding applications that ignore cancellation. if (!call!.responded) { call!.responded = true; - response(call!.original, undefined, new DuplexError('cancelled', 'Request deadline exceeded.')); + respond(call!.original, undefined, new PublicError('cancelled', 'Request deadline exceeded.')); } - }, limits.requestTimeoutMs); + }, bounds.requestTimeoutMs); const pending = invoke(registration, path, message); - if (pending) void pending.catch((error: unknown) => response(message, undefined, publicError(error))); + if (pending) void pending.catch((error: unknown) => respond(message, undefined, publicError(error))); } } finally { endpoint.draining = false; @@ -286,74 +288,72 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { }); }; const admit = (endpoint: PairEnd, path: Path, original: Message) => { - if (closed) throw disconnected(); + if (closed) throw ended(); const name = encodePath(path), - frame = profileFrame(original.frame, name, limits.maxFrameBytes); + frame = profileFrame(original.frame, name, bounds.maxFrameBytes); if (frame.kind !== 'request' && frame.kind !== 'event' && frame.kind !== 'cancel') - throw new DuplexError('invalid_message', "A response is sent to its request's return address."); + throw new PublicError('invalid_message', "A response is sent to its request's return address."); if (frame.kind !== 'event' && !original.return?.wire) - throw new DuplexError('invalid_message', 'A wire request or cancellation requires a return address.'); + throw new PublicError('invalid_message', 'A wire request or cancellation requires a return address.'); const message: Message = { frame, return: original.return }; - let call: LocalCall | undefined, refusal: DuplexError | undefined; + let call: LocalCall | undefined, refusal: PublicError | undefined; if (frame.kind === 'cancel') { call = endpoint.calls.get(message.return!)?.get(frame.id); if (!call || call.completed || call.cancelQueued || call.cancelled) return; call.cancelQueued = true; } else { - if (endpoint.queued >= limits.queueCapacity) { - const error = new DuplexError('busy', 'Local wire queue limit reached.'); - observe({ - type: 'backpressure', - at: new Date(), - queued: endpoint.queued, - stalled: true, - deadlineMs: limits.writeTimeoutMs, - }); + if (endpoint.queued >= bounds.queueCapacity) { + // A full queue ends the carrier; the refused send reports it ended. + const error = new PublicError('busy', 'Local wire queue limit reached.'); fail(error); - throw error; + throw ended(error); } endpoint.queued++; if (frame.kind === 'request') { let calls = endpoint.calls.get(message.return!); - if (calls?.has(frame.id)) refusal = new DuplexError('invalid_message', 'Duplicate active request identifier.'); - else if (endpoint.retained >= limits.maxPendingRequests) - refusal = new DuplexError('busy', 'Outstanding call limit reached.'); + if (calls?.has(frame.id)) refusal = new PublicError('invalid_message', 'Duplicate active request identifier.'); + else if (endpoint.retained >= bounds.maxPendingRequests) + refusal = new PublicError('busy', 'Outstanding call limit reached.'); else { const invocation = new Invocation(defaultInvocationLimits()); - const returning: ReturnAddress = { - wire: { - send: (suffix, reply) => { - if (suffix.length) { - invocation.deliver(suffix, reply); - return; - } - if (reply.frame.kind !== 'response' || reply.frame.id !== frame.id) - throw new DuplexError('invalid_message', 'Invalid wire response.'); - // A refused encoding may be retried as the shared bounded - // internal-error fallback; only an admitted response completes. - const checked = profileFrame(reply.frame, '', limits.maxFrameBytes); - try { - if (call!.responded || call!.completed) throw disconnected(); - call!.responded = true; - try { - message.return!.wire.send([], { frame: checked }); - } catch (error) { - throw error instanceof DuplexError ? publicError(error) : error; - } - invocation.settle(); - } finally { - complete(endpoint, call!); - } - }, + const returning: AddressedWire = { + send: (suffix, reply) => { + if (suffix.length) { + invocation.deliver(suffix, reply); + return; + } + if (reply.frame.kind !== 'response' || reply.frame.id !== frame.id) + throw new PublicError('invalid_message', 'Invalid wire response.'); + // A refused encoding may be retried as the shared bounded + // internal-error fallback; only an admitted response completes. + const checked = profileFrame(reply.frame, '', bounds.maxFrameBytes); + if (call!.responded || call!.completed) { + // A deadline already answered the caller. This is the handler's + // actual response, which is what releases its budget. + complete(endpoint, call!); + throw ended(); + } + call!.responded = true; + // Retire before the caller can hold its answer: a caller that + // issues its next call as soon as this one returns must find the + // slot free. A queued cancellation keeps the reservation until + // it drains. + complete(endpoint, call!); + invocation.settle(); + try { + message.return!.wire.send([], { frame: checked }); + } catch (error) { + throw error instanceof PublicError ? publicError(error) : error; + } }, }; call = { id: frame.id, original: message, path: [...path], - returning, + returning: { wire: returning }, invocation, - source: wireContext(message.return!), + source: dispatchContext(message.return), cleanup: () => {}, controller: new AbortController(), completed: false, @@ -391,14 +391,16 @@ export function wirePair(options: PeerOptions = {}): [Endpoint, Endpoint] { endpoint.wire = { send: (path, message) => admit(endpoint.other, path, message), receive: (receiver) => { - if (closed) throw disconnected(); - if (endpoint.attachment) throw new WireError('receiver_exists'); + if (closed) throw ended(); + if (endpoint.attachment) throw new ReceiverExistsError(); const registration = { receiver }; endpoint.attachment = registration; return () => { if (endpoint.attachment === registration) delete endpoint.attachment; }; }, + // Nothing is transmitted in-process, so any code closes the pair as an + // abort would: its receivers are told the code and the reason. close: end, }; ends.push(endpoint); diff --git a/core/ts/src/respond.ts b/core/ts/src/respond.ts new file mode 100644 index 0000000..3d75fb6 --- /dev/null +++ b/core/ts/src/respond.ts @@ -0,0 +1,71 @@ +import type { Message, ProfileFrame } from '@bitspark/bitwire'; +import { PublicError } from './error.ts'; +import { publicError, snapshot } from './internal/frame.ts'; +import { traceMembers, traceOf } from './internal/trace.ts'; + +/** + * Answers a request through its return capability: the result, or the error + * normalized to what the protocol carries โ€” a public error as it is, anything + * else as `internal`. An unencodable or oversized result is answered with a + * bounded internal error instead, so the caller is never left waiting. + * + * It returns the outcome it reported: undefined for a delivered success, the + * refusal it sent, or the return capability's own refusal. A message that is + * no request, or has no return capability, is answered `disconnected`. + */ +export function respond(request: Message, result?: unknown, error?: unknown): PublicError | undefined { + if (request.frame.kind !== 'request' || !request.return) + return new PublicError('disconnected', 'The request has no return address.'); + let outcome: PublicError | undefined; + let payload: { result: unknown } | { error: { code: string; message: string; data?: unknown } }; + try { + if (error !== undefined) { + const refused = publicError(error); + // Retain a valid local cancellation identity for the helper's outcome; + // only normalized public fields below enter the response frame. + outcome = + error instanceof PublicError && error.code === refused.code && error.message === refused.message + ? error + : refused; + payload = snapshot({ + error: { + code: refused.code, + message: refused.message, + ...(refused.data === undefined ? {} : { data: refused.data }), + }, + }); + } else payload = { result: snapshot(result === undefined ? null : result) }; + } catch { + outcome = new PublicError('internal', 'Response could not be encoded'); + payload = { error: { code: 'internal', message: 'Response could not be encoded' } }; + } + const frame = { + version: 1, + kind: 'response', + id: request.frame.id, + ...payload, + ...traceMembers(traceOf(request.frame)), + } as ProfileFrame; + try { + request.return.wire.send([], { frame }); + } catch (error) { + if (error instanceof PublicError && ['invalid_message', 'frame_too_large'].includes(error.code)) { + outcome = new PublicError('internal', 'Response could not be encoded'); + try { + request.return.wire.send([], { + frame: { + version: 1, + kind: 'response', + id: request.frame.id, + error: { code: 'internal', message: 'Response could not be encoded' }, + ...traceMembers(traceOf(request.frame)), + }, + }); + } catch { + /* The return address cannot admit even the bounded error response. */ + } + } else outcome ??= publicError(error); + /* The caller may already have cancelled or ended. */ + } + return outcome; +} diff --git a/core/ts/src/trace.ts b/core/ts/src/trace.ts index 1fa6b02..d9a0508 100644 --- a/core/ts/src/trace.ts +++ b/core/ts/src/trace.ts @@ -55,21 +55,6 @@ export const defaultPropagator: Propagator = { }, }; -/** The members an incoming frame carries, verbatim: what a propagator extracts. */ -export function traceOf(frame: { readonly traceparent?: unknown; readonly tracestate?: unknown }): Trace | undefined { - const traceparent = typeof frame.traceparent === 'string' ? frame.traceparent : ''; - const tracestate = typeof frame.tracestate === 'string' ? frame.tracestate : ''; - if (!traceparent && !tracestate) return undefined; - return tracestate ? { traceparent, tracestate } : { traceparent }; -} - -/** Stamps onto an outgoing frame what a propagator minted; an empty member is none. */ -export function traced(envelope: Record, trace: Trace | undefined): Record { - if (trace?.traceparent) envelope.traceparent = trace.traceparent; - if (trace?.tracestate) envelope.tracestate = trace.tracestate; - return envelope; -} - /** Web Crypto is the only source; the runtime takes no dependency for it. */ function randomHex(bytes: number): string { return Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, '0')).join( diff --git a/core/ts/src/tree.ts b/core/ts/src/tree.ts new file mode 100644 index 0000000..59b836d --- /dev/null +++ b/core/ts/src/tree.ts @@ -0,0 +1,143 @@ +import type { + AddressedWire, Child, DeixisNode, Key, Message, Parts, Path, TreePath, WireTree, +} from '@bitspark/bitwire'; + +import { InvalidPathError, MissingPathError } from './error.ts'; + +function keyName(key: Key): string { + if (!(key instanceof Uint8Array)) throw new TypeError('Tree keys must be Uint8Array values'); + let name = ''; + for (const byte of key) name += byte.toString(16).padStart(2, '0'); + return name; +} + +function copyChildren(children: Iterable>): ReadonlyArray> { + const result: Child[] = []; + const names = new Set(); + for (const [key, child] of children) { + const name = keyName(key); + if (names.has(name)) throw new TypeError('Duplicate byte key'); + if (child === null || (typeof child !== 'object' && typeof child !== 'function') || + typeof child.own !== 'function' || typeof child.children !== 'function' || + typeof child.at !== 'function' || typeof child.decompose !== 'function') { + throw new TypeError('A child must implement DeixisNode'); + } + names.add(name); + // Uint8Array.from also copies a Node Buffer; Buffer.slice would alias it. + result.push(Object.freeze([Uint8Array.from(key), child] as const)); + } + return Object.freeze(result); +} + +const constructed = new WeakSet(); + +/** Validate foreign subtrees without recursion or changing their identity. */ +function validateChildren(children: ReadonlyArray>): void { + const active = new WeakSet(); + const visited = new WeakSet(); + const pending: Array, boolean]> = children.map(([, child]) => [child, false]); + while (pending.length > 0) { + const [node, leaving] = pending.pop()!; + if (leaving) { + active.delete(node); + visited.add(node); + continue; + } + if (active.has(node)) throw new TypeError('Structural cycle'); + if (visited.has(node) || constructed.has(node)) continue; + active.add(node); + pending.push([node, true]); + for (const [, child] of copyChildren(node.children())) pending.push([child, false]); + } +} + +class Node implements DeixisNode { + readonly #value: T; + readonly #children: ReadonlyArray>; + readonly #byKey: ReadonlyMap>; + + constructor(own: T, children: ReadonlyArray>) { + this.#value = own; + this.#children = children; + this.#byKey = new Map(children.map(([key, child]) => [keyName(key), child])); + constructed.add(this); + Object.freeze(this); + } + + own(): T { return this.#value; } + + children(): ReadonlyArray> { return copyChildren(this.#children); } + + at(path: TreePath): DeixisNode | undefined { return select(this, path); } + + decompose(): Parts { + return Object.freeze({ own: this.#value, children: this.children() }); + } + + static child(node: Node, key: Key): DeixisNode | undefined { + return node.#byKey.get(keyName(key)); + } +} + +/** + * Construct a complete immutable node, retaining own-value and child identity. + * Keys and the child collection are copied. Duplicate keys and structural cycles + * are rejected. Foreign nodes must themselves honor DeixisNode's finite, stable, + * immutable topology contract; validation does not freeze another implementation. + */ +export function compose(own: T, children: Iterable> = []): DeixisNode { + const retained = copyChildren(children); + validateChildren(retained); + return new Node(own, retained); +} + +/** Follow exact byte-key edges. Empty path is self; missing edges have no fallback. */ +export function select(tree: DeixisNode, path: TreePath): DeixisNode | undefined { + let current = tree; + for (const key of path) { + const wanted = keyName(key); + const child = current instanceof Node + ? Node.child(current, key) + : current.children().find(([candidate]) => keyName(candidate) === wanted)?.[1]; + if (child === undefined) return undefined; + current = child; + } + return current; +} + +/** Select a node and call exactly its own primitive, preserving message identity. */ +export function send(tree: WireTree, path: TreePath, message: Message): void { + const selected = select(tree, path); + if (selected === undefined) throw new MissingPathError(); + selected.own().send(message); +} + +const encoder = new TextEncoder(); + +function treePath(path: Path): TreePath { + return path.map(segment => { + if (typeof segment !== 'string') throw new InvalidPathError(); + for (let i = 0; i < segment.length; i++) { + const unit = segment.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = segment.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new InvalidPathError(); + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new InvalidPathError(); + } + } + return encoder.encode(segment); + }); +} + +/** + * Expose a full tree through the existing Unicode-string addressed access. + * Segments become exact UTF-8 keys without normalization or slash interpretation. + * Non-UTF-8 tree keys remain valid, but cannot be named through this bridge. + * This grants no receive/close capability and cannot infer a tree from a router. + */ +export function asAddressed(tree: WireTree): AddressedWire { + return Object.freeze({ send(path: Path, message: Message): void { + send(tree, treePath(path), message); + } }); +} diff --git a/core/ts/test/bitwire-conformance.test.mjs b/core/ts/test/bitwire-conformance.test.mjs index 09da394..d80a1cc 100644 --- a/core/ts/test/bitwire-conformance.test.mjs +++ b/core/ts/test/bitwire-conformance.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import { compose, select, send } from '../dist/index.js'; +import { compose, select, send } from '../../../dist/core/ts/src/index.js'; // Independent oracle: Bitspark/bitwire 0.3.0, conformance/trees/expected.json. // This driver uses the production runtime, not Bitwire's test interpreter. diff --git a/core/ts/test/core.test.mjs b/core/ts/test/core.test.mjs index ab0cf96..37bac13 100644 --- a/core/ts/test/core.test.mjs +++ b/core/ts/test/core.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { asAddressed, compose, InvalidPathError, MissingPathError, select, send, -} from '../dist/index.js'; +} from '../../../dist/core/ts/src/index.js'; const key = (...bytes) => Uint8Array.from(bytes); const text = value => new TextEncoder().encode(value); diff --git a/core/ts/tsconfig.check.json b/core/ts/tsconfig.check.json deleted file mode 100644 index 7c5b655..0000000 --- a/core/ts/tsconfig.check.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/core/ts/tsconfig.json b/core/ts/tsconfig.json deleted file mode 100644 index 83fdd0f..0000000 --- a/core/ts/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "declaration": true, - "verbatimModuleSyntax": true, - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src/**/*.ts"] -} diff --git a/dispatch/ts/src/dispatch.ts b/dispatch/ts/src/dispatch.ts new file mode 100644 index 0000000..3da675b --- /dev/null +++ b/dispatch/ts/src/dispatch.ts @@ -0,0 +1,224 @@ +import type { AddressedWire, Path, ProfileFrame, ReturnAddress } from '@bitspark/bitwire'; +import { PublicError, UnpublishedError } from '../../../core/ts/src/error.ts'; +import { InvocationError, beginInvocationBody } from '../../../core/ts/src/invocation.ts'; +import type { Meta } from '../../../core/ts/src/meta.ts'; +import { respond } from '../../../core/ts/src/respond.ts'; +import { defaultPropagator, type Propagator, type TraceContext } from '../../../core/ts/src/trace.ts'; +import { dispatchContext, eventContext } from '../../../core/ts/src/internal/context.ts'; +import { carrying } from '../../../core/ts/src/internal/envelope.ts'; +import { setOutgoingTrace, snapshot } from '../../../core/ts/src/internal/frame.ts'; +import { encodePath } from '../../../core/ts/src/internal/path.ts'; +import { request } from '../../../core/ts/src/internal/request.ts'; +import { traceMembers, traceOf } from '../../../core/ts/src/internal/trace.ts'; +import type { Registry } from './dispatcher.ts'; + +/** + * What a request handler is given beside the params, independent of the + * carrier: the registry it was registered on, a signal that fires when the + * caller withdraws or a deadline passes, the request's id, and the trace and + * meta the request brought. Values a carrier established privately for the + * request โ€” an authenticated identity a propagator placed there โ€” are reached + * through the context's prototype; the carrier itself is not. + */ +export interface RequestContext extends TraceContext { + wire: AddressedWire; + signal: AbortSignal; + requestId: string; + readonly meta?: Meta; +} +/** What an event listener is told about the frame that carried its event. */ +export interface EventContext extends TraceContext { + wire: AddressedWire; + readonly meta?: Meta; +} +/** What one call may carry: its propagator, a withdrawing signal, a deadline, the context it is made under, and its meta. */ +export interface CallOptions { + propagator?: Propagator; + signal?: AbortSignal; + timeoutMs?: number; + context?: TraceContext; + meta?: Meta; +} +/** What one emit may carry: its propagator, the context it is made under, and its meta. */ +export interface EmitOptions { + propagator?: Propagator; + context?: TraceContext; + meta?: Meta; +} +/** Answers one request: the result, or a thrown PublicError that crosses with its code; any other error crosses as `internal`. */ +export type Handler = (params: unknown, context: RequestContext) => unknown | Promise; +/** Takes one event's data; an event has no answer, and a failure ends the registry. */ +export type EventListener = (data: unknown, context: EventContext) => void | Promise; +/** A request handler and an event listener that share one path. */ +export interface Handlers { + request?: Handler; + event?: EventListener; +} + +/** + * Sends one request at a relative path and waits for its response. Its return + * capability is fresh for this call and carries the invocation lifecycle, so it + * is independent of every other call's identifier. A caller that withdraws โ€” + * its signal fires or its deadline passes โ€” sends a best-effort cancellation. + * It never retries. + * + * A refusal before anything was sent is an UnpublishedError; a response error + * is a PublicError. + */ +export function call( + access: AddressedWire, + path: Path, + params: unknown = {}, + options: CallOptions = {}, +): Promise { + return request(access, path, params, options, (options.propagator ?? defaultPropagator).inject(options.context)); +} + +/** + * Admits one event at a relative path. Return means the destination accepted + * it, never that it was consumed; a refusal is an UnpublishedError. + */ +export function emit(access: AddressedWire, path: Path, data: unknown = null, options: EmitOptions = {}): void { + try { + encodePath(path); + const trace = (options.propagator ?? defaultPropagator).inject(options.context); + const frame = snapshot( + carrying({ version: 1, kind: 'event', data, ...traceMembers(trace) }, options.meta), + ) as unknown as ProfileFrame; + setOutgoingTrace(frame, trace); + access.send(path, { frame }); + } catch (error) { + throw new UnpublishedError(error); + } +} + +/** Registers one request handler; application code runs after the delivering turn. */ +export function handle(registry: Registry, path: Path, handler: Handler): () => void { + return register(registry, path, { request: handler }); +} + +/** Registers one event listener; the delivering carrier awaits an asynchronous one. */ +export function onEvent(registry: Registry, path: Path, listener: EventListener): () => void { + return register(registry, path, { event: listener }); +} + +/** + * Registers a request handler, an event listener, or both at one path, with + * one cancellation map; the one detach removes the group. An event-only path + * refuses requests with method_not_found, and an event listener's failure + * closes the registry as a protocol error. + */ +export function register(registry: Registry, path: Path, handlers: Handlers): () => void { + const incoming = new Map>(); + const stop = () => { + for (const calls of incoming.values()) for (const controller of calls.values()) controller.abort(); + }; + const detach = registry.register(path, { + closed: stop, + message: (_path, message) => { + const frame = message.frame; + if (frame.kind === 'event') { + if (!handlers.event) return; + const received = eventContext(message); + const context = (received ? Object.create(received) : {}) as EventContext; + Object.defineProperties(context, { + wire: { value: registry, enumerable: true }, + meta: { value: frame.meta ? { ...frame.meta } : undefined, enumerable: true }, + }); + if (!received) defaultPropagator.extract(context, traceOf(frame)); + const failed = () => { + registry.close(1002, 'wire event rejected'); + }; + try { + const pending = handlers.event(frame.data, context); + if (pending) return pending.catch(failed); + } catch { + failed(); + } + return; + } + if ((frame.kind !== 'request' && frame.kind !== 'cancel') || !message.return) return; + let calls = incoming.get(message.return); + if (frame.kind === 'cancel') { + calls?.get(frame.id)?.abort(); + return; + } + if (!handlers.request) { + respond(message, undefined, new PublicError('method_not_found', 'An event has no request handler.')); + return; + } + if (calls?.has(frame.id)) { + respond(message, undefined, new PublicError('invalid_message', 'Duplicate active request identifier.')); + return; + } + if (!calls) { + calls = new Map(); + incoming.set(message.return, calls); + } + const controller = new AbortController(); + calls.set(frame.id, controller); + const dispatch = dispatchContext(message.return); + const context = ( + dispatch ? Object.create(dispatch.context) : { ...(frame.meta ? { meta: { ...frame.meta } } : {}) } + ) as RequestContext; + Object.defineProperties(context, { + wire: { value: registry, enumerable: true }, + signal: { + value: dispatch ? AbortSignal.any([controller.signal, dispatch.context.signal]) : controller.signal, + enumerable: true, + }, + requestId: { value: dispatch?.context.requestId ?? frame.id, enumerable: true }, + }); + if (!dispatch) defaultPropagator.extract(context, traceOf(frame)); + // The body runs after this receiver returns, so returning is not + // completion. The lease says so to whoever admitted the request: an + // early answer to the caller cannot retire an invocation whose body is + // still running. A bound reached is a refusal; any other refusal means + // this return capability carries no lifecycle, and ordinary addressed + // delivery goes on without one. + let body: { done(): void } | undefined; + try { + body = beginInvocationBody(message); + } catch (error) { + if (error instanceof InvocationError && error.code === 'limit') { + calls.delete(frame.id); + if (!calls.size) incoming.delete(message.return); + controller.abort(); + respond(message, undefined, new PublicError('busy', 'Invocation participation limit reached.')); + return; + } + } + let cancelledBeforeHandler = false; + void Promise.resolve() + .then(() => { + if (context.signal.aborted) { + cancelledBeforeHandler = true; + throw new PublicError('cancelled', 'Request was cancelled.'); + } + return handlers.request!(frame.params, context); + }) + .then( + (result) => { + const error = context.signal.aborted ? new PublicError('cancelled', 'Request was cancelled.') : undefined; + if (error && dispatch?.completion) dispatch.completion.cancelled = true; + respond(message, result, error); + }, + (error: unknown) => { + // A public handler refusal stays an error even if cancellation + // raced its completion. Only this helper's withdrawal is local. + if (cancelledBeforeHandler && dispatch?.completion) dispatch.completion.cancelled = true; + respond(message, undefined, error); + }, + ) + .finally(() => { + body?.done(); + calls!.delete(frame.id); + if (!calls!.size) incoming.delete(message.return!); + }); + }, + }); + return () => { + detach(); + stop(); + }; +} diff --git a/dispatch/ts/src/dispatcher.ts b/dispatch/ts/src/dispatcher.ts index 59c8719..45c100f 100644 --- a/dispatch/ts/src/dispatcher.ts +++ b/dispatch/ts/src/dispatcher.ts @@ -1,11 +1,16 @@ -import { encodePath, WireError } from '@nightseam/duplex'; -import type { Endpoint, Message, Path, Receiver, Wire } from '@nightseam/duplex'; -import { DuplexError } from './error.ts'; -import { InvocationError, captureInvocation, relayInvocationControl } from './invocation.ts'; -import { response } from './wire.ts'; +import type { AddressedWire, Endpoint, Message, Path, Receiver } from '@bitspark/bitwire'; +import { PublicError, ReceiverExistsError } from '../../../core/ts/src/error.ts'; +import { InvocationError, captureInvocation, relayInvocationControl } from '../../../core/ts/src/invocation.ts'; +import { respond } from '../../../core/ts/src/respond.ts'; +import { ended as disconnected } from '../../../core/ts/src/internal/frame.ts'; +import { encodePath } from '../../../core/ts/src/internal/path.ts'; -/** Registration authority; close releases this registry, never its borrowed carrier. */ -export interface HandlerRegistry extends Wire { +/** + * The registration capability the handler helpers need: send access plus + * explicit route registration. Closing it releases its registrations, never + * a borrowed carrier. + */ +export interface Registry extends AddressedWire { register(path: Path, receiver: Receiver): () => void; close(code?: number, reason?: string): void; } @@ -28,7 +33,7 @@ export interface DispatcherOptions { * endpoint: the lifecycle travels with the unchanged return capability, and * nothing here recognizes a concrete type. */ -export class WireDispatcher implements HandlerRegistry { +export class Dispatcher implements Registry { private readonly exact = new Map(); private readonly prefixes = new Map(); private ended = false; @@ -45,24 +50,26 @@ export class WireDispatcher implements HandlerRegistry { }); if (this.ended) { detach(); - throw new WireError('closed'); + throw disconnected(); } this.detach = detach; } send(path: Path, message: Message): void { - if (this.ended) throw new WireError('closed'); + if (this.ended) throw disconnected(); this.root.send(path, message); } + /** Routes exactly path to receiver. A path has one registration. */ register(path: Path, receiver: Receiver): () => void { return this.install(path, receiver, this.exact); } + /** Routes path and every path beneath it, the longest registered prefix winning, unless an exact registration matches. */ registerPrefix(path: Path, receiver: Receiver): () => void { return this.install(path, receiver, this.prefixes); } private install(path: Path, receiver: Receiver, routes: Map): () => void { const name = encodePath(path); - if (this.ended) throw new WireError('closed'); - if (routes.has(name)) throw new WireError('receiver_exists'); + if (this.ended) throw disconnected(); + if (routes.has(name)) throw new ReceiverExistsError(); const registration = { path: [...path], receiver }; routes.set(name, registration); return () => { @@ -99,7 +106,7 @@ export class WireDispatcher implements HandlerRegistry { const registration = this.ended ? undefined : this.match(path, name); if (!registration?.receiver.message) { if (message.frame.kind === 'request') - response(message, undefined, new DuplexError('method_not_found', 'Unknown method.')); + respond(message, undefined, new PublicError('method_not_found', 'Unknown method.')); return; } const delivered = [...path]; @@ -113,12 +120,12 @@ export class WireDispatcher implements HandlerRegistry { // A bound reached is a refusal to try again at; a capability that // carries no lifecycle is a request this dispatcher cannot route with // the guarantees it advertises. - response( + respond( message, undefined, error instanceof InvocationError && error.code === 'limit' - ? new DuplexError('busy', 'Invocation participation limit reached.') - : new DuplexError('invalid_message', 'Invocation requires the lifecycle its return capability carries.'), + ? new PublicError('busy', 'Invocation participation limit reached.') + : new PublicError('invalid_message', 'Invocation requires the lifecycle its return capability carries.'), ); return; } @@ -136,9 +143,11 @@ export class WireDispatcher implements HandlerRegistry { return pending.finally(() => capture.ready()); } + /** A receiving view of this shared dispatcher at a prefix, with no closure authority over the root. */ select(path: Path): SelectedEndpoint { return new SelectedEndpoint(this, [...path]); } + /** Releases the routes and the root attachment, and closes the root only when this dispatcher owns it. */ close(code = 1000, reason = ''): void { if (this.ended) return; this.ended = true; @@ -158,8 +167,9 @@ export class WireDispatcher implements HandlerRegistry { if (this.ownEndpoint) this.root.close(code, reason); } } -export function createDispatcher(endpoint: Endpoint, options: DispatcherOptions = {}): WireDispatcher { - return new WireDispatcher(endpoint, options); +/** Attaches a dispatcher to an endpoint, borrowed unless the options transfer its closure. */ +export function createDispatcher(endpoint: Endpoint, options: DispatcherOptions = {}): Dispatcher { + return new Dispatcher(endpoint, options); } interface Attachment { @@ -170,9 +180,9 @@ interface Attachment { export class SelectedEndpoint implements Endpoint { private ended = false; private attachment: Attachment | undefined; - private readonly owner: WireDispatcher; + private readonly owner: Dispatcher; private readonly prefix: Path; - constructor(owner: WireDispatcher, prefix: Path) { + constructor(owner: Dispatcher, prefix: Path) { this.owner = owner; this.prefix = prefix; } @@ -180,12 +190,12 @@ export class SelectedEndpoint implements Endpoint { return this.owner.select([...this.prefix, ...path]); } send(path: Path, message: Message): void { - if (this.ended) throw new WireError('closed'); + if (this.ended) throw disconnected(); this.owner.send([...this.prefix, ...path], message); } receive(receiver: Receiver): () => void { - if (this.ended) throw new WireError('closed'); - if (this.attachment) throw new WireError('receiver_exists'); + if (this.ended) throw disconnected(); + if (this.attachment) throw new ReceiverExistsError(); const attachment: Attachment = { receiver }; this.attachment = attachment; try { @@ -193,13 +203,13 @@ export class SelectedEndpoint implements Endpoint { message: (path, message) => { if (receiver.message) return receiver.message(path.slice(this.prefix.length), message); if (message.frame.kind === 'request') - response(message, undefined, new DuplexError('method_not_found', 'Unknown method.')); + respond(message, undefined, new PublicError('method_not_found', 'Unknown method.')); }, closed: (code, reason) => this.remove(attachment, { code, reason }), }); if (this.attachment !== attachment) { detach(); - throw new WireError('closed'); + throw disconnected(); } attachment.detach = detach; } catch (error) { @@ -214,6 +224,7 @@ export class SelectedEndpoint implements Endpoint { attachment.detach?.(); if (ending) attachment.receiver.closed?.(ending.code, ending.reason); } + /** Ends the view's route; it never closes the shared root. */ close(code = 1000, reason = ''): void { this.ended = true; if (this.attachment) this.remove(this.attachment, { code, reason }); diff --git a/dispatch/ts/src/index.ts b/dispatch/ts/src/index.ts new file mode 100644 index 0000000..f891d59 --- /dev/null +++ b/dispatch/ts/src/index.ts @@ -0,0 +1,21 @@ +/** + * `@bitspark/bitruntime/dispatch`: a Dispatcher owns one endpoint attachment + * and an explicit exact/longest-prefix routing policy; call and emit send + * through any addressed access; handle, onEvent and register run a body per + * request or event with the context its carrier established. + */ +export { Dispatcher, SelectedEndpoint, createDispatcher, type DispatcherOptions, type Registry } from './dispatcher.ts'; +export { + call, + emit, + handle, + onEvent, + register, + type CallOptions, + type EmitOptions, + type EventContext, + type EventListener, + type Handler, + type Handlers, + type RequestContext, +} from './dispatch.ts'; diff --git a/dispatch/ts/src/wire.ts b/dispatch/ts/src/wire.ts deleted file mode 100644 index 474bee9..0000000 --- a/dispatch/ts/src/wire.ts +++ /dev/null @@ -1,896 +0,0 @@ -import { decodePath, encodePath, WireError } from '@nightseam/duplex'; -import type { Message, Path, ProfileFrame, Receiver, ReturnAddress, Wire, Endpoint } from '@nightseam/duplex'; -import { type HandlerRegistry } from './dispatcher.ts'; -import { Invocation, InvocationError, beginInvocationBody, defaultInvocationLimits } from './invocation.ts'; -import { DuplexError, UnpublishedError } from './error.ts'; -import { carrying, decodeEnvelope, isObject } from './envelope.ts'; -import { scalarJSON } from './unicode.ts'; -import { defaultPropagator, traceOf } from './trace.ts'; -import type { ValueEnvironment } from './value-adapter.ts'; -import type { Propagator, Trace, TraceContext } from './trace.ts'; -import type { Observer } from './observer.ts'; -import { observeWire, observeWireRequest } from './wire-observer.ts'; -import type { - CallOptions, - DuplexPeer, - EmitOptions, - EventContext, - EventListener, - Meta, - PeerOptions, - RequestContext, - RequestHandler, -} from './peer.ts'; - -// Outgoing propagators may privately associate their trace object with an -// active consumer context. Keep that identity across local frame snapshots; -// only the two public trace strings cross a physical connection. -const outgoingTraces = new WeakMap(); -function outgoingTrace(frame: ProfileFrame): Trace | undefined { - return outgoingTraces.get(frame) ?? traceOf(frame); -} - -/** @internal Received values retained beside a local return capability. */ -export interface WireDispatchContext { - context: RequestContext | WireRequestContext; - panic: (error: unknown) => void; - maxFrameBytes: number; - completion?: { cancelled: boolean }; -} -// This is a local capability association, never a field a caller can serialize -// or supply as ambient outgoing metadata. Keep non-enumerable verified values. -const dispatchContexts = new WeakMap(); - -interface WireEventDispatchContext { - context: EventContext | WireEventContext; - panic?: (error: unknown) => void; -} -// An event's local context capability has no waiter, id or callable return. -// Weak ownership lets queued deliveries outlive the source receiver's return. -const eventContexts = new WeakMap(); -const receivedEventTraces = new WeakMap(); -/** @internal Preserve the received frame independently of a custom propagator's context. */ -export function setReceivedEventTrace(context: EventContext, trace: Trace | undefined): void { - receivedEventTraces.set(context, trace); -} -const eventContextCarrier: Wire = Object.freeze({ - send: () => { - throw new DuplexError('invalid_message', 'An event context is not a return address.'); - }, -}); -/** @internal Inspect only runtime-associated event context, never caller data. */ -export function wireEventContext(message: Message): WireEventDispatchContext | undefined { - return message.return ? eventContexts.get(message.return) : undefined; -} -/** @internal Carry received event context across local asynchronous composition. */ -export function withWireEventContext( - message: Message, - context: EventContext | WireEventContext, - panic?: (error: unknown) => void, -): Message { - const address: ReturnAddress = { wire: eventContextCarrier }; - eventContexts.set(address, { context, panic }); - return { frame: message.frame, return: address }; -} - -/** @internal Read the verified context associated with a local return capability. */ -export function wireContext(address: ReturnAddress): WireDispatchContext | undefined { - return dispatchContexts.get(address); -} - -/** @internal Associate received context without adding it to serialized frame data. */ -export function setWireContext(address: ReturnAddress, context: WireDispatchContext): () => void { - dispatchContexts.set(address, context); - return () => { - if (dispatchContexts.get(address) === context) dispatchContexts.delete(address); - }; -} - -/** @internal Preserve authenticated receive context across a local root's return remapping. */ -export function inheritWireContext(source: ReturnAddress, target: ReturnAddress): () => void { - const context = dispatchContexts.get(source); - return context ? setWireContext(target, context) : () => {}; -} - -/** Context beside a wire request, independent of its concrete carrier. */ -export interface WireModelContext extends TraceContext { - signal?: AbortSignal; - timeoutMs?: number; - readonly meta?: Meta; - outgoingMeta?: Meta; - requestId?: string; -} -/** Runtime construction options shared by derived family adapters. */ -export interface AdapterContext { - options?: PeerOptions; - valueEnvironment?: ValueEnvironment; -} -/** Context beside a wire request, independent of its concrete carrier. */ -export interface WireRequestContext extends WireModelContext { - wire: Wire; - signal: AbortSignal; - requestId: string; - meta?: Meta; -} -export interface WireCallOptions { - propagator?: Propagator; - observer?: Observer; - family?: string; - signal?: AbortSignal; - timeoutMs?: number; - context?: TraceContext; - meta?: Meta; -} -export interface WireEmitOptions { - propagator?: Propagator; - observer?: Observer; - family?: string; - context?: TraceContext; - meta?: Meta; -} -export interface WireEventContext extends TraceContext { - wire: Wire; - meta?: Meta; -} -export type WireHandler = (params: unknown, context: WireRequestContext) => unknown | Promise; -export type WireEventListener = (data: unknown, context: WireEventContext) => void | Promise; -export interface WireHandlers { - request?: WireHandler; - event?: WireEventListener; - observer?: Observer; - family?: string; -} - -/** @internal The completion/deadline primitive shared by Peer.call and CallWire. */ -export function requestCompletion() { - let settled = false; - let timer: ReturnType | undefined; - let detach: (() => void) | undefined; - let resolve!: (value: T) => void; - let reject!: (error: DuplexError) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - const cleanup = () => { - clearTimeout(timer); - detach?.(); - detach = undefined; - }; - return { - promise, - get settled() { - return settled; - }, - cleanup, - resolve: (value: T) => { - if (!settled) { - settled = true; - cleanup(); - resolve(value); - } - }, - reject: (error: DuplexError) => { - if (!settled) { - settled = true; - cleanup(); - reject(error); - } - }, - wait: ( - signal: AbortSignal | undefined, - timeoutMs: number, - method: string, - cancel: (error: DuplexError, outcome: 'timeout' | 'cancelled') => void, - ) => { - if (settled) return; - timer = setTimeout( - () => - cancel( - new DuplexError('request_timeout', `Call ${method} timed out; its outcome may be unknown.`), - 'timeout', - ), - timeoutMs, - ); - if (signal) { - const abort = () => - cancel(new DuplexError('cancelled', 'Call was cancelled; its outcome may be unknown.'), 'cancelled'); - signal.addEventListener('abort', abort, { once: true }); - detach = () => signal.removeEventListener('abort', abort); - if (signal.aborted) abort(); - } - }, - }; -} - -function snapshot(value: T): T { - try { - const encoded = JSON.stringify(value, (_key, member: unknown) => { - if ( - typeof member === 'function' || - typeof member === 'symbol' || - (typeof member === 'number' && !Number.isFinite(member)) - ) - throw new Error('Not JSON.'); - return member; - }); - scalarJSON(encoded); - return JSON.parse(encoded) as T; - } catch { - throw new DuplexError('invalid_message', 'Frame must contain serializable JSON values.'); - } -} - -/** @internal Reuse the physical profile validator at structured root admission. */ -export function profileFrame(value: ProfileFrame, name: string, maxFrameBytes?: number): ProfileFrame { - const saved: unknown = snapshot(value); - if (!isObject(saved) || Object.hasOwn(saved, 'method') || Object.hasOwn(saved, 'event')) - throw new DuplexError('invalid_message', 'Invalid structured profile frame.'); - // The path is the sole operation name. Reuse the physical profile validator - // after translating that name, without silently replacing an extra member. - const envelope = { - ...saved, - ...(saved.kind === 'request' ? { method: name } : saved.kind === 'event' ? { event: name } : {}), - }; - const text = JSON.stringify(envelope); - if (maxFrameBytes !== undefined && new TextEncoder().encode(text).byteLength > maxFrameBytes) - throw new DuplexError('frame_too_large', 'Outgoing frame exceeds the size limit.'); - // Logical request ids belong to local return addresses, not physical roles. - // Both accepted prefixes still use the existing canonical numeric grammar. - const prefix = typeof saved.id === 'string' && saved.id.startsWith('s:') ? 's:' : 'c:'; - try { - decodeEnvelope(text, prefix, prefix); - } catch { - throw new DuplexError('invalid_message', 'Invalid structured profile frame.'); - } - const frame = saved as unknown as ProfileFrame; - const associated = outgoingTraces.get(value); - if (associated) outgoingTraces.set(frame, associated); - return frame; -} - -/** @internal Remove local publication proof from a dispatched refusal. */ -export function publicError(error: unknown): DuplexError { - // Reconstructing public data strips local publication proof after dispatch. - return error instanceof DuplexError && - typeof error.code === 'string' && - error.code.length > 0 && - typeof error.message === 'string' && - error.message.length > 0 - ? new DuplexError(error.code, error.message, error.data) - : new DuplexError('internal', 'Request handler failed.'); -} -/** @internal Return a validated public result or a bounded internal-error fallback. */ -export function response(request: Message, result?: unknown, error?: unknown): DuplexError | undefined { - if (request.frame.kind !== 'request' || !request.return) - return new DuplexError('disconnected', 'The request has no return address.'); - let outcome: DuplexError | undefined; - let payload: { result: unknown } | { error: { code: string; message: string; data?: unknown } }; - try { - if (error !== undefined) { - const refused = publicError(error); - // Retain a valid local cancellation identity for the helper's outcome; - // only normalized public fields below enter the response frame. - outcome = - error instanceof DuplexError && error.code === refused.code && error.message === refused.message - ? error - : refused; - payload = snapshot({ - error: { - code: refused.code, - message: refused.message, - ...(refused.data === undefined ? {} : { data: refused.data }), - }, - }); - } else payload = { result: snapshot(result === undefined ? null : result) }; - } catch { - outcome = new DuplexError('internal', 'Response could not be encoded'); - payload = { error: { code: 'internal', message: 'Response could not be encoded' } }; - } - const frame: ProfileFrame = { - version: 1, - kind: 'response', - id: request.frame.id, - ...payload, - ...traceOf(request.frame), - }; - try { - request.return.wire.send([], { frame }); - } catch (error) { - if (error instanceof DuplexError && ['invalid_message', 'frame_too_large'].includes(error.code)) { - outcome = new DuplexError('internal', 'Response could not be encoded'); - try { - request.return.wire.send([], { - frame: { - version: 1, - kind: 'response', - id: request.frame.id, - error: { code: 'internal', message: 'Response could not be encoded' }, - ...traceOf(request.frame), - }, - }); - } catch { - /* The return address cannot admit even the bounded error response. */ - } - } else outcome ??= publicError(error); - /* The caller may already have cancelled or ended. */ - } - return outcome; -} - -/** Calls through the shared request primitive; no new peer or channel is made. */ -export function callWire( - wire: Wire, - path: Path, - params: unknown = {}, - options: WireCallOptions = {}, -): Promise { - return callWireTraced(wire, path, params, options, (options.propagator ?? defaultPropagator).inject(options.context)); -} -function callWireTraced( - wire: Wire, - path: Path, - params: unknown, - options: WireCallOptions, - trace?: Trace, - dispatch?: WireDispatchContext, -): Promise { - let name: string; - let frame: ProfileFrame; - try { - name = encodePath(path); - if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) - throw new DuplexError('invalid_options', 'timeoutMs must be a positive safe integer.'); - if (options.signal?.aborted) throw new DuplexError('cancelled', 'Call was cancelled before sending.'); - frame = snapshot( - carrying({ version: 1, kind: 'request', id: 'c:1', params, ...trace }, options.meta), - ) as unknown as ProfileFrame; - } catch (error) { - return Promise.reject(new UnpublishedError(error)); - } - if (!dispatch && trace) outgoingTraces.set(frame, trace); - const completion = requestCompletion(); - if (dispatch) dispatch = { ...dispatch, completion: { cancelled: false } }; - const invocation = new Invocation(defaultInvocationLimits()); - const finish = observeWireRequest(options.observer, options.family, name, false, trace); - let localOutcome: 'cancelled' | 'timeout' | undefined; - void completion.promise.then( - () => finish(), - (error: unknown) => finish(error, localOutcome ?? 'error'), - ); - const returning: Wire = { - send: (suffix, message) => { - if (suffix.length) { - invocation.deliver(suffix, message); - return; - } - const frame = profileFrame(message.frame, '', dispatch?.maxFrameBytes); - if (frame.kind !== 'response' || frame.id !== 'c:1') - throw new DuplexError('invalid_message', 'Invalid wire response.'); - if (completion.settled) throw new WireError('closed'); - if (frame.error?.code === 'cancelled' && dispatch?.context.signal.aborted && dispatch.completion?.cancelled) - completion.resolve(undefined as T); - else if (frame.error) completion.reject(new DuplexError(frame.error.code, frame.error.message, frame.error.data)); - else completion.resolve(frame.result as T); - invocation.settle(); - }, - }; - const address: ReturnAddress = { wire: returning }; - const retire = () => { - dispatchContexts.delete(address); - invocation.settle(); - invocation.dispatchDone(); - }; - if (dispatch) dispatchContexts.set(address, dispatch); - void completion.promise.then(retire, retire); - try { - wire.send(path, { frame, return: address }); - } catch (error) { - completion.reject(new UnpublishedError(error)); - } - completion.wait(options.signal, options.timeoutMs ?? 30_000, name, (error, outcome) => { - if (completion.settled) return; - completion.cleanup(); - if (!dispatch) { - localOutcome = outcome; - completion.reject(error); - finish(error, outcome); - } - // An incoming dispatch occupies the carrier's handler budget until the - // receiver replies. Its cancellation ends the caller's wait elsewhere; - // settling this promise here would release a still-executing body. - try { - wire.send(path, { frame: { version: 1, kind: 'cancel', id: 'c:1', ...trace }, return: address }); - } catch { - /* Cancellation is best effort and never extends the caller's wait. */ - } - }); - return completion.promise; -} - -/** Registers one operation; application code runs after the delivering turn. */ -export function handleWire(wire: HandlerRegistry, path: Path, handler: WireHandler): () => void { - return registerWire(wire, path, { request: handler }); -} - -/** Registers request and event facets at one operation with one cancellation map. */ -export function registerWire(wire: HandlerRegistry, path: Path, handlers: WireHandlers): () => void { - const incoming = new Map>(); - const stop = () => { - for (const calls of incoming.values()) for (const controller of calls.values()) controller.abort(); - }; - const detach = wire.register(path, { - closed: stop, - message: (_path, message) => { - const frame = message.frame; - if (frame.kind === 'event') { - if (!handlers.event) return; - if (handlers.observer) - observeWire(handlers.observer, { - type: 'event.delivered', - at: new Date(), - name: encodePath(path), - bytes: new TextEncoder().encode(JSON.stringify(frame.data)).byteLength, - trace: traceOf(frame), - family: handlers.family ?? '', - }); - const dispatch = wireEventContext(message); - const context = (dispatch ? Object.create(dispatch.context) : {}) as WireEventContext; - Object.defineProperties(context, { - wire: { value: wire, enumerable: true }, - meta: { value: frame.meta ? { ...frame.meta } : undefined, enumerable: true }, - }); - if (!dispatch) defaultPropagator.extract(context, traceOf(frame)); - const failed = (error: unknown) => { - if (!(error instanceof DuplexError)) dispatch?.panic?.(error); - wire.close(1002, 'wire event rejected'); - }; - try { - const pending = handlers.event(frame.data, context); - if (pending) return pending.catch(failed); - } catch (error) { - failed(error); - } - return; - } - if ((frame.kind !== 'request' && frame.kind !== 'cancel') || !message.return) return; - let calls = incoming.get(message.return); - if (frame.kind === 'cancel') { - calls?.get(frame.id)?.abort(); - return; - } - const finish = observeWireRequest(handlers.observer, handlers.family, encodePath(path), true, traceOf(frame)); - if (!handlers.request) { - const error = new DuplexError('method_not_found', 'An event has no request handler.'); - finish(response(message, undefined, error)); - return; - } - if (calls?.has(frame.id)) { - const error = new DuplexError('invalid_message', 'Duplicate active request identifier.'); - finish(response(message, undefined, error)); - return; - } - if (!calls) { - calls = new Map(); - incoming.set(message.return, calls); - } - const controller = new AbortController(); - calls.set(frame.id, controller); - const dispatch = dispatchContexts.get(message.return); - const context: WireRequestContext = dispatch - ? (Object.create(dispatch.context) as WireRequestContext) - : ({ - ...(frame.meta ? { meta: { ...frame.meta } } : {}), - } as WireRequestContext); - Object.defineProperties(context, { - wire: { value: wire, enumerable: true }, - signal: { - value: dispatch ? AbortSignal.any([controller.signal, dispatch.context.signal]) : controller.signal, - enumerable: true, - }, - requestId: { value: dispatch?.context.requestId ?? frame.id, enumerable: true }, - }); - if (!dispatch) defaultPropagator.extract(context, traceOf(frame)); - // The body runs after this receiver returns, so returning is not - // completion. The lease says so to whoever admitted the request: an - // early answer to the caller cannot retire an invocation whose body is - // still running. A return capability that carries no lifecycle still - // gets ordinary addressed delivery. - // A bound reached is a refusal; any other refusal means this return - // capability carries no lifecycle, and ordinary addressed delivery goes - // on without one. - let body: { done(): void } | undefined; - try { - body = beginInvocationBody(message); - } catch (error) { - if (error instanceof InvocationError && error.code === 'limit') { - calls.delete(frame.id); - if (!calls.size) incoming.delete(message.return); - controller.abort(); - finish(response(message, undefined, new DuplexError('busy', 'Invocation participation limit reached.'))); - return; - } - } - let cancelledBeforeHandler = false; - void Promise.resolve() - .then(() => { - if (context.signal.aborted) { - cancelledBeforeHandler = true; - throw new DuplexError('cancelled', 'Request was cancelled.'); - } - return handlers.request!(frame.params, context); - }) - .then( - (result) => { - const error = context.signal.aborted ? new DuplexError('cancelled', 'Request was cancelled.') : undefined; - if (error && dispatch?.completion) dispatch.completion.cancelled = true; - const outcome = response(message, result, error); - finish(outcome, error && outcome === error ? 'cancelled' : undefined); - }, - (error: unknown) => { - if (!(error instanceof DuplexError)) dispatch?.panic(error); - // A public handler refusal stays an error even if cancellation - // raced its completion. Only this helper's withdrawal is local. - if (cancelledBeforeHandler && dispatch?.completion) dispatch.completion.cancelled = true; - const outcome = response(message, undefined, error); - finish(outcome, cancelledBeforeHandler && outcome === error ? 'cancelled' : 'error'); - }, - ) - .finally(() => { - body?.done(); - calls!.delete(frame.id); - if (!calls!.size) incoming.delete(message.return!); - }); - }, - }); - return () => { - detach(); - stop(); - }; -} - -/** Emits a relative event; return means accepted, never consumed. */ -export function emitWire(wire: Wire, path: Path, data: unknown = null, options: WireEmitOptions = {}): void { - try { - const name = encodePath(path); - const trace = (options.propagator ?? defaultPropagator).inject(options.context); - const frame = snapshot( - carrying({ version: 1, kind: 'event', data, ...trace }, options.meta), - ) as unknown as ProfileFrame; - outgoingTraces.set(frame, trace); - if (options.observer) - observeWire(options.observer, { - type: 'event.emitted', - at: new Date(), - name, - bytes: new TextEncoder().encode(JSON.stringify(frame.kind === 'event' ? frame.data : null)).byteLength, - trace, - family: options.family ?? '', - }); - wire.send(path, { frame }); - } catch (error) { - throw new UnpublishedError(error); - } -} - -/** The root's existing serial event dispatcher awaits an async listener. */ -export function onWireEvent(wire: HandlerRegistry, path: Path, listener: WireEventListener): () => void { - return registerWire(wire, path, { event: listener }); -} - -/** Forwards both relative origins without owning either endpoint. */ -export function forwardWire(a: Endpoint, b: Endpoint): () => void { - const removals: (() => void)[] = []; - let detached = false; - const stop = () => { - if (detached) return; - detached = true; - for (const remove of removals) remove(); - }; - const receiver = (destination: Wire): Receiver => ({ - closed: stop, - message: (path, message) => { - // Detach stops new dispatch, not controls for an already captured call. - try { - destination.send(path, message); - } catch (error) { - stop(); - response(message, undefined, publicError(error)); - } - }, - }); - try { - for (const [source, destination] of [ - [a, b], - [b, a], - ] as const) { - const remove = source.receive(receiver(destination)); - if (detached) remove(); - else removals.push(remove); - } - } catch (error) { - stop(); - throw error; - } - return stop; -} - -/** @internal Hooks retain all carrier ownership in the peer. */ -export interface PeerWireOptions { - queueCapacity: number; - maxPendingRequests: number; - maxFrameBytes: number; - requestTimeoutMs: number; - call: (method: string, params: unknown, options: CallOptions, trace?: Trace) => Promise; - emit: (name: string, data: unknown, options: EmitOptions, trace?: Trace) => Promise; - dispatch: ( - request: (method: string) => RequestHandler | undefined, - event: (name: string) => EventListener | undefined, - ) => void; - fail: (error: DuplexError) => void; - pressure: (waiting: number) => void; - panic: (method: string, error: unknown, trace?: Trace) => void; - close: (code: number, reason: string) => void; -} - -interface RoutedCall { - address: ReturnAddress; - id: string; - controller: AbortController; - completed: boolean; - cancelQueued: boolean; - cancelled: boolean; -} -interface RoutedDelivery { - path: Path; - message: Message; - call?: RoutedCall; - refusal?: DuplexError; -} - -interface WireRegistration { - receiver: Receiver; - request: (path: Path, params: unknown, context: RequestContext) => Promise; - detach: () => void; -} - -/** @internal One bridge per peer; selection never constructs another. */ -export function peerWire(peer: DuplexPeer, options: PeerWireOptions): Endpoint { - const queued: RoutedDelivery[] = []; - const incoming = new Map>(); - let attachment: WireRegistration | undefined; - const lookup = (name: string): { path: Path; registration: WireRegistration } | undefined => { - let path: Path; - try { - path = decodePath(name); - } catch { - return; - } - return attachment ? { path, registration: attachment } : undefined; - }; - options.dispatch( - (name) => { - const found = lookup(name); - return found ? (params, context) => found.registration.request(found.path, params, context) : undefined; - }, - (name) => { - const found = lookup(name); - return found - ? (_name, data, context) => { - const receivedTrace = receivedEventTraces.has(context) ? receivedEventTraces.get(context) : context.trace; - return found.registration.receiver.message?.( - found.path, - withWireEventContext( - { - frame: { - version: 1, - kind: 'event', - data, - ...receivedTrace, - ...(context.meta ? { meta: context.meta } : {}), - }, - }, - context, - (error) => options.panic(name, error, receivedTrace), - ), - ); - } - : undefined; - }, - ); - let retained = 0, - dataQueued = 0, - scheduled = false, - ended = false; - const endError = () => new DuplexError('disconnected', 'Connection ended; outcome may be unknown.'); - const retire = (call: RoutedCall) => { - // A completed call still owns a queued control slot. Reusing its budget - // early would let fast completions accumulate unbounded stale cancels. - if (!call.completed || call.cancelQueued) return; - const calls = incoming.get(call.address); - if (calls?.get(call.id) !== call) return; - calls.delete(call.id); - if (!calls.size) incoming.delete(call.address); - retained--; - }; - peer.onClose(() => { - ended = true; - for (const delivery of queued.splice(0)) response(delivery.message, undefined, endError()); - for (const calls of incoming.values()) for (const call of calls.values()) call.controller.abort(); - incoming.clear(); - retained = 0; - dataQueued = 0; - const ending = attachment ? [attachment] : []; - for (const { detach } of ending) detach(); - for (const { receiver } of ending) { - try { - receiver.closed?.(1001, 'peer ended'); - } catch { - /* One receiver cannot interrupt another's cleanup. */ - } - } - }); - const drain = () => { - scheduled = false; - while (queued.length && !ended) { - const { path, message, call, refusal } = queued.shift()!; - const frame = message.frame; - if (frame.kind === 'cancel') { - call!.cancelQueued = false; - call!.cancelled = true; - if (!call!.completed) call!.controller.abort(); - retire(call!); - continue; - } - dataQueued--; - if (refusal) { - response(message, undefined, refusal); - continue; - } - const name = encodePath(path); - if (frame.kind === 'event') { - // Invoke admission now, in wire order; only completion is asynchronous. - void options - .emit( - name, - frame.data, - { - meta: frame.meta ? { ...frame.meta } : undefined, - }, - outgoingTrace(frame), - ) - .catch((error: unknown) => options.fail(publicError(error))); - continue; - } - if (frame.kind !== 'request') continue; - // Peer.call allocates the carrier id and enqueues before it returns. - const pending = options.call( - name, - frame.params, - { - signal: call!.controller.signal, - meta: frame.meta ? { ...frame.meta } : undefined, - }, - outgoingTrace(frame), - ); - const finish = (value?: unknown, error?: unknown) => { - call!.completed = true; - retire(call!); - response(message, value, error); - }; - void pending.then( - (value) => finish(value), - (error: unknown) => finish(undefined, error), - ); - } - }; - const wire: Endpoint = { - send: (path, message) => { - const name = encodePath(path); - if (ended || peer.status !== 'connected') throw endError(); - const frame = profileFrame(message.frame, name, options.maxFrameBytes); - if (!name && (frame.kind === 'request' || frame.kind === 'event')) - throw new DuplexError('invalid_message', 'A root wire operation requires a nonempty path.'); - if ((frame.kind === 'request' || frame.kind === 'cancel') && !message.return?.wire) - throw new DuplexError('invalid_message', 'A wire request or cancellation requires a return address.'); - if (frame.kind !== 'request' && frame.kind !== 'event' && frame.kind !== 'cancel') - throw new DuplexError('invalid_message', "A response is sent to its request's return address."); - let call: RoutedCall | undefined; - if (frame.kind === 'cancel') { - call = incoming.get(message.return!)?.get(frame.id); - // Cancellation belongs to an already admitted request. Its one control - // reservation is bounded by the existing pending-request budget. - if (!call || call.completed || call.cancelQueued || call.cancelled) return; - } - if (ended || peer.status !== 'connected') throw endError(); - if (frame.kind !== 'cancel' && dataQueued >= options.queueCapacity) { - const error = new DuplexError('busy', 'Output consumer is stalled; queue limit reached.'); - options.pressure(dataQueued); - options.fail(error); - throw error; - } - let refusal: DuplexError | undefined; - if (frame.kind === 'cancel') call!.cancelQueued = true; - else { - dataQueued++; - if (frame.kind === 'request') { - let calls = incoming.get(message.return!); - if (calls?.has(frame.id) || retained >= options.maxPendingRequests) { - refusal = new DuplexError( - calls?.has(frame.id) ? 'invalid_message' : 'busy', - 'Outstanding wire call refused.', - ); - } else { - if (!calls) { - calls = new Map(); - incoming.set(message.return!, calls); - } - call = { - address: message.return!, - id: frame.id, - controller: new AbortController(), - completed: false, - cancelQueued: false, - cancelled: false, - }; - calls.set(frame.id, call); - retained++; - } - } - } - // Data and reserved control entries share one FIFO. A cancel cannot jump - // ahead of an earlier event, request, or cancellation on this wire. - queued.push({ path: [...path], message: { frame, return: message.return }, call, refusal }); - if (!scheduled) { - scheduled = true; - queueMicrotask(drain); - } - }, - receive: (receiver) => { - if (ended) throw endError(); - if (attachment) throw new WireError('receiver_exists'); - const target: Wire = { - send: (suffix, message) => { - try { - if (!receiver.message) { - response(message, undefined, new DuplexError('method_not_found', 'Unknown method.')); - return; - } - const result = receiver.message(suffix, message); - if (result) void result.catch((error: unknown) => response(message, undefined, publicError(error))); - } catch (error) { - response(message, undefined, publicError(error)); - } - }, - }; - const request = (received: Path, params: unknown, context: RequestContext) => - callWireTraced( - target, - received, - params, - { - signal: context.signal, - timeoutMs: options.requestTimeoutMs, - meta: context.meta, - }, - context.trace, - { - context, - panic: (error) => options.panic(encodePath(received), error, context.trace), - maxFrameBytes: options.maxFrameBytes, - }, - ); - const registration: WireRegistration = { - receiver, - request, - detach: () => { - if (attachment === registration) attachment = undefined; - }, - }; - attachment = registration; - return registration.detach; - }, - close: (code = 1000, reason = '') => options.close(code, reason), - }; - return wire; -} diff --git a/engine/ts/src/index.ts b/engine/ts/src/index.ts new file mode 100644 index 0000000..17e18ca --- /dev/null +++ b/engine/ts/src/index.ts @@ -0,0 +1,7 @@ +/** + * `@bitspark/bitruntime/engine`: the bitwire/1 protocol engine. A Peer speaks + * the protocol over any frames duplex connection of the seam โ€” a pipe, a + * WebSocket it connects or is handed โ€” and presents it only through its root + * Endpoint, `wire()`. + */ +export { Peer, PEER_DEFAULTS, PROTOCOL, type PeerOptions, type PeerStatus } from './peer.ts'; diff --git a/engine/ts/src/peer.ts b/engine/ts/src/peer.ts index 55eceb2..7a39e8e 100644 --- a/engine/ts/src/peer.ts +++ b/engine/ts/src/peer.ts @@ -1,88 +1,56 @@ -import { DuplexError, UnpublishedError } from './error.ts'; -export { DuplexError, UnpublishedError } from './error.ts'; -import { decodeEnvelope, carrying, requireName, isObject, type Envelope } from './envelope.ts'; -import { webSocketConnection } from '@nightseam/duplex'; -import type { Frame, FrameConnection, WebSocketLike, Endpoint } from '@nightseam/duplex'; -import { defaultPropagator, traceOf, traced } from './trace.ts'; -import type { Propagator, Trace, TraceContext } from './trace.ts'; -import { peerWire, requestCompletion, setReceivedEventTrace } from './wire.ts'; -import type { Observer, ObserverEvent } from './observer.ts'; -import { scalarJSON } from './unicode.ts'; +import type { Endpoint } from '@bitspark/bitwire'; +import { PublicError, UnpublishedError } from '../../../core/ts/src/error.ts'; +import type { Meta } from '../../../core/ts/src/meta.ts'; +import { defaultPropagator, type Propagator, type Trace } from '../../../core/ts/src/trace.ts'; +import { + setReceivedEventTrace, + type ReceivedEventContext, + type ReceivedRequestContext, +} from '../../../core/ts/src/internal/context.ts'; +import { carrying, decodeEnvelope, isObject, requireName, type Envelope } from '../../../core/ts/src/internal/envelope.ts'; +import { ended } from '../../../core/ts/src/internal/frame.ts'; +import { LIMIT_DEFAULTS, limits } from '../../../core/ts/src/internal/limits.ts'; +import { requestCompletion } from '../../../core/ts/src/internal/request.ts'; +import { traceOf, traced } from '../../../core/ts/src/internal/trace.ts'; +import { scalarJSON } from '../../../core/ts/src/internal/unicode.ts'; +import { + sendable, + webSocketConnection, + type Frame, + type FrameConnection, + type WebSocketLike, +} from '../../../transports/ts/src/index.ts'; +import { rootWire, type EventHandler, type RequestHandler } from './wire.ts'; -export type { WebSocketLike } from '@nightseam/duplex'; - -/** The endpoint selects this profile; it is offered as no subprotocol by default. */ -export const DUPLEX_PROFILE = 'nightseam.duplex/1'; -/** The limits a peer runs with unless its options say otherwise; docs/runtime/peer.md tables them. */ -export const DUPLEX_DEFAULTS = Object.freeze({ - maxConcurrentHandlers: 64, - maxPendingRequests: 128, - queueCapacity: 128, - maxFrameBytes: 1_048_576, - requestTimeoutMs: 30_000, - writeTimeoutMs: 10_000, - connectTimeoutMs: 30_000, -}); +/** + * The protocol revision this engine speaks: the behavior of Nightseam + * v0.6.0's `nightseam.duplex/1` profile. The name never travels on a + * connection and is offered as no subprotocol by default. + */ +export const PROTOCOL = 'bitwire/1'; +/** The limits a peer runs with unless its options say otherwise. */ +export const PEER_DEFAULTS = Object.freeze({ ...LIMIT_DEFAULTS, connectTimeoutMs: 30_000 }); /** Where a peer is between construction and its end; `connected` is the only state that carries frames. */ export type PeerStatus = 'disconnected' | 'connecting' | 'connected'; -/** - * What a frame carries about a call rather than of it: a flat map of strings โ€” - * a tenant, an idempotency key, a credential that is per request โ€” which the - * profile carries verbatim and reads nothing into. - */ -export type Meta = Record; -/** What a call may carry: a signal that withdraws it, a deadline of its own, the context it is made under (so its trace parents on the request being served), and its meta. */ -export interface CallOptions { - signal?: AbortSignal; - timeoutMs?: number; - context?: TraceContext; - meta?: Meta; -} -/** What an emit may carry: the context it is made under, and its meta. */ -export interface EmitOptions { - context?: TraceContext; - meta?: Meta; -} -/** What a handler is given beside the params: a signal that fires when the caller withdraws the request or its deadline passes, the peer it arrived on, the request's id, and the trace and meta the frame brought. */ -export interface RequestContext { - signal: AbortSignal; - peer: DuplexPeer; - requestId: string; - /** What the propagator read from the frame that started this request. */ - trace?: Trace; + +/** How a peer is made: its role, its preparation, the socket factory, its limits and its propagator. Every member is optional and takes PEER_DEFAULTS. */ +export interface PeerOptions { /** - * What the frame that started this request carried, and absent where it - * carried none. Passing it on is the handler's to say โ€” `{ meta: context.meta }` - * โ€” since a trace is the peer's to propagate and a credential is not. + * Runs once after validation, before any connection can read: a receiver it + * attaches to the peer's wire() is there before anything can arrive. Must be + * synchronous; a throw fails the construction. */ - meta?: Meta; -} -/** What an event's listeners are told about the frame that carried it. */ -export interface EventContext { - peer: DuplexPeer; - trace?: Trace; - meta?: Meta; -} -/** Answers one request: the result, or a thrown DuplexError that crosses the wire with its code โ€” any other error reaches the caller as `internal`. */ -export type RequestHandler = (params: unknown, context: RequestContext) => unknown | Promise; -/** Answers every request the peer has no handler for, by method name; the generated binding installs one. */ -export type Dispatcher = (method: string, params: unknown, context: RequestContext) => unknown | Promise; -/** Takes one event's data; events have no answer, and listeners run one at a time in arrival order. */ -export type EventListener = (event: string, data: unknown, context: EventContext) => void | Promise; -/** How a peer is made: its role, its dispatcher, the socket factory, its limits, its propagator, its observer and the family each name belongs to. Every member is optional and takes DUPLEX_DEFAULTS. */ -export interface PeerOptions { - /** Installs handlers once after validation, before any connection can read. Must be synchronous. */ - prepare?: (peer: DuplexPeer) => void; + prepare?: (peer: Peer) => void; + /** The side of the connection; it decides the prefix of the request ids the peer mints, c: or s:. */ role?: 'client' | 'server'; - dispatch?: Dispatcher; webSocketFactory?: (url: string, protocols?: string[]) => WebSocketLike; /** * Offered to the server at the handshake, in order of preference; none by * default. A server that selects none leaves the connection with none and - * the profile is spoken over it either way โ€” but a browser refuses a + * the protocol is spoken over it either way โ€” but a browser refuses a * handshake whose offer went unselected, so a client that offers must be - * met by a server that selects (docs/wire/profile.md). + * met by a server that selects. */ subprotocols?: string[]; maxConcurrentHandlers?: number; @@ -94,22 +62,14 @@ export interface PeerOptions { connectTimeoutMs?: number; /** Absent, the default mints W3C ids; an adapter for a tracing library replaces it. */ propagator?: Propagator; - onError?: (error: DuplexError) => void; - observer?: Observer; - /** Method or event name to family label; the generated install fills it. */ - families?: Record; + /** Told of failures the peer does not surface elsewhere: why it ended, a listener that failed. */ + onError?: (error: PublicError) => void; } type Timer = ReturnType; -/** How a request ended, as the observer's event spells it. */ -type Outcome = Extract['outcome']; interface Pending { resolve: (value: unknown) => void; - reject: (error: DuplexError) => void; - /** What an observer is told the call was, wherever and whenever it ends. */ - method: string; - started: number; - trace?: Trace; + reject: (error: PublicError) => void; cleanup: () => void; } interface Incoming { @@ -118,35 +78,34 @@ interface Incoming { responded: boolean; /** The request's trace; its response, and nothing else, carries it back. */ trace?: Trace; - method: string; - started: number; } interface Outgoing { text: string; started: number; sent: boolean; waited: boolean; - /** What the observer is told of this frame, called by the writer just before the bytes leave. */ - observeSent?: () => void; } interface QueuedEvent { name: string; data: unknown; - bytes: number; trace?: Trace; meta?: Meta; } /** - * A bounded full-duplex peer. Message routing never awaits application handlers. - * Calls are not retried, and connections are never reopened automatically. - * The caller owns endpoint authentication and authorization of incoming methods. + * A bounded full-duplex bitwire/1 peer over a frames duplex connection. It + * presents the protocol only through its root Endpoint, wire(): a request or + * an event sent there travels with its path's canonical encoding as its method + * or event name, and what the remote side sends is delivered to the root's + * receiver by the path its name encodes. Message routing never awaits + * application handlers. Calls are not retried, and connections are never + * reopened automatically. The caller owns endpoint authentication and + * authorization of incoming requests. */ -export class DuplexPeer { +export class Peer { private readonly options: PeerOptions; - private readonly limits: typeof DUPLEX_DEFAULTS; + private readonly limits: { -readonly [K in keyof typeof PEER_DEFAULTS]: number }; private readonly propagator: Propagator; - private readonly observer?: Observer; private readonly localPrefix: string; private readonly remotePrefix: string; private connection?: FrameConnection; @@ -162,15 +121,13 @@ export class DuplexPeer { // for room takes its turn behind them rather than jumping in. private readonly publishing: number[] = []; private nextTicket = 0; - private opening?: { resolve: () => void; reject: (error: DuplexError) => void; timer: Timer }; + private opening?: { resolve: () => void; reject: (error: PublicError) => void; timer: Timer }; private readonly pending = new Map(); private readonly incoming = new Map(); - private readonly handlers = new Map(); - private readonly listeners = new Set(); - private readonly closedListeners = new Set<(error: DuplexError) => void>(); + private readonly closedListeners = new Set<(error: PublicError) => void>(); private readonly outgoing: Outgoing[] = []; private writeTimer?: Timer; - /** Public senders paced by a full output queue; wire handoffs never join it. */ + /** Senders paced by a full output queue; wire handoffs never join it. */ private readonly waitingForRoom = new Set<(room: boolean) => void>(); private readonly events: QueuedEvent[] = []; private eventActive = false; @@ -181,37 +138,27 @@ export class DuplexPeer { private negotiated = ''; private relativeWire?: Endpoint; private wireRequest?: (method: string) => RequestHandler | undefined; - private wireEvent?: (name: string) => EventListener | undefined; + private wireEvent?: (name: string) => EventHandler | undefined; constructor(options: PeerOptions = {}) { this.options = options; if (options.role !== undefined && options.role !== 'client' && options.role !== 'server') { - throw new DuplexError('invalid_options', 'Peer role must be client or server.'); - } - this.limits = { ...DUPLEX_DEFAULTS }; - for (const key of Object.keys(DUPLEX_DEFAULTS) as (keyof typeof DUPLEX_DEFAULTS)[]) { - const value = options[key]; - if (value !== undefined) { - positiveInteger(value, key, true); - // Values deliberately remain configurable without introducing unbounded queues. - (this.limits as Record)[key] = value; - } + throw new PublicError('invalid_options', 'Peer role must be client or server.'); } + // Values deliberately remain configurable without introducing unbounded queues. + this.limits = limits(PEER_DEFAULTS, options); this.propagator = options.propagator ?? defaultPropagator; - this.observer = options.observer; this.localPrefix = options.role === 'server' ? 's:' : 'c:'; this.remotePrefix = options.role === 'server' ? 'c:' : 's:'; try { const preparation: unknown = options.prepare?.(this); if (preparation && typeof (preparation as PromiseLike).then === 'function') { void Promise.resolve(preparation).catch(() => {}); - throw new DuplexError('invalid_options', 'prepare must complete synchronously.'); + throw new PublicError('invalid_options', 'prepare must complete synchronously.'); } } catch (error) { // Preparation owns no transport yet, but it can already own wire - // registrations and scopes. Notify their existing close hooks once. - this.handlers.clear(); - this.listeners.clear(); + // registrations. Notify their existing close hooks once. for (const listener of [...this.closedListeners]) { try { listener(asError(error)); @@ -229,47 +176,37 @@ export class DuplexPeer { return this.state; } - /** This peer's relative origin; selections share its existing carrier. */ + /** + * This peer's root origin: send access to the remote side's paths, receive + * attachment for the requests and events the remote side sends, and the + * connection's closure. Repeated calls return the same endpoint. + */ wire(): Endpoint { - return (this.relativeWire ??= peerWire(this, { + return (this.relativeWire ??= rootWire(this, { queueCapacity: this.limits.queueCapacity, maxPendingRequests: this.limits.maxPendingRequests, maxFrameBytes: this.limits.maxFrameBytes, requestTimeoutMs: this.limits.requestTimeoutMs, - call: (method, params, options, trace) => this.callWithTrace(method, params, options, () => trace, true), - emit: (name, data, options, trace) => this.emitWithTrace(name, data, options, trace, true), + call: (method, params, options, trace) => this.callWithTrace(method, params, options, trace), + emit: (name, data, options, trace) => this.emitWithTrace(name, data, options, trace), dispatch: (request, event) => { this.wireRequest = request; this.wireEvent = event; }, fail: (error) => this.fail(error), - pressure: (waiting) => { - if (this.observer) this.pressure(waiting, true); - }, - panic: (method, error, trace) => { - if (this.observer) - this.observe({ - type: 'handler.panic', - at: new Date(), - method, - value: describe(error), - trace, - family: this.family(method), - }); - }, close: (code, reason) => - this.fail(new DuplexError('disconnected', 'Connection closed by caller.'), true, code, reason), + this.fail(new PublicError('disconnected', 'Connection closed by caller.'), true, code, reason), })); } - /** The side of the connection this peer is; a tunnel over it chooses channel ids by it. */ + /** The side of the connection this peer is. */ get role(): 'client' | 'server' { return this.options.role ?? 'client'; } /** * What the WebSocket handshake beneath this peer selected, and '' when it - * selected none or the peer does not run over a WebSocket. The profile + * selected none or the peer does not run over a WebSocket. The protocol * reads nothing into it. */ get subprotocol(): string { @@ -278,16 +215,16 @@ export class DuplexPeer { /** Absolute ws/wss URLs are required. Factories may supply platform-specific auth. */ connect(url: string): Promise { - if (this.connection) return Promise.reject(new DuplexError('already_connected', 'Peer already has a connection.')); + if (this.connection) return Promise.reject(new PublicError('already_connected', 'Peer already has a connection.')); let endpoint: URL; try { endpoint = new URL(url); } catch { - return Promise.reject(new DuplexError('invalid_url', 'An absolute WebSocket URL is required.')); + return Promise.reject(new PublicError('invalid_url', 'An absolute WebSocket URL is required.')); } if (!['ws:', 'wss:'].includes(endpoint.protocol) || endpoint.hash || endpoint.username || endpoint.password) { return Promise.reject( - new DuplexError('invalid_url', 'Use an absolute ws/wss URL without credentials or a fragment.'), + new PublicError('invalid_url', 'Use an absolute ws/wss URL without credentials or a fragment.'), ); } let socket: WebSocketLike; @@ -297,7 +234,7 @@ export class DuplexPeer { this.options.webSocketFactory?.(endpoint.href, protocols) ?? (protocols ? new WebSocket(endpoint.href, protocols) : new WebSocket(endpoint.href)); } catch { - return Promise.reject(new DuplexError('connection_failed', 'Unable to create WebSocket.')); + return Promise.reject(new PublicError('connection_failed', 'Unable to create WebSocket.')); } return this.attach(socket); } @@ -307,26 +244,23 @@ export class DuplexPeer { * WebSocket is wrapped by the adapter; the peer itself never touches one. */ attach(connection: FrameConnection | WebSocketLike): Promise { - if (this.connection) return Promise.reject(new DuplexError('already_connected', 'Peer already has a connection.')); + if (this.connection) return Promise.reject(new PublicError('already_connected', 'Peer already has a connection.')); const socket = isWebSocketLike(connection) ? connection : undefined; const frames = socket === undefined ? (connection as FrameConnection) : webSocketConnection(socket); if (frames.state !== 'connecting' && frames.state !== 'open') { - return Promise.reject(new DuplexError('disconnected', 'Cannot attach a closing or closed WebSocket.')); + return Promise.reject(new PublicError('disconnected', 'Cannot attach a closing or closed WebSocket.')); } this.connection = frames; this.generation++; this.state = frames.state === 'open' ? 'connected' : 'connecting'; // The handshake has selected by the time the socket opens, and not before. this.negotiated = this.state === 'connected' ? subprotocolOf(socket) : ''; - if (this.observer && this.state === 'connected') - this.observe({ type: 'connection.opened', at: new Date(), role: this.role }); const current = () => this.connection === frames; this.detach = frames.listen({ open: () => { if (!current()) return; this.state = 'connected'; this.negotiated = subprotocolOf(socket); - if (this.observer) this.observe({ type: 'connection.opened', at: new Date(), role: this.role }); if (this.opening) { clearTimeout(this.opening.timer); this.opening.resolve(); @@ -339,20 +273,20 @@ export class DuplexPeer { close: (code, reason) => { if (current()) this.fail( - new DuplexError('disconnected', 'Connection closed; outstanding call outcomes may be unknown.'), + new PublicError('disconnected', 'Connection closed; outstanding call outcomes may be unknown.'), false, code, reason, ); }, error: () => { - if (current()) this.fail(new DuplexError('connection_failed', 'WebSocket connection failed.')); + if (current()) this.fail(new PublicError('connection_failed', 'WebSocket connection failed.')); }, }); if (this.state === 'connected') return Promise.resolve(); return new Promise((resolve, reject) => { const timer = setTimeout( - () => this.fail(new DuplexError('connect_timeout', 'Connection timed out.')), + () => this.fail(new PublicError('connect_timeout', 'Connection timed out.')), this.limits.connectTimeoutMs, ); this.opening = { resolve, reject, timer }; @@ -361,11 +295,11 @@ export class DuplexPeer { /** Ends the connection with a normal close; every pending call rejects with `disconnected`. */ close(): void { - this.fail(new DuplexError('disconnected', 'Connection closed by caller.'), true, 1000); + this.fail(new PublicError('disconnected', 'Connection closed by caller.'), true, 1000); } /** Tells the listener once, when the peer ends, why it ended; returns what removes the listener. */ - onClose(listener: (error: DuplexError) => void): () => void { + onClose(listener: (error: PublicError) => void): () => void { this.closedListeners.add(listener); return () => { this.closedListeners.delete(listener); @@ -373,82 +307,40 @@ export class DuplexPeer { } /** - * Serves a method: one handler per name, given the params and a request context, its return the - * result and a thrown DuplexError the error the caller receives. Returns what unregisters it. + * Performs the bounded admission of one outgoing request immediately: the + * root hands requests over in its delivery order, and this reserves the + * serial and enqueues before it returns. It resolves with the result, or + * rejects with the remote's public error or the peer's own: `request_timeout` + * past the deadline, `cancelled` when the signal fired, `busy` when too many + * calls are outstanding, `disconnected` when the connection ended first. */ - handle(method: string, handler: RequestHandler): () => void { - requireName(method, 'method'); - if (this.handlers.has(method)) - throw new DuplexError('duplicate_handler', `Handler already registered for ${method}.`); - this.handlers.set(method, handler); - return () => { - if (this.handlers.get(method) === handler) this.handlers.delete(method); - }; - } - - /** Listens to every event the remote emits, or to one by name; returns what removes the listener. */ - onEvent(listener: EventListener): () => void; - onEvent(event: string, listener: (data: unknown, context: EventContext) => void | Promise): () => void; - onEvent( - eventOrListener: string | EventListener, - listener?: (data: unknown, context: EventContext) => void | Promise, - ): () => void { - let callback: EventListener; - if (typeof eventOrListener === 'string') { - requireName(eventOrListener, 'event'); - if (!listener) throw new DuplexError('invalid_listener', 'An event listener is required.'); - callback = (event, data, context) => { - if (event === eventOrListener) return listener(data, context); - }; - } else { - callback = eventOrListener; - } - this.listeners.add(callback); - return () => { - this.listeners.delete(callback); - }; - } - - /** - * Calls a method on the remote and resolves with its result, or rejects with the DuplexError the - * remote answered โ€” or the peer's own: `request_timeout` past the deadline, `cancelled` when the - * caller's signal fired, `busy` when too many calls are outstanding, `disconnected` when the - * connection ended first. - */ - call(method: string, params: unknown = {}, options: CallOptions = {}): Promise { - return this.callWithTrace(method, params, options, () => this.propagator.inject(options.context)); - } - private callWithTrace( method: string, params: unknown, - options: CallOptions, - traceSource: () => Trace | undefined, - immediate = false, + options: { signal?: AbortSignal; meta?: Meta }, + trace: Trace | undefined, ): Promise { try { requireName(method, 'method'); - if (options.timeoutMs !== undefined) positiveInteger(options.timeoutMs, 'timeoutMs', true); } catch (error) { return Promise.reject(new UnpublishedError(error)); } if (!this.isOpen()) - return Promise.reject(new UnpublishedError(new DuplexError('not_connected', 'Peer is not connected.'))); + return Promise.reject(new UnpublishedError(new PublicError('not_connected', 'Peer is not connected.'))); if (options.signal?.aborted) - return Promise.reject(new UnpublishedError(new DuplexError('cancelled', 'Call was cancelled before sending.'))); + return Promise.reject(new UnpublishedError(new PublicError('cancelled', 'Call was cancelled before sending.'))); if (this.pending.size >= this.limits.maxPendingRequests) { - return Promise.reject(new UnpublishedError(new DuplexError('busy', 'Outstanding call limit reached.'))); + return Promise.reject(new UnpublishedError(new PublicError('busy', 'Outstanding call limit reached.'))); } if (this.nextID >= Number.MAX_SAFE_INTEGER) { return Promise.reject( new UnpublishedError( - new DuplexError('identifier_exhausted', 'Create a new peer before issuing further calls.'), + new PublicError('identifier_exhausted', 'Create a new peer before issuing further calls.'), ), ); } const id = this.localPrefix + (++this.nextID).toString(10); // One trace for the exchange: the request carries it and its cancel repeats it. - const trace = traceSource(); let request: Envelope; try { request = carrying(traced({ version: 1, kind: 'request', id, method, params }, trace), options.meta); @@ -465,75 +357,40 @@ export class DuplexPeer { completion.cleanup(); admission.abort(); }, - method, - started: Date.now(), - trace, }; this.pending.set(id, pending); - const cancel = (error: DuplexError, outcome: Outcome) => { + const cancel = (error: PublicError) => { if (!this.takePending(id)) return; - this.ended(id, pending, outcome, error.code); completion.reject(accepted ? error : new UnpublishedError(error)); // Cancellation is best effort, as in Go. It never waits for room and // an already cancelled caller cannot end a healthy carrier merely // because its cancellation frame has no room in the output queue. if (accepted && this.outgoing.length < this.limits.queueCapacity) - void this.send(traced({ version: 1, kind: 'cancel', id }, trace), method).catch(() => {}); + void this.send(traced({ version: 1, kind: 'cancel', id }, trace)).catch(() => {}); }; - if (this.observer) - this.observe({ - type: 'request.started', - at: new Date(), - id, - method, - incoming: false, - trace, - family: this.family(method), - }); - completion.wait(options.signal, options.timeoutMs ?? this.limits.requestTimeoutMs, method, cancel); + completion.wait(options.signal, this.limits.requestTimeoutMs, method, cancel); const refused = (failure: unknown) => { const unsent = this.takePending(id); if (!unsent) return; - const error = asError(failure, 'send_failed'); - this.ended(id, unsent, 'error', error.code); - unsent.reject(error); + unsent.reject(asError(failure, 'send_failed')); }; if (!completion.settled) - void this.send(request, method, refused, undefined, immediate, admission.signal, () => { + void this.send(request, refused, true, admission.signal, () => { accepted = true; }).catch(refused); return completion.promise; } /** - * Emits one event and resolves when its frame was accepted for sending, which is queued for this - * connection and no more: an event says nothing about receipt, and a caller that wants delivery - * has a call. The queue's own deadline continues behind it and ends a connection that never drains. + * Queues one outgoing event immediately. It resolves when its frame was + * accepted for sending, which is queued for this connection and no more: an + * event says nothing about receipt. The queue's own deadline continues + * behind it and ends a connection that never drains. */ - emit(event: string, data: unknown = null, options: EmitOptions = {}): Promise { - try { - return this.emitWithTrace(event, data, options, this.propagator.inject(options.context)); - } catch (error) { - return Promise.reject(new UnpublishedError(error)); - } - } - - private emitWithTrace( - event: string, - data: unknown, - options: EmitOptions, - trace?: Trace, - immediate = false, - ): Promise { + private emitWithTrace(event: string, data: unknown, options: { meta?: Meta }, trace?: Trace): Promise { try { requireName(event, 'event'); - return this.send( - carrying(traced({ version: 1, kind: 'event', event, data }, trace), options.meta), - event, - undefined, - trace, - immediate, - ); + return this.send(carrying(traced({ version: 1, kind: 'event', event, data }, trace), options.meta), undefined, true); } catch (error) { return Promise.reject(new UnpublishedError(error)); } @@ -563,9 +420,7 @@ export class DuplexPeer { private async send( envelope: Envelope, - name = '', refused?: (error: UnpublishedError) => void, - carriedTrace?: Trace, immediate = false, abandoned?: AbortSignal, accepted?: () => void, @@ -577,7 +432,7 @@ export class DuplexPeer { const ticket = envelope.kind === 'request' ? this.nextTicket++ : undefined; if (ticket !== undefined) this.publishing.push(ticket); try { - if (!this.isOpen()) throw new DuplexError('not_connected', 'Peer is not connected.'); + if (!this.isOpen()) throw new PublicError('not_connected', 'Peer is not connected.'); let text: string; try { text = JSON.stringify(envelope, (_key, value: unknown) => { @@ -592,21 +447,20 @@ export class DuplexPeer { }); scalarJSON(text); } catch { - throw new DuplexError('invalid_message', 'Frame must contain serializable JSON values.'); + throw new PublicError('invalid_message', 'Frame must contain serializable JSON values.'); } const bytes = new TextEncoder().encode(text).byteLength; if (bytes > this.limits.maxFrameBytes) { - throw new DuplexError('frame_too_large', 'Outgoing frame exceeds the size limit.'); + throw new PublicError('frame_too_large', 'Outgoing frame exceeds the size limit.'); } - // A wire handoff must decide bounded admission immediately. Public peer - // sends instead pace transient bursts for at most one write deadline. + // A wire handoff must decide bounded admission immediately. A response + // instead paces a transient burst for at most one write deadline. const connection = this.connection; const deadline = Date.now() + this.limits.writeTimeoutMs; - let paced = false; for (;;) { if (abandoned?.aborted) return; if (!this.isOpen() || this.connection !== connection) - throw new DuplexError('not_connected', 'Peer is not connected.'); + throw new PublicError('not_connected', 'Peer is not connected.'); // The queue is the one ordering gate: room alone is not enough, the // sender must also be the one whose turn it is. That is what keeps // publication in the order senders reserved, which the request serial @@ -614,16 +468,9 @@ export class DuplexPeer { if (this.outgoing.length < this.limits.queueCapacity && (ticket === undefined || this.publishing[0] === ticket)) break; if (immediate || Date.now() >= deadline) { - if (this.observer) this.pressure(this.outgoing.length, true); endOnRefusal = true; - throw new DuplexError('busy', 'Output consumer is stalled; queue limit reached.'); - } - if (!paced) { - paced = true; - if (this.observer) this.pressure(this.outgoing.length, false); + throw new PublicError('busy', 'Output consumer is stalled; queue limit reached.'); } - // An observer may synchronously withdraw the call or close the peer. - if (abandoned?.aborted || !this.isOpen() || this.connection !== connection) continue; const room = await new Promise((resolve) => { const wake = (available: boolean) => { clearTimeout(timer); @@ -639,40 +486,16 @@ export class DuplexPeer { if (!room) { if (abandoned?.aborted) return; if (!this.isOpen() || this.connection !== connection) - throw new DuplexError('not_connected', 'Peer is not connected.'); - if (this.observer) this.pressure(this.outgoing.length, true); + throw new PublicError('not_connected', 'Peer is not connected.'); endOnRefusal = true; - throw new DuplexError('busy', 'Output consumer is stalled; queue limit reached.'); + throw new PublicError('busy', 'Output consumer is stalled; queue limit reached.'); } } - const kind = envelope.kind as string; - const trace = carriedTrace ?? traceOf(envelope); - const family = this.family(name); - // What the peer did comes before the frame that carried it, as the Go - // peer tells it: an event is emitted, then its frame is sent. The frame - // itself is observed by the writer, immediately before the bytes leave โ€” - // one serialization point per peer, so that nothing a frame draws can be - // observed received ahead of it (docs/runtime/observer.md). - if (this.observer && kind === 'event') - this.observe({ type: 'event.emitted', at: new Date(), name, bytes, trace, family }); - const observeSent = this.observer - ? () => - this.observe({ - type: 'frame.sent', - at: new Date(), - kind, - name, - bytes, - id: envelope.id as string | undefined, - trace, - family, - }) - : undefined; - // Accepted for sending is queued, as the profile says and as the Go peer - // returns: what the transport does with the frame after that is the - // transport's, held to the write deadline the flush keeps, and a sender - // that waited on the drain would hold a composition to this consumer. - this.outgoing.push({ text, started: Date.now(), sent: false, waited: false, observeSent }); + // Accepted for sending is queued, as the protocol says: what the + // transport does with the frame after that is the transport's, held to + // the write deadline the flush keeps, and a sender that waited on the + // drain would hold a composition to this consumer. + this.outgoing.push({ text, started: Date.now(), sent: false, waited: false }); queued = true; accepted?.(); this.release(ticket); @@ -680,7 +503,9 @@ export class DuplexPeer { } catch (error) { this.release(ticket); if (!queued) { - const proof = new UnpublishedError(error); + // A refusal that ends the carrier reports the carrier ended, keeping + // why as its cause (R26); the peer itself ends with that cause. + const proof = new UnpublishedError(endOnRefusal ? ended(error) : error); // Settle this unqueued attempt before a terminal admission failure // broadcasts an uncertain outcome to unrelated accepted requests. refused?.(proof); @@ -688,7 +513,7 @@ export class DuplexPeer { throw proof; } if (error instanceof UnpublishedError) { - const dispatched = new DuplexError(error.code, error.message, error.data); + const dispatched = new PublicError(error.code, error.message, error.data); Object.defineProperty(dispatched, 'cause', { value: error }); throw dispatched; } @@ -700,19 +525,17 @@ export class DuplexPeer { if (this.writeTimer || !this.isOpen()) return; const connection = this.connection!; while (this.outgoing.length) { - const item = this.outgoing[0]; + const item = this.outgoing[0]!; if (Date.now() - item.started >= this.limits.writeTimeoutMs) { - if (this.observer) this.pressure(this.outgoing.length, true); - this.fail(new DuplexError('write_timeout', 'Socket output did not drain before the write deadline.')); + this.fail(new PublicError('write_timeout', 'Socket output did not drain before the write deadline.')); return; } if (!item.sent && connection.buffered === 0) { - item.observeSent?.(); try { connection.send({ kind: 'text', data: item.text }); item.sent = true; } catch { - this.fail(new DuplexError('send_failed', 'WebSocket send failed.')); + this.fail(new PublicError('send_failed', 'WebSocket send failed.')); return; } } @@ -733,36 +556,23 @@ export class DuplexPeer { private receive(incoming: Frame): void { if (!this.isOpen()) return; if (incoming.kind !== 'text') { - this.fail(new DuplexError('invalid_message', 'Only JSON text frames are supported.')); + this.fail(new PublicError('invalid_message', 'Only JSON text frames are supported.')); return; } const data = incoming.data; const bytes = new TextEncoder().encode(data).byteLength; if (bytes > this.limits.maxFrameBytes) { - this.fail(new DuplexError('frame_too_large', 'Incoming frame exceeds the size limit.')); + this.fail(new PublicError('frame_too_large', 'Incoming frame exceeds the size limit.')); return; } let frame: Envelope; try { frame = decodeEnvelope(data, this.localPrefix, this.remotePrefix); } catch { - this.fail(new DuplexError('invalid_message', 'Invalid duplex frame.')); + this.fail(new PublicError('invalid_message', 'Invalid duplex frame.')); return; } const trace = traceOf(frame); - if (this.observer) { - const name = this.nameOf(frame); - this.observe({ - type: 'frame.received', - at: new Date(), - kind: frame.kind as string, - name, - bytes, - id: frame.id as string | undefined, - trace, - family: this.family(name), - }); - } // Within one connection instance and one direction, each request's serial // is greater than every request's published before it. Gaps are allowed; a // serial that does not increase is a protocol violation, as a malformed @@ -771,22 +581,18 @@ export class DuplexPeer { if (frame.kind === 'request') { const serial = Number((frame.id as string).slice(this.remotePrefix.length)); if (!Number.isSafeInteger(serial) || serial <= this.admittedSerial) { - this.fail(new DuplexError('invalid_message', 'Duplex request serial did not increase.')); + this.fail(new PublicError('invalid_message', 'Duplex request serial did not increase.')); return; } this.admittedSerial = serial; } switch (frame.kind) { case 'response': { - const id = frame.id as string; - const pending = this.takePending(id); + const pending = this.takePending(frame.id as string); if (!pending) return; // A cancellation or deadline may precede a late response. if (isObject(frame.error)) { - const error = new DuplexError(frame.error.code as string, frame.error.message as string, frame.error.data); - this.ended(id, pending, 'error', error.code); - pending.reject(error); + pending.reject(new PublicError(frame.error.code as string, frame.error.message as string, frame.error.data)); } else { - this.ended(id, pending, 'ok'); pending.resolve(frame.result); } break; @@ -794,9 +600,8 @@ export class DuplexPeer { case 'cancel': { // A cancel withdraws the request and answers nothing itself: the // receiver aborts the handler's signal, and the response โ€” cancelled, - // whatever the handler goes on to return โ€” is the handler's return, - // as the profile says and the Go peer does. What frees the - // correlation is the work ending, not the asking to end it. + // whatever the handler goes on to return โ€” is the handler's return. + // What frees the correlation is the work ending, not the asking to end it. this.incoming.get(frame.id as string)?.controller.abort(); break; } @@ -804,14 +609,14 @@ export class DuplexPeer { this.request(frame.id as string, frame.method as string, frame.params, trace, frame.meta as Meta | undefined); break; case 'event': - this.event(frame.event as string, frame.data, bytes, trace, frame.meta as Meta | undefined); + this.event(frame.event as string, frame.data, trace, frame.meta as Meta | undefined); break; } } private request(id: string, method: string, params: unknown, trace?: Trace, meta?: Meta): void { if (this.incoming.has(id)) { - this.fail(new DuplexError('invalid_message', 'An incoming request ID is already active.')); + this.fail(new PublicError('invalid_message', 'An incoming request ID is already active.')); return; } if (this.incoming.size >= this.limits.maxConcurrentHandlers) { @@ -820,7 +625,6 @@ export class DuplexPeer { { version: 1, kind: 'response', id, error: { code: 'busy', message: 'Incoming request limit reached.' } }, trace, ), - method, ).catch((error) => this.fail(asError(error))); return; } @@ -829,66 +633,41 @@ export class DuplexPeer { controller, responded: false, trace, - method, - started: Date.now(), timer: setTimeout(() => { controller.abort(); // What crosses the wire when a receiver's own deadline passes is // `cancelled`: the request was abandoned, which is what the caller can - // act on, and is what the profile and the Go peer both answer. - // `request_timeout` is a caller's own error and never a frame. - this.respond(id, incoming, undefined, new DuplexError('cancelled', 'Request deadline exceeded.'), 'timeout'); + // act on. `request_timeout` is a caller's own error and never a frame. + this.respond(id, incoming, undefined, new PublicError('cancelled', 'Request deadline exceeded.')); }, this.limits.requestTimeoutMs), }; this.incoming.set(id, incoming); - if (this.observer) - this.observe({ - type: 'request.started', - at: new Date(), - id, - method, - incoming: true, - trace, - family: this.family(method), - }); - const context: RequestContext = { peer: this, signal: controller.signal, requestId: id }; + // What the handler is given: the signal, the request's id, and the trace + // and meta the frame brought. The peer itself is not reachable from it. + const context: ReceivedRequestContext = { signal: controller.signal, requestId: id }; if (meta) context.meta = meta; this.propagator.extract(context, trace); + // The receiver chosen now stays with this request. const attachedHandler = this.wireRequest?.(method); void Promise.resolve() .then(() => { // The peer can close or cancel before the handler's first microtask. if (controller.signal.aborted) { - this.respond(id, incoming, undefined, new DuplexError('cancelled', 'Request was cancelled.'), 'cancelled'); + this.respond(id, incoming, undefined, new PublicError('cancelled', 'Request was cancelled.')); return; } - if (attachedHandler) return attachedHandler(params, context); - const handler = this.handlers.get(method); - if (handler) return handler(params, context); - if (this.options.dispatch) return this.options.dispatch(method, params, context); - throw new DuplexError('method_not_found', `Unknown method ${method}.`); + if (attachedHandler) return attachedHandler(params, context, trace); + throw new PublicError('method_not_found', `Unknown method ${method}.`); }) .then( (result) => this.respond(id, incoming, result === undefined ? null : result), - (error: unknown) => { - // Anything a handler threw but a public error is this runtime's panic. - if (this.observer && !(error instanceof DuplexError)) { - this.observe({ - type: 'handler.panic', - at: new Date(), - method, - value: describe(error), - trace, - family: this.family(method), - }); - } + (error: unknown) => this.respond( id, incoming, undefined, - error instanceof DuplexError ? error : new DuplexError('internal', 'Request handler failed.'), - ); - }, + error instanceof PublicError ? error : new PublicError('internal', 'Request handler failed.'), + ), ) .finally(() => { clearTimeout(incoming.timer); @@ -896,15 +675,11 @@ export class DuplexPeer { }); } - private respond(id: string, incoming: Incoming, result?: unknown, error?: DuplexError, outcome?: Outcome): void { + private respond(id: string, incoming: Incoming, result?: unknown, error?: PublicError): void { if (incoming.responded || this.incoming.get(id) !== incoming || !this.isOpen()) return; // A result returned after withdrawal becomes a local cancellation. A // handler's public refusal stays an error, whatever its code says. - if (!error && incoming.controller.signal.aborted) { - error = new DuplexError('cancelled', 'Request was cancelled.'); - outcome ??= 'cancelled'; - } - outcome ??= error ? 'error' : 'ok'; + if (!error && incoming.controller.signal.aborted) error = new PublicError('cancelled', 'Request was cancelled.'); incoming.responded = true; clearTimeout(incoming.timer); const frame: Envelope = { version: 1, kind: 'response', id }; @@ -915,28 +690,11 @@ export class DuplexPeer { ...(error.data === undefined ? {} : { data: error.data }), }; else frame.result = result; - // The request ends before its response is sent, as the Go peer tells it; - // a response carries its request's trace, mints none of its own, and is - // named by nothing โ€” its id says which request it answers. - if (this.observer) { - this.observe({ - type: 'request.ended', - at: new Date(), - id, - method: incoming.method, - incoming: true, - durationMs: Date.now() - incoming.started, - outcome, - // The observer names this peer's deadline; the response still says cancelled. - errorCode: outcome === 'timeout' ? 'request_timeout' : error?.code, - trace: incoming.trace, - family: this.family(incoming.method), - }); - } - void this.send(traced(frame, incoming.trace), '').catch(async (error: unknown) => { - // An unencodable response must settle the call, as the Go peer does, - // without publishing a replacement for malformed handler output. - if (error instanceof DuplexError && ['invalid_message', 'frame_too_large'].includes(error.code)) { + // A response carries its request's trace and mints none of its own. + void this.send(traced(frame, incoming.trace)).catch(async (error: unknown) => { + // An unencodable response must settle the call without publishing a + // replacement for malformed handler output. + if (error instanceof PublicError && ['invalid_message', 'frame_too_large'].includes(error.code)) { try { await this.send( traced( @@ -959,23 +717,21 @@ export class DuplexPeer { }); } - private event(name: string, data: unknown, bytes: number, trace?: Trace, meta?: Meta): void { + private event(name: string, data: unknown, trace?: Trace, meta?: Meta): void { const queued = this.events.length + Number(this.eventActive); if (queued >= this.limits.queueCapacity && !this.stallTimer) { // A full queue can be a healthy transient burst, so the producer is paced - // for one write deadline before the consumer is declared stalled, as the - // Go peer paces it. The producer is the remote, and a peer here cannot - // pause what it is handed โ€” a socket delivers when it delivers โ€” so the - // events are held rather than the reading stopped. The deadline is the - // same, and so is what happens at it. - if (this.observer) this.pressure(queued, false); + // for one write deadline before the consumer is declared stalled. The + // producer is the remote, and a peer here cannot pause what it is handed + // โ€” a socket delivers when it delivers โ€” so the events are held rather + // than the reading stopped. The deadline is the same, and so is what + // happens at it. this.stallTimer = setTimeout(() => { this.stallTimer = undefined; - if (this.observer) this.pressure(this.events.length + Number(this.eventActive), true); - this.fail(new DuplexError('busy', 'Event consumer is stalled; queue limit reached.')); + this.fail(new PublicError('busy', 'Event consumer is stalled; queue limit reached.')); }, this.limits.writeTimeoutMs); } - this.events.push({ name, data, bytes, trace, meta }); + this.events.push({ name, data, trace, meta }); this.drainEvents(); } @@ -991,25 +747,14 @@ export class DuplexPeer { const event = this.events.shift(); if (!event) return; this.eventActive = true; - // Delivered when it reaches the listeners, not when its frame arrived. - if (this.observer) - this.observe({ - type: 'event.delivered', - at: new Date(), - name: event.name, - bytes: event.bytes, - trace: event.trace, - family: this.family(event.name), - }); const generation = this.generation; this.eventTimer = setTimeout(() => { - if (this.observer) this.pressure(this.events.length, true); - this.fail(new DuplexError('stalled_consumer', 'Event handler deadline exceeded.')); + this.fail(new PublicError('stalled_consumer', 'Event handler deadline exceeded.')); }, this.limits.writeTimeoutMs); - const listeners = [...this.listeners]; + const listeners: EventHandler[] = []; const wireListener = this.wireEvent?.(event.name); if (wireListener) listeners.push(wireListener); - const context: EventContext = { peer: this }; + const context: ReceivedEventContext = {}; setReceivedEventTrace(context, event.trace); this.propagator.extract(context, event.trace); if (event.meta) context.meta = event.meta; @@ -1029,16 +774,16 @@ export class DuplexPeer { while (index < listeners.length) { if (!this.isOpen() || generation !== this.generation) return; try { - const result = listeners[index++](event.name, event.data, context); + const result = listeners[index++]!(event.name, event.data, context); if (result && typeof result.then === 'function') { void result.then(next, () => { - this.notifyError(new DuplexError('event_handler_failed', 'An event handler failed.')); + this.notifyError(new PublicError('event_handler_failed', 'An event handler failed.')); next(); }); return; } } catch { - this.notifyError(new DuplexError('event_handler_failed', 'An event handler failed.')); + this.notifyError(new PublicError('event_handler_failed', 'An event handler failed.')); } } finish(); @@ -1048,12 +793,12 @@ export class DuplexPeer { next(); } - private fail(error: DuplexError, closeConnection = true, code = 4011, reason = CLOSE_REASON): void { + private fail(error: PublicError, closeConnection = true, code = 4011, reason = CLOSE_REASON): void { // Ending a connection settles unrelated, possibly delivered requests too. // A failed reply's local proof must not be broadcast as their send outcome. if (error instanceof UnpublishedError) { const cause = error; - error = new DuplexError(cause.code, cause.message, cause.data); + error = new PublicError(cause.code, cause.message, cause.data); Object.defineProperty(error, 'cause', { value: cause }); } const connection = this.connection; @@ -1078,113 +823,41 @@ export class DuplexPeer { this.opening.reject(error); this.opening = undefined; } - for (const id of [...this.pending.keys()]) { - const pending = this.takePending(id); - if (!pending) continue; - this.ended(id, pending, 'error', error.code); - pending.reject(error); - } - for (const [id, request] of this.incoming) { + // Every pending call learns the carrier ended, and why, as its cause. + const outcome = ended(error); + for (const id of [...this.pending.keys()]) this.takePending(id)?.reject(outcome); + for (const request of this.incoming.values()) { clearTimeout(request.timer); request.controller.abort(); - if (this.observer && !request.responded) { - this.observe({ - type: 'request.ended', - at: new Date(), - id, - method: request.method, - incoming: true, - durationMs: Date.now() - request.started, - outcome: 'error', - errorCode: error.code, - trace: request.trace, - family: this.family(request.method), - }); - } } this.incoming.clear(); // Nothing here is a caller's promise: a send resolved when it was queued. this.outgoing.length = 0; if (closeConnection) { - // Browser close() restricts application codes to 3000โ€“4999 (or 1000). try { - connection.close(code, reason); + // A code that may only be observed is never sent. A connection of the + // seam ends only by a close, so it ends with a normal one instead. + if (sendable(code)) connection.close(code, reason); + else connection.close(); } catch { /* Already closed. */ } } - // Reported once everything it ended has been. The peer closes the - // connection exactly when the close is its own. - if (this.observer) - this.observe({ type: 'connection.closed', at: new Date(), code, reason, local: closeConnection }); this.notifyError(error); for (const listener of this.closedListeners) { try { listener(error); } catch { - /* Observers cannot interrupt cleanup. */ + /* Listeners cannot interrupt cleanup. */ } } } - /** - * Tells this peer's observer one event, the runtime's own or one of a layer - * running over the peer, which is how a tunnel and a live scope observe โ€” through - * the peer they run over, rather than through an observer of their own. An - * observer that is absent costs nothing, and one that throws interrupts nothing. - */ - observe(event: ObserverEvent): void { - try { - this.observer?.observe(event); - } catch { - /* Observers cannot interrupt routing. */ - } - } - - private pressure(queued: number, stalled: boolean): void { - this.observe({ type: 'backpressure', at: new Date(), queued, stalled, deadlineMs: this.limits.writeTimeoutMs }); - } - - /** Every outgoing call ends once, wherever it settles. */ - private ended(id: string, pending: Pending, outcome: Outcome, errorCode?: string): void { - if (!this.observer) return; - this.observe({ - type: 'request.ended', - at: new Date(), - id, - method: pending.method, - incoming: false, - durationMs: Date.now() - pending.started, - outcome, - errorCode, - trace: pending.trace, - family: this.family(pending.method), - }); - } - - /** What a frame is named on the wire: a request its method, an event its event; a response or a cancel nothing, its id says which request it concerns. */ - private nameOf(frame: Envelope): string { - switch (frame.kind) { - case 'request': - return frame.method as string; - case 'event': - return frame.event as string; - default: - return ''; - } - } - - /** The label the caller gave this method or event name; an unlabelled name has none. */ - private family(name: string): string { - const label = this.options.families?.[name]; - return typeof label === 'string' ? label : ''; - } - - private notifyError(error: DuplexError): void { + private notifyError(error: PublicError): void { try { this.options.onError?.(error); } catch { - /* Observers cannot interrupt routing. */ + /* Diagnostics cannot interrupt routing. */ } } } @@ -1197,23 +870,8 @@ function subprotocolOf(socket: WebSocketLike | undefined): string { const selected = (socket as { protocol?: unknown } | undefined)?.protocol; return typeof selected === 'string' ? selected : ''; } -/** The reason a peer gives for a close of its own. */ +/** The reason a peer gives for a close of its own, as bitwire/1 sends it. */ const CLOSE_REASON = 'Duplex connection closed'; -/** What a handler threw, as a string: never its params, and never a payload. */ -function describe(value: unknown): string { - try { - return String(value); - } catch { - return '[unprintable value]'; - } -} -/** Validates a component limit, returning it or throwing invalid_options; safe also requires exact integer representation. */ -export function positiveInteger(value: unknown, name: string, safe = false): number { - if (typeof value !== 'number' || !(safe ? Number.isSafeInteger(value) : Number.isInteger(value)) || value <= 0) { - throw new DuplexError('invalid_options', `${name} must be a positive ${safe ? 'safe ' : ''}integer.`); - } - return value; -} -function asError(error: unknown, code = 'internal'): DuplexError { - return error instanceof DuplexError ? error : new DuplexError(code, 'Duplex operation failed.'); +function asError(error: unknown, code = 'internal'): PublicError { + return error instanceof PublicError ? error : new PublicError(code, 'Duplex operation failed.'); } diff --git a/engine/ts/src/wire.ts b/engine/ts/src/wire.ts new file mode 100644 index 0000000..8f2865c --- /dev/null +++ b/engine/ts/src/wire.ts @@ -0,0 +1,297 @@ +import type { AddressedWire, Endpoint, Message, Path, Receiver, ReturnAddress } from '@bitspark/bitwire'; +import { PublicError, ReceiverExistsError } from '../../../core/ts/src/error.ts'; +import type { Meta } from '../../../core/ts/src/meta.ts'; +import { respond } from '../../../core/ts/src/respond.ts'; +import type { Trace } from '../../../core/ts/src/trace.ts'; +import { + receivedEventTrace, + withEventContext, + type ReceivedEventContext, + type ReceivedRequestContext, +} from '../../../core/ts/src/internal/context.ts'; +import { ended, outgoingTrace, profileFrame, publicError } from '../../../core/ts/src/internal/frame.ts'; +import { decodePath, encodePath } from '../../../core/ts/src/internal/path.ts'; +import { request } from '../../../core/ts/src/internal/request.ts'; +import { traceMembers } from '../../../core/ts/src/internal/trace.ts'; +import type { Peer } from './peer.ts'; + +/** + * The body the peer runs for an incoming request whose method names a path, + * given the trace members its frame arrived with: what a propagator placed on + * the context is its own, and never rewrites the forwarded frame. + */ +export type RequestHandler = (params: unknown, context: ReceivedRequestContext, received?: Trace) => Promise; +/** The listener the peer runs for an incoming event whose name names a path. */ +export type EventHandler = (name: string, data: unknown, context: ReceivedEventContext) => void | Promise; + +/** Hooks that retain all carrier ownership in the peer. */ +export interface RootOptions { + queueCapacity: number; + maxPendingRequests: number; + maxFrameBytes: number; + requestTimeoutMs: number; + call: (method: string, params: unknown, options: { signal?: AbortSignal; meta?: Meta }, trace?: Trace) => Promise; + emit: (name: string, data: unknown, options: { meta?: Meta }, trace?: Trace) => Promise; + dispatch: (request: (method: string) => RequestHandler | undefined, event: (name: string) => EventHandler | undefined) => void; + fail: (error: PublicError) => void; + close: (code: number, reason: string) => void; +} + +interface RoutedCall { + address: ReturnAddress; + id: string; + controller: AbortController; + completed: boolean; + cancelQueued: boolean; + cancelled: boolean; +} +interface RoutedDelivery { + path: Path; + message: Message; + call?: RoutedCall; + refusal?: PublicError; +} +interface Registration { + receiver: Receiver; + request: (path: Path, params: unknown, context: ReceivedRequestContext, received?: Trace) => Promise; + detach: () => void; +} + +/** + * The peer's addressed origin; one per peer, and selection never constructs + * another. Outgoing requests, events and cancellations queue here in admission + * order and are handed to the peer one at a time. What the remote side sends + * is delivered to the one receiver with the path the frame's name encodes; a + * name that encodes no path, or arrives while nothing is attached, is what a + * peer without that handler answers. + */ +export function rootWire(peer: Peer, options: RootOptions): Endpoint { + const queued: RoutedDelivery[] = []; + const incoming = new Map>(); + let attachment: Registration | undefined; + const lookup = (name: string): { path: Path; registration: Registration } | undefined => { + let path: Path; + try { + path = decodePath(name); + } catch { + return; + } + return attachment ? { path, registration: attachment } : undefined; + }; + // The receiver chosen when a frame arrives stays with it, so a later detach + // or replacement cannot redirect its cancellation. + options.dispatch( + (name) => { + const found = lookup(name); + return found + ? (params, context, received) => found.registration.request(found.path, params, context, received) + : undefined; + }, + (name) => { + const found = lookup(name); + return found + ? (_name, data, context) => { + const received = receivedEventTrace(context); + return found.registration.receiver.message?.( + found.path, + withEventContext( + { + frame: { + version: 1, + kind: 'event', + data, + ...traceMembers(received), + ...(context.meta ? { meta: context.meta } : {}), + }, + }, + context, + ), + ); + } + : undefined; + }, + ); + let retained = 0, + dataQueued = 0, + scheduled = false, + closed = false; + const retire = (call: RoutedCall) => { + // A completed call still owns a queued control slot. Reusing its budget + // early would let fast completions accumulate unbounded stale cancels. + if (!call.completed || call.cancelQueued) return; + const calls = incoming.get(call.address); + if (calls?.get(call.id) !== call) return; + calls.delete(call.id); + if (!calls.size) incoming.delete(call.address); + retained--; + }; + peer.onClose((reason) => { + closed = true; + // Every request still queued is owed an answer: a queued refusal its + // refusal, an admitted request that never reached the peer disconnected. + // A request already handed to the peer is answered by its own waiter, + // which the peer's end releases. + for (const delivery of queued.splice(0)) + if (delivery.message.frame.kind === 'request') + respond(delivery.message, undefined, delivery.refusal ?? ended(reason)); + for (const calls of incoming.values()) for (const call of calls.values()) call.controller.abort(); + incoming.clear(); + retained = 0; + dataQueued = 0; + const ending = attachment ? [attachment] : []; + for (const { detach } of ending) detach(); + for (const { receiver } of ending) { + try { + receiver.closed?.(1001, 'peer ended'); + } catch { + /* One receiver cannot interrupt another's cleanup. */ + } + } + }); + const drain = () => { + scheduled = false; + while (queued.length && !closed) { + const { path, message, call, refusal } = queued.shift()!; + const frame = message.frame; + if (frame.kind === 'cancel') { + call!.cancelQueued = false; + call!.cancelled = true; + if (!call!.completed) call!.controller.abort(); + retire(call!); + continue; + } + dataQueued--; + if (refusal) { + respond(message, undefined, refusal); + continue; + } + const name = encodePath(path); + if (frame.kind === 'event') { + // Invoke admission now, in wire order; only completion is asynchronous. + void options + .emit(name, frame.data, { meta: frame.meta ? { ...frame.meta } : undefined }, outgoingTrace(frame)) + .catch((error: unknown) => options.fail(publicError(error))); + continue; + } + if (frame.kind !== 'request') continue; + // The peer allocates the carrier id and enqueues before it returns. + const pending = options.call( + name, + frame.params, + { signal: call!.controller.signal, meta: frame.meta ? { ...frame.meta } : undefined }, + outgoingTrace(frame), + ); + const finish = (value?: unknown, error?: unknown) => { + // Retire before delivering the response: its callback can admit + // another request, but a queued cancellation still owns budget. + call!.completed = true; + retire(call!); + respond(message, value, error); + }; + void pending.then( + (value) => finish(value), + (error: unknown) => finish(undefined, error), + ); + } + }; + const connected = () => !closed && peer.status === 'connected'; + const endpoint: Endpoint = { + send: (path, message) => { + const name = encodePath(path); + if (!connected()) throw ended(); + const frame = profileFrame(message.frame, name, options.maxFrameBytes); + if (!name && (frame.kind === 'request' || frame.kind === 'event')) + throw new PublicError('invalid_message', 'A root wire operation requires a nonempty path.'); + if ((frame.kind === 'request' || frame.kind === 'cancel') && !message.return?.wire) + throw new PublicError('invalid_message', 'A wire request or cancellation requires a return address.'); + if (frame.kind !== 'request' && frame.kind !== 'event' && frame.kind !== 'cancel') + throw new PublicError('invalid_message', "A response is sent to its request's return address."); + let call: RoutedCall | undefined; + if (frame.kind === 'cancel') { + call = incoming.get(message.return!)?.get(frame.id); + // Cancellation belongs to an already admitted request. Its one control + // reservation is bounded by the existing pending-request budget. + if (!call || call.completed || call.cancelQueued || call.cancelled) return; + } + if (!connected()) throw ended(); + if (frame.kind !== 'cancel' && dataQueued >= options.queueCapacity) { + // bitwire/1: a full root queue ends the carrier, and the refused send + // reports the carrier ended. + const error = new PublicError('busy', 'Output consumer is stalled; queue limit reached.'); + options.fail(error); + throw ended(error); + } + let refusal: PublicError | undefined; + if (frame.kind === 'cancel') call!.cancelQueued = true; + else { + dataQueued++; + if (frame.kind === 'request') { + let calls = incoming.get(message.return!); + if (calls?.has(frame.id) || retained >= options.maxPendingRequests) { + refusal = new PublicError(calls?.has(frame.id) ? 'invalid_message' : 'busy', 'Outstanding wire call refused.'); + } else { + if (!calls) { + calls = new Map(); + incoming.set(message.return!, calls); + } + call = { + address: message.return!, + id: frame.id, + controller: new AbortController(), + completed: false, + cancelQueued: false, + cancelled: false, + }; + calls.set(frame.id, call); + retained++; + } + } + } + // Data and reserved control entries share one FIFO. A cancel cannot jump + // ahead of an earlier event, request, or cancellation on this wire. + queued.push({ path: [...path], message: { frame, return: message.return }, call, refusal }); + if (!scheduled) { + scheduled = true; + queueMicrotask(drain); + } + }, + receive: (receiver) => { + if (closed) throw ended(); + if (attachment) throw new ReceiverExistsError(); + const target: AddressedWire = { + send: (suffix, message) => { + try { + if (!receiver.message) { + respond(message, undefined, new PublicError('method_not_found', 'Unknown method.')); + return; + } + const result = receiver.message(suffix, message); + if (result) void result.catch((error: unknown) => respond(message, undefined, publicError(error))); + } catch (error) { + respond(message, undefined, publicError(error)); + } + }, + }; + const handler = (path: Path, params: unknown, context: ReceivedRequestContext, received?: Trace) => + request( + target, + path, + params, + { signal: context.signal, timeoutMs: options.requestTimeoutMs, meta: context.meta }, + received, + { context, maxFrameBytes: options.maxFrameBytes }, + ); + const registration: Registration = { + receiver, + request: handler, + detach: () => { + if (attachment === registration) attachment = undefined; + }, + }; + attachment = registration; + return registration.detach; + }, + close: (code = 1000, reason = '') => options.close(code, reason), + }; + return endpoint; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0734087 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "@bitspark/bitruntime", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bitspark/bitruntime", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "@bitspark/bitwire": "0.3.0" + }, + "devDependencies": { + "@types/node": "24.19.0", + "@types/ws": "8.18.1", + "typescript": "5.9.3", + "ws": "8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@bitspark/bitwire": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@bitspark/bitwire/-/bitwire-0.3.0.tgz", + "integrity": "sha512-6tqme+2nfJAp2xwmYIrO7hSSh+6TrJ7gPIb8SZX4dQAoZZHs/iqPPsZFv8vJsMUBejzgBxwNtOoC3PjI0KMPRw==", + "license": "Apache-2.0" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "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/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fffb24f --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "@bitspark/bitruntime", + "version": "0.2.0", + "description": "The TypeScript implementation of the Bitwire contract: trees, addressed operators, the local pair, dispatch, the bitwire/1 protocol engine and its transports.", + "license": "Apache-2.0", + "type": "module", + "repository": { "type": "git", "url": "git+https://github.com/Bitspark/bitruntime.git", "directory": "" }, + "exports": { + "./core": { "types": "./dist/core/ts/src/index.d.ts", "import": "./dist/core/ts/src/index.js" }, + "./transports": { "types": "./dist/transports/ts/src/index.d.ts", "import": "./dist/transports/ts/src/index.js" }, + "./engine": { "types": "./dist/engine/ts/src/index.d.ts", "import": "./dist/engine/ts/src/index.js" }, + "./dispatch": { "types": "./dist/dispatch/ts/src/index.d.ts", "import": "./dist/dispatch/ts/src/index.js" } + }, + "files": ["dist", "README.md", "LICENSE", "NOTICE"], + "sideEffects": false, + "engines": { "node": ">=22.12.0" }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" }, + "scripts": { + "check": "tsc -p tsconfig.check.json", + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", + "test": "npm run build && node --test --test-timeout=30000 \"core/ts/test/*.test.mjs\" \"*/ts/test/*.test.ts\"" + }, + "dependencies": { "@bitspark/bitwire": "0.3.0" }, + "devDependencies": { + "@types/node": "24.19.0", + "@types/ws": "8.18.1", + "typescript": "5.9.3", + "ws": "8.21.3" + } +} diff --git a/transports/ts/src/transport.ts b/transports/ts/src/index.ts similarity index 75% rename from transports/ts/src/transport.ts rename to transports/ts/src/index.ts index 77cb61f..b65f688 100644 --- a/transports/ts/src/transport.ts +++ b/transports/ts/src/index.ts @@ -1,22 +1,16 @@ -export { at, mount, encodePath, decodePath, WireError } from './wire.ts'; -export type { - Path, - ProfileKind, - ProfileFrame, - ProfileError, - ReturnAddress, - Message, - Receiver, - Wire, - Endpoint, -} from './wire.ts'; -export { record, MemoryWireLog, RecordError } from './record.ts'; -export type { WireRecord, WireLog, RecordOptions, RecordedWire, Follower } from './record.ts'; +/** + * The seam beneath every carrier: a frames duplex connection. It is ordered, + * message-framed, bidirectional and closed explicitly with a code and a + * reason, and it is nothing else โ€” no JSON, no requests, no correlation, no + * events, no reconnection. The bitwire/1 protocol engine runs over it; beneath + * it the transport is an in-memory pipe or a WebSocket, and neither side of + * the seam knows which. + */ /** * A frames duplex connection: ordered, message-framed, bidirectional, with an * explicit close carrying a code and a reason. Nothing about JSON, requests, - * correlation or events belongs here; those stay in the peer. + * correlation or events belongs here; those stay in the protocol engine. */ export type Frame = { kind: 'text'; data: string } | { kind: 'binary'; data: ArrayBuffer | Uint8Array }; /** Where a connection is in its life; frames flow only while `open`. */ @@ -32,7 +26,8 @@ export interface ConnectionHandlers { /** * Close codes are the WebSocket registry's numbers (1000 normal, 1008 policy, * 1009 too big, 1011 internal, 4000โ€“4999 application) on every transport, so - * that close semantics travel with the peer. + * that close semantics travel with the peer. A code that may only be observed + * (see `sendable`) is refused: `close` throws and sends nothing. */ export interface FrameConnection { readonly state: ConnectionState; @@ -46,6 +41,7 @@ export interface FrameConnection { readonly buffered: number; /** Throws when the connection is not open. */ send(frame: Frame): void; + /** Throws a RangeError, and sends nothing, for a code that is not `sendable`. */ close(code?: number, reason?: string): void; /** Registers handlers; returns a function that detaches all of them. */ listen(handlers: ConnectionHandlers): () => void; @@ -62,8 +58,50 @@ export interface WebSocketLike { } const STATES: readonly ConnectionState[] = ['connecting', 'open', 'closing', 'closed']; + +/** A close both sides meant. */ +export const CODE_NORMAL = 1000; +/** A side shutting down. */ +export const CODE_GOING_AWAY = 1001; +/** A frame the receiver could not take as the protocol above the seam defines one. */ +export const CODE_PROTOCOL_ERROR = 1002; +/** A frame of a kind the receiver does not speak, such as binary where JSON text was expected. */ +export const CODE_UNSUPPORTED_DATA = 1003; /** The registry's "no status code present": what a side reads when the other closed with no code, never sent. */ export const NO_STATUS = 1005; +/** What a side reads when the other ended with no close at all, never sent. */ +export const CODE_ABNORMAL_CLOSURE = 1006; +/** A frame that parses and is refused anyway. */ +export const CODE_POLICY_VIOLATION = 1008; +/** A frame over the receiver's limit. */ +export const CODE_TOO_LARGE = 1009; +/** A failure of the receiver's own. */ +export const CODE_INTERNAL_ERROR = 1011; +/** What a side reads when a TLS handshake failed, never sent. */ +export const CODE_TLS_HANDSHAKE = 1015; +/** The first code of the range a protocol above the seam may use for its own reasons. */ +export const CODE_APPLICATION_FIRST = 4000; +/** The last code of that range. */ +export const CODE_APPLICATION_LAST = 4999; +/** The code bitwire/1 closes with when the other side broke the protocol. */ +export const CODE_PROTOCOL = 4011; + +/** + * Whether a close code may be sent. Codes that only describe what a side + * observed โ€” no status, an abnormal closure, a failed TLS handshake โ€” are + * never transmitted, and neither is 1004 or a code outside the registry's + * usable ranges, 1000โ€“1014 and 3000โ€“4999. + */ +export function sendable(code: number): boolean { + if (!Number.isInteger(code)) return false; + if (code === NO_STATUS || code === CODE_ABNORMAL_CLOSURE || code === CODE_TLS_HANDSHAKE || code === 1004) return false; + return (code >= 1000 && code <= 1014) || (code >= 3000 && code <= 4999); +} + +/** Refuses a close whose code may only be observed; nothing is sent. */ +function refuseUnsendable(code: number): void { + if (!sendable(code)) throw new RangeError(`bitruntime: close code ${code} may only be observed`); +} /** * Adapts a WebSocket to a frames duplex connection: readyState maps to state, @@ -147,7 +185,8 @@ export function webSocketConnection(socket: WebSocketLike): FrameConnection { // WebSocketLike declares the text surface the peer needs; real sockets also accept binary. (socket.send as (data: string | ArrayBuffer | Uint8Array) => void)(frame.data); }, - close(code = 1000, reason = '') { + close(code = CODE_NORMAL, reason = '') { + refuseUnsendable(code); socket.close(code, reason); }, listen(handlers) { @@ -195,7 +234,8 @@ export function pipe(): [FrameConnection, FrameConnection] { (this.inFlight.length < IN_FLIGHT ? this.inFlight : this.held).push(frame); this.drainLater(); } - close(code = 1000, reason = ''): void { + close(code = CODE_NORMAL, reason = ''): void { + refuseUnsendable(code); if (this.state === 'closed') return; this.state = 'closed'; // What neither end has been handed goes nowhere, so nothing is buffered diff --git a/tsconfig.check.json b/tsconfig.check.json new file mode 100644 index 0000000..43c483e --- /dev/null +++ b/tsconfig.check.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "allowImportingTsExtensions": true }, + "include": ["*/ts/src/**/*.ts", "*/ts/test/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a31cff4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "rewriteRelativeImportExtensions": true, + "rootDir": ".", + "outDir": "dist" + }, + "include": ["core/ts/src/**/*.ts", "transports/ts/src/**/*.ts", "dispatch/ts/src/**/*.ts", "engine/ts/src/**/*.ts"] +} From ebc02b68a085090d08cec63060b41249ebbd5cff Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:26:14 +0200 Subject: [PATCH 30/39] core, dispatch, engine, transports: port the v0.6.0 TypeScript tests Ported from Nightseam v0.6.0 at 5cc9723a: duplex's pipe and WebSocket adapter tests with their conformance suite, at/mount and path encoding, wire-pair, invocation, invocation-experiment (two independent lifecycle integrations and an opaque wrapper, public facilities only), dispatcher, dispatcher-ownership, wire, wire-forward, wire-event-context, peer, peer-pacing, serial (vectors/bitwire-1/serials.json), carriage (every row of vectors/bitwire-1/frames.json), unicode (vectors/bitwire-1/unicode.json), trace and publication. Raw-API tests go through wire() and the dispatch helpers, or raw frames whose names are path encodings. Not ported: observer and family assertions, the tests of the observer alone, the pacing of the removed raw emit and call, the 'peer' route of the cancellation matrix and the validator rows of the unicode test. Each changed expectation is a documented change, noted where it stands. Added: regressions of nightseam#722 and #658 (200 chained calls), the queued-cancellation slot, R26, R27, forward's row 14, R28 on the peer's root, a request cut off by its peer's end answering disconnected, close codes transmitted on every ending, method names that encode no path, and two peers over a real socket (Node's WebSocket against a ws server) calling, emitting and cancelling both ways. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/ts/test/addressed.test.ts | 501 ++++++ core/ts/test/forward.test.ts | 55 + core/ts/test/invocation-experiment.test.ts | 332 ++++ core/ts/test/invocation.test.ts | 318 ++++ core/ts/test/pair.test.ts | 432 +++++ core/ts/test/unicode.test.ts | 40 + dispatch/ts/test/dispatcher-ownership.test.ts | 154 ++ dispatch/ts/test/dispatcher.test.ts | 71 + engine/ts/test/carriage.test.ts | 231 +++ engine/ts/test/peer-pacing.test.ts | 107 ++ engine/ts/test/peer.test.ts | 1475 +++++++++++++++++ engine/ts/test/publication.test.ts | 219 +++ engine/ts/test/serial.test.ts | 130 ++ engine/ts/test/trace.test.ts | 203 +++ engine/ts/test/websocket.test.ts | 124 ++ engine/ts/test/wire-event-context.test.ts | 208 +++ engine/ts/test/wire-forward.test.ts | 395 +++++ engine/ts/test/wire.test.ts | 674 ++++++++ transports/ts/test/conformance.ts | 290 ++++ transports/ts/test/transports.test.ts | 356 ++++ 20 files changed, 6315 insertions(+) create mode 100644 core/ts/test/addressed.test.ts create mode 100644 core/ts/test/forward.test.ts create mode 100644 core/ts/test/invocation-experiment.test.ts create mode 100644 core/ts/test/invocation.test.ts create mode 100644 core/ts/test/pair.test.ts create mode 100644 core/ts/test/unicode.test.ts create mode 100644 dispatch/ts/test/dispatcher-ownership.test.ts create mode 100644 dispatch/ts/test/dispatcher.test.ts create mode 100644 engine/ts/test/carriage.test.ts create mode 100644 engine/ts/test/peer-pacing.test.ts create mode 100644 engine/ts/test/peer.test.ts create mode 100644 engine/ts/test/publication.test.ts create mode 100644 engine/ts/test/serial.test.ts create mode 100644 engine/ts/test/trace.test.ts create mode 100644 engine/ts/test/websocket.test.ts create mode 100644 engine/ts/test/wire-event-context.test.ts create mode 100644 engine/ts/test/wire-forward.test.ts create mode 100644 engine/ts/test/wire.test.ts create mode 100644 transports/ts/test/conformance.ts create mode 100644 transports/ts/test/transports.test.ts diff --git a/core/ts/test/addressed.test.ts b/core/ts/test/addressed.test.ts new file mode 100644 index 0000000..ccf0a10 --- /dev/null +++ b/core/ts/test/addressed.test.ts @@ -0,0 +1,501 @@ +// Ported from Nightseam v0.6.0 duplex/ts/src/wire.test.ts. A closed mount now +// reports the one closed classification, `disconnected` (was the duplex +// wire error 'closed', R26), and a path that selects no child is refused with +// MissingPathError ('missing_path', was 'no_route'). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AddressedWire, Endpoint, Message, Receiver, Path, ProfileFrame } from '@bitspark/bitwire'; +import { at, mount, PublicError, ReceiverExistsError } from '../src/index.ts'; +import { encodePath, decodePath } from '../src/internal/path.ts'; + +test('path encoding is canonical, injective and composes by concatenation', () => { + const paths: Path[] = [[], [''], ['a', 'b'], ['a.b'], ['a', 'b:c'], ['รฉ', 'e\u0301', '๐Ÿ˜€', '\ufeff', '\0']]; + const seen = new Set(); + for (const path of paths) { + const encoded = encodePath(path); + assert.equal(seen.has(encoded), false); + seen.add(encoded); + assert.deepEqual(decodePath(encoded), path); + for (const suffix of paths) assert.equal(encodePath([...path, ...suffix]), encoded + encodePath(suffix)); + } + assert.equal(encodePath(['a', '๐Ÿ˜€', '']), '1:a4:๐Ÿ˜€0:'); + for (const malformed of [ + '01:a', + '00:', + '1', + ':', + '-1:a', + '2:a', + '1:รฉ', + '99999999999999999999999999999:x', + '1:\ud800', + ]) + assert.throws(() => decodePath(malformed)); + assert.throws(() => encodePath(['\ud800'])); + assert.throws(() => encodePath(['\udc00'])); +}); + +// A controlled single-attachment fixture: no scheduler or physical-carrier claim. +class QueuedEndpoint implements Endpoint { + readonly queue: { path: Path; message: Message }[] = []; + receiver: Receiver | undefined; + attachments = 0; + detaches = 0; + closes = 0; + send(path: Path, message: Message): void { + if (this.closes) throw new PublicError('disconnected', 'Closed.'); + encodePath(path); + this.queue.push({ path: [...path], message }); + } + receive(receiver: Receiver): () => void { + if (this.closes) throw new PublicError('disconnected', 'Closed.'); + if (this.receiver) throw new ReceiverExistsError(); + this.attachments++; + this.receiver = receiver; + let active = true; + return () => { + if (!active) return; + active = false; + this.detaches++; + if (this.receiver === receiver) this.receiver = undefined; + }; + } + close(code = 1000, reason = ''): void { + if (this.closes) return; + this.closes++; + const receiver = this.receiver; + this.receiver = undefined; + receiver?.closed?.(code, reason); + } + async drain(): Promise { + for (let entry = this.queue.shift(); entry; entry = this.queue.shift()) { + await this.receiver?.message?.(entry.path, entry.message); + } + } +} + +const event: Message = { frame: { version: 1, kind: 'event', data: null } }; + +test('selection is send-only, copies prefixes and delegates nested and empty paths without dispatching', () => { + const root = new QueuedEndpoint(); + let delivered = 0; + root.receive({ + message: () => { + delivered++; + }, + }); + const prefix = ['a.b']; + const selected = at(root, prefix); + prefix[0] = 'changed'; + const nested = at(selected, ['๐Ÿ˜€']); + const suffix = ['call']; + nested.send(suffix, event); + suffix[0] = 'changed'; + at(root, []).send([], event); + at(at(root, ['a.b']), ['๐Ÿ˜€']).send(['call'], event); + assert.equal(delivered, 0); + assert.deepEqual( + root.queue.map((entry) => entry.path), + [['a.b', '๐Ÿ˜€', 'call'], [], ['a.b', '๐Ÿ˜€', 'call']], + ); + for (const entry of root.queue) assert.equal(entry.message, event); + for (const view of [selected, nested, at(root, [])]) { + assert.deepEqual(Object.keys(view), ['send']); + assert.equal('receive' in view, false); + assert.equal('close' in view, false); + } + // A send-only implementation, not just an Endpoint narrowed statically. + const access: AddressedWire = { send: (path, message) => root.send(path, message) }; + at(access, ['']).send([], event); + assert.deepEqual(root.queue.at(-1)?.path, ['']); +}); + +test('one mount receiver spans children and preserves whole messages, return identity and queued dispatch', async () => { + const left = new QueuedEndpoint(), + right = new QueuedEndpoint(), + replacement = new QueuedEndpoint(); + const children = new Map([ + ['left', left], + ['', right], + ]); + const mounted = mount(children); + children.set('left', replacement); + const received: { path: Path; message: Message }[] = []; + const detach = mounted.receive({ + message: (path, message) => { + received.push({ path, message }); + }, + }); + assert.equal(left.attachments, 1); + assert.equal(right.attachments, 1); + const reply: AddressedWire = { send() {} }; + const address = { wire: reply }; + const frames: ProfileFrame[] = [ + { version: 1, kind: 'request', id: 'c:1', params: { n: 42 }, meta: { tag: 'value' } }, + { version: 1, kind: 'response', id: 'c:1', error: { code: 'refused', message: 'No', data: { why: 'test' } } }, + { version: 1, kind: 'event', data: null }, + { version: 1, kind: 'cancel', id: 'c:1' }, + ]; + const messages = frames.map((frame) => ({ frame, return: address })); + const contexts = new WeakMap(); + for (const message of messages) { + contexts.set(message, {}); + at(at(mounted, ['left']), ['๐Ÿ˜€']).send(['call'], message); + } + mounted.send([''], event); + assert.equal(received.length, 0, 'destination ran inside send'); + assert.equal(left.queue.length, 4); + assert.equal(right.queue.length, 1); + assert.equal(replacement.queue.length, 0); + for (const entry of left.queue) assert.deepEqual(entry.path, ['๐Ÿ˜€', 'call']); + assert.deepEqual(right.queue[0]?.path, []); + await left.drain(); + await right.drain(); + assert.equal(received.length, 5); + for (let i = 0; i < messages.length; i++) { + const delivered = received[i]!; + assert.deepEqual(delivered.path, ['left', '๐Ÿ˜€', 'call']); + assert.equal(delivered.message, messages[i]); + assert.equal(delivered.message.return, address); + assert.equal(contexts.get(delivered.message), contexts.get(messages[i]!)); + } + assert.deepEqual(received[4], { path: [''], message: event }); + assert.throws(() => mounted.receive({}), { code: 'receiver_exists' }); + assert.equal(left.attachments, 1); + assert.equal(right.attachments, 1); + detach(); + assert.equal(left.detaches, 1); + assert.equal(right.detaches, 1); +}); + +test('detach allows rebind and a stale disposer cannot detach its successor or notify it', async () => { + const root = new QueuedEndpoint(); + const mounted = mount(new Map([['service', root]])); + const calls: string[] = []; + let closed = 0; + const first = mounted.receive({ + message: () => { + calls.push('first'); + }, + closed: () => { + closed++; + }, + }); + const captured = root.receiver!; + mounted.send(['service', 'first'], event); + await root.drain(); + first(); + const second = mounted.receive({ + message: () => { + calls.push('second'); + }, + closed: () => { + closed++; + }, + }); + first(); + captured.closed?.(1000, 'stale ending'); + mounted.send(['service', 'second'], event); + await root.drain(); + assert.deepEqual(calls, ['first', 'second']); + assert.equal(closed, 0); + assert.equal(root.detaches, 1); + second(); + second(); + mounted.close(); + assert.equal(root.detaches, 2); + assert.equal(root.closes, 0); + assert.equal(closed, 0); +}); + +test('partial acquisition rolls back earlier children without releasing another owner', () => { + const left = new QueuedEndpoint(), + right = new QueuedEndpoint(); + const other: Receiver = {}; + const releaseOther = right.receive(other); + const mounted = mount( + new Map([ + ['left', left], + ['right', right], + ]), + ); + let closed = 0; + assert.throws( + () => + mounted.receive({ + closed: () => { + closed++; + }, + }), + { code: 'receiver_exists' }, + ); + assert.equal(left.receiver, undefined); + assert.equal(left.detaches, 1); + assert.equal(right.receiver, other); + assert.equal(right.detaches, 0); + assert.equal(closed, 0); + releaseOther(); + const detach = mounted.receive({}); + detach(); + assert.equal(left.detaches, 2); + assert.equal(right.detaches, 2); + assert.equal(left.closes + right.closes, 0); +}); + +test('two mount keys cannot acquire the same child twice and roll back the first attachment', () => { + const child = new QueuedEndpoint(); + const mounted = mount( + new Map([ + ['one', child], + ['two', child], + ]), + ); + assert.throws(() => mounted.receive({}), { code: 'receiver_exists' }); + assert.equal(child.receiver, undefined); + assert.equal(child.detaches, 1); + assert.equal(child.closes, 0); + child.receive({})(); +}); + +test('mount close releases its attachment once and leaves borrowed children usable', async () => { + const left = new QueuedEndpoint(), + right = new QueuedEndpoint(); + const mounted = mount( + new Map([ + ['left', left], + ['right', right], + ]), + ); + let closed = 0; + const detach = mounted.receive({ + closed: (code, reason) => { + closed++; + assert.equal(code, 1000); + assert.equal(reason, 'mount ended'); + assert.equal(left.receiver, undefined); + assert.equal(right.receiver, undefined); + mounted.close(); + }, + }); + const old = left.receiver!; + mounted.close(1000, 'mount ended'); + mounted.close(1000, 'again'); + detach(); + old.closed?.(1000, 'late child'); + assert.equal(closed, 1); + assert.equal(left.closes + right.closes, 0); + assert.equal(left.detaches + right.detaches, 2); + assert.throws(() => mounted.receive({}), { code: 'disconnected' }); + assert.throws(() => at(mounted, ['left']).send(['call'], event), { code: 'disconnected' }); + let received = 0; + for (const child of [left, right]) { + const release = child.receive({ + message: () => { + received++; + }, + }); + child.send(['call'], event); + await child.drain(); + release(); + } + assert.equal(received, 2); +}); + +test('mount close during child receive releases both early and late returned disposers', () => { + const left = new QueuedEndpoint(), + right = new QueuedEndpoint(); + let mounted: Endpoint; + const child: Endpoint = { + send: (path, message) => right.send(path, message), + receive: (receiver) => { + const detach = right.receive(receiver); + mounted.close(1000, 'done'); + return detach; + }, + close: (code, reason) => right.close(code, reason), + }; + mounted = mount( + new Map([ + ['left', left], + ['right', child], + ]), + ); + let closed = 0; + assert.throws( + () => + mounted.receive({ + closed: () => { + closed++; + }, + }), + { code: 'disconnected' }, + ); + assert.equal(left.receiver, undefined); + assert.equal(right.receiver, undefined); + assert.equal(left.detaches, 1); + assert.equal(right.detaches, 1); + assert.equal(left.closes + right.closes, 0); + assert.equal(closed, 1); +}); + +test('a child ending during acquisition refuses and rolls back the incomplete attachment', () => { + const left = new QueuedEndpoint(), + right = new QueuedEndpoint(), + later = new QueuedEndpoint(); + const child: Endpoint = { + send: (path, message) => right.send(path, message), + receive: (receiver) => { + const detach = right.receive(receiver); + right.close(1000, 'ended during attachment'); + return detach; + }, + close: (code, reason) => right.close(code, reason), + }; + const mounted = mount( + new Map([ + ['left', left], + ['right', child], + ['later', later], + ]), + ); + let closed = 0; + assert.throws( + () => + mounted.receive({ + closed: () => { + closed++; + }, + }), + { code: 'disconnected' }, + ); + assert.equal(left.receiver, undefined); + assert.equal(right.receiver, undefined); + assert.equal(left.detaches, 1); + assert.equal(right.detaches, 1); + assert.equal(later.attachments, 0); + assert.equal(left.closes, 0); + assert.equal(closed, 0); + left.receive({})(); +}); + +test('a child ending leaves siblings active and the final child ends only the current attachment', async () => { + const left = new QueuedEndpoint(), + right = new QueuedEndpoint(); + const mounted = mount( + new Map([ + ['left', left], + ['right', right], + ]), + ); + const paths: Path[] = []; + const endings: { code: number; reason: string }[] = []; + const detach = mounted.receive({ + message: (path) => { + paths.push(path); + }, + closed: (code, reason) => { + endings.push({ code, reason }); + }, + }); + const firstReceiver = left.receiver!; + left.close(1000, 'left ended'); + firstReceiver.closed?.(1000, 'duplicate'); + assert.equal(endings.length, 0); + mounted.send(['right', 'still', 'usable'], event); + await right.drain(); + assert.deepEqual(paths, [['right', 'still', 'usable']]); + right.close(1001, 'last ended'); + assert.deepEqual(endings, [{ code: 1001, reason: 'last ended' }]); + assert.throws(() => mounted.send([], event), { code: 'missing_path' }); + detach(); + mounted.close(); + assert.equal(endings.length, 1); + assert.equal(left.closes, 1); + assert.equal(right.closes, 1); + assert.equal(left.detaches, 1); + assert.equal(right.detaches, 1); +}); + +test('captured request and cancellation retain the original receiver and async result after detach and rebind', async () => { + const root = new QueuedEndpoint(), + reply = new QueuedEndpoint(); + const mounted = mount(new Map([['service', root]])); + const address = { wire: reply }; + const request: Message = { frame: { version: 1, kind: 'request', id: 'c:1', params: {} }, return: address }; + const cancel: Message = { frame: { version: 1, kind: 'cancel', id: 'c:1' }, return: address }; + const seen: { path: Path; message: Message }[] = []; + let complete!: () => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + const detach = mounted.receive({ + message: (path, message) => { + seen.push({ path, message }); + return pending; + }, + }); + const captured = root.receiver!; + assert.equal(captured.message?.(['wait'], request), pending); + detach(); + let successor = 0; + const releaseNext = mounted.receive({ + message: () => { + successor++; + }, + }); + assert.equal(captured.message?.(['wait'], cancel), pending); + complete(); + await pending; + assert.deepEqual( + seen.map((entry) => entry.path), + [ + ['service', 'wait'], + ['service', 'wait'], + ], + ); + assert.equal(seen[0]?.message, request); + assert.equal(seen[1]?.message, cancel); + assert.equal(seen[1]?.message.return, address); + assert.equal(successor, 0); + const response: Message = { frame: { version: 1, kind: 'response', id: 'c:1', result: 42 } }; + seen[0]!.message.return!.wire.send([], response); + assert.equal(reply.queue[0]?.message, response); + releaseNext(); +}); + +test('empty paths and keys remain distinct, and an empty mount can own an attachment', async () => { + const child = new QueuedEndpoint(); + const mounted = mount(new Map([['', child]])); + const paths: Path[] = []; + const detach = mounted.receive({ + message: (path) => { + paths.push(path); + }, + }); + assert.throws(() => mounted.send([], event), { code: 'missing_path' }); + assert.throws(() => mounted.send(['missing'], event), { code: 'missing_path' }); + assert.throws(() => mounted.send(['', '\ud800'], event), { code: 'invalid_path' }); + mounted.send([''], event); + mounted.send(['', ''], event); + await child.drain(); + assert.deepEqual(paths, [[''], ['', '']]); + detach(); + const empty = mount(new Map()); + let closed = 0; + const release = empty.receive({ + closed: () => { + closed++; + }, + }); + assert.throws(() => empty.receive({}), { code: 'receiver_exists' }); + assert.throws(() => empty.send([], event), { code: 'missing_path' }); + release(); + empty.receive({ + closed: () => { + closed++; + }, + }); + empty.close(); + empty.close(); + assert.equal(closed, 1); +}); diff --git a/core/ts/test/forward.test.ts b/core/ts/test/forward.test.ts new file mode 100644 index 0000000..4736280 --- /dev/null +++ b/core/ts/test/forward.test.ts @@ -0,0 +1,55 @@ +// Regressions of what forward changes from Nightseam v0.6.0's forwardWire: +// research 0001 row 14 (a refused message fails only itself) and R26 (a +// closed destination answers disconnected, never internal). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Endpoint } from '@bitspark/bitwire'; +import { forward, mount, pair, PublicError } from '../src/index.ts'; +import { call, createDispatcher, handle } from '../../../dispatch/ts/src/index.ts'; + +test('R26: a request forwarded to a closed mount is answered disconnected, not internal', async (t) => { + const [caller, forwarding] = pair(); + const [leaf, leafServer] = pair(); + const [other] = pair(); + const inner = mount(new Map([['leaf', leaf]])); + const outer = mount(new Map([ + ['inner', inner], + ['other', other], + ])); + const stop = forward(forwarding, outer); + t.after(() => { + stop(); + caller.close(); + leaf.close(); + other.close(); + }); + const dispatcher = createDispatcher(leafServer); + handle(dispatcher, ['read'], () => 'open'); + assert.equal(await call(caller, ['inner', 'leaf', 'read']), 'open'); + inner.close(); + await assert.rejects(call(caller, ['inner', 'leaf', 'read']), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'disconnected', 'a closed carrier behind forward surfaced as something else'); + return true; + }); +}); + +test('row 14: a message the destination refuses fails only itself, and forwarding goes on', async (t) => { + const [caller, forwarding] = pair(); + const [service, serviceServer] = pair(); + const stop = forward(forwarding, mount(new Map([['service', service]]))); + t.after(() => { + stop(); + caller.close(); + service.close(); + }); + const dispatcher = createDispatcher(serviceServer); + handle(dispatcher, ['echo'], (value) => value); + // The mount has no child here: the refusal answers this request alone. + await assert.rejects(call(caller, ['absent', 'echo'], 1), { code: 'internal' }); + // An event the destination refuses is dropped, as an event has no answer. + caller.send(['absent'], { frame: { version: 1, kind: 'event', data: null } }); + // v0.6.0 detached both directions on the first refusal. + assert.equal(await call(caller, ['service', 'echo'], 2), 2); + assert.equal(await call(caller, ['service', 'echo'], 3), 3); +}); diff --git a/core/ts/test/invocation-experiment.test.ts b/core/ts/test/invocation-experiment.test.ts new file mode 100644 index 0000000..6bfa349 --- /dev/null +++ b/core/ts/test/invocation-experiment.test.ts @@ -0,0 +1,332 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/invocation-experiment.test.ts. +// The second integration uses the public vocabulary and nothing else. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Endpoint, Message, Path, Receiver, ReturnAddress, AddressedWire } from '@bitspark/bitwire'; +import { + forward, + invocationBegin, + invocationCapture, + invocationControl, + invocationDone, + invocationReady, + invocationRelease, +} from '../src/index.ts'; +import { createDispatcher, handle } from '../../../dispatch/ts/src/index.ts'; + +/** + * The second independent integration. It shares no ledger with the first and + * composes none of bitruntime's lifecycle: it answers the invocation vocabulary + * itself, out of its own state, using the operation paths and nothing else. If + * the dispatcher works against this, the boundary is public in fact and not + * only in name. + */ +class HandwrittenCall { + readonly address: ReturnAddress; + readonly outcomes: Message[] = []; + readonly #owner: HandwrittenEndpoint; + #sinks = new Map(); + #delivered = new Set(); + #told = new Set(); + #bodies = new Set(); + #takenCaptures = 0; + #takenBodies = 0; + #pending = 1; + #control: Message | undefined; + #settled = false; + retired = false; + + constructor(owner: HandwrittenEndpoint) { + this.#owner = owner; + this.address = { wire: { send: (path, message) => this.#send(path, message) } }; + } + + #send(path: Path, message: Message): void { + if (path.length === 0) { + if (message.frame.kind !== 'response') throw new Error('invalid outcome'); + this.outcomes.push(message); + this.#settled = true; + this.#retire(); + return; + } + if (path[0] === invocationControl) { + if (path.length !== 1 || message.frame.kind !== 'cancel') throw new Error('unknown invocation operation'); + this.#latch(message); + return; + } + if (path.length !== 2) throw new Error('unknown invocation operation'); + const id = path[1]!; + switch (path[0]) { + case invocationCapture: { + const sink = message.return?.wire; + if (!sink) throw new Error('unknown invocation operation'); + if (this.retired) throw new Error('retired'); + if (this.#takenCaptures >= this.#owner.captureBound) { + this.#owner.refusals++; + throw new Error('capture bound reached'); + } + this.#takenCaptures++; + this.#pending++; + this.#sinks.set(id, sink); + return; + } + case invocationReady: { + const sink = this.#sinks.get(id); + if (!sink || this.#delivered.has(id)) return; + this.#delivered.add(id); + this.#pending--; + if (this.#control && !this.#told.has(id)) { + this.#told.add(id); + sink.send([], this.#control); + } + this.#retire(); + return; + } + case invocationRelease: { + if (!this.#sinks.has(id)) return; + if (!this.#delivered.has(id)) this.#pending--; + this.#sinks.delete(id); + this.#delivered.delete(id); + this.#retire(); + return; + } + case invocationBegin: { + if (this.retired) throw new Error('retired'); + if (this.#takenBodies >= this.#owner.bodyBound) { + this.#owner.refusals++; + throw new Error('body bound reached'); + } + this.#takenBodies++; + this.#bodies.add(id); + return; + } + case invocationDone: { + if (!this.#bodies.delete(id)) return; + this.#retire(); + return; + } + default: + throw new Error('unknown invocation operation'); + } + } + + #latch(message: Message): void { + if (this.retired || this.#control) return; + this.#control = message; + for (const [id, sink] of this.#sinks) { + if (!this.#delivered.has(id) || this.#told.has(id)) continue; + this.#told.add(id); + sink.send([], message); + } + } + + dispatchDone(): void { + this.#pending--; + this.#retire(); + } + + cancel(id: string): void { + this.address.wire.send([invocationControl], { frame: { version: 1, kind: 'cancel', id }, return: this.address }); + } + + #retire(): void { + if (this.retired || !this.#settled || this.#pending !== 0 || this.#bodies.size !== 0) return; + this.retired = true; + this.#sinks = new Map(); + this.#delivered = new Set(); + this.#told = new Set(); + this.#control = undefined; + this.#owner.retirements++; + } +} + +class HandwrittenEndpoint implements Endpoint { + #receiver: Receiver | undefined; + #next = 0; + retirements = 0; + refusals = 0; + readonly captureBound: number; + readonly bodyBound: number; + constructor(captureBound = 8, bodyBound = 8) { + this.captureBound = captureBound; + this.bodyBound = bodyBound; + } + send(): void {} + receive(receiver: Receiver): () => void { + if (this.#receiver) throw new Error('receiver_exists'); + this.#receiver = receiver; + return () => { + if (this.#receiver === receiver) this.#receiver = undefined; + }; + } + close(code = 1000, reason = ''): void { + const receiver = this.#receiver; + this.#receiver = undefined; + receiver?.closed?.(code, reason); + } + admit(path: Path, params: unknown = null): { id: string; call: HandwrittenCall } { + const id = `h:${++this.#next}`; + const call = new HandwrittenCall(this); + void this.#receiver?.message?.(path, { + frame: { version: 1, kind: 'request', id, params }, + return: call.address, + }); + call.dispatchDone(); + return { id, call }; + } +} + +/** Passes the complete message and return capability through, and nothing else. */ +class Opaque implements Endpoint { + private readonly inner: Endpoint; + constructor(inner: Endpoint) { + this.inner = inner; + } + send(path: Path, message: Message): void { + this.inner.send(path, message); + } + receive(receiver: Receiver): () => void { + return this.inner.receive(receiver); + } + close(code?: number, reason?: string): void { + this.inner.close(code, reason); + } +} + +/** + * Half of a pure route: what is sent on one half is delivered to the other + * half's attachment, verbatim, with the return capability untouched. It + * correlates nothing and admits nothing, so a composition over it is pure + * forwarding rather than a carrier hop. + */ +class Conduit implements Endpoint { + other: Conduit | undefined; + private attached: Receiver | undefined; + send(path: Path, message: Message): void { + void this.other?.attached?.message?.(path, message); + } + receive(receiver: Receiver): () => void { + if (this.attached) throw new Error('receiver_exists'); + this.attached = receiver; + return () => { + if (this.attached === receiver) this.attached = undefined; + }; + } + close(): void {} +} +const conduit = (): [Conduit, Conduit] => { + const near = new Conduit(); + const far = new Conduit(); + near.other = far; + far.other = near; + return [near, far]; +}; + +const settled = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +test('a second integration participates with no shared ledger', async () => { + const endpoint = new HandwrittenEndpoint(); + const dispatch = createDispatcher(new Opaque(endpoint)); + const view = createDispatcher(dispatch.select(['space'])); + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let cancelled: boolean | undefined; + handle(view, ['read'], async (_params, context) => { + await held; + cancelled = context.signal.aborted; + return 'answer'; + }); + const { id, call } = endpoint.admit(['space', 'read']); + await settled(); + assert.equal(call.retired, false, 'retired while the body was running'); + call.cancel(id); + release(); + await settled(); + assert.equal(cancelled, true, 'cancellation did not reach the captured traversal of the second integration'); + assert.equal(call.outcomes.length, 1); + assert.equal(call.retired, true); + assert.equal(endpoint.retirements, 1); +}); + +test('forwarding preserves lifecycle participation and the original return capability', async () => { + const origin = new HandwrittenEndpoint(); + const [near, far] = conduit(); + forward(new Opaque(origin), new Opaque(near)); + const dispatch = createDispatcher(far); + const controls: Message[] = []; + const requests: Message[] = []; + const detach = dispatch.register(['read'], { + message(_path, message) { + if (message.frame.kind === 'cancel') controls.push(message); + else requests.push(message); + }, + }); + const { id, call } = origin.admit(['read']); + await settled(); + assert.equal(requests.length, 1, 'the forwarded request never arrived'); + assert.equal(requests[0]?.return, call.address, 'forwarding did not preserve the original return capability'); + detach(); + const rebound: Message[] = []; + dispatch.register(['read'], { + message(_path, message) { + rebound.push(message); + }, + }); + call.cancel(id); + await settled(); + assert.equal(controls.length, 1, 'the control did not follow the captured traversal across the forwarder'); + assert.equal(rebound.length, 0, 'the control reached the rebound registration'); +}); + +test('a queued control cannot reach a reused identity', async () => { + const endpoint = new HandwrittenEndpoint(); + const dispatch = createDispatcher(endpoint); + const seen: string[] = []; + dispatch.register(['read'], { + message(_path, message) { + seen.push(`${message.frame.kind}:${'id' in message.frame ? message.frame.id : ''}`); + }, + }); + const first = endpoint.admit(['read']); + await settled(); + assert.deepEqual(seen, ['request:h:1']); + // The first invocation settles and retires before its old control is + // released. Its control ticket is the invocation itself, not a key. + first.call.address.wire.send([], { frame: { version: 1, kind: 'response', id: 'h:1', result: null } }); + const second = endpoint.admit(['read']); + await settled(); + assert.deepEqual(seen, ['request:h:1', 'request:h:2']); + // The stale control names the first invocation's identifier and travels on + // the first invocation's own return capability. It reaches nothing. + first.call.cancel('h:1'); + await settled(); + assert.deepEqual(seen, ['request:h:1', 'request:h:2'], 'a stale control was delivered'); + second.call.cancel('h:2'); + await settled(); + assert.deepEqual(seen, ['request:h:1', 'request:h:2', 'cancel:h:2']); +}); + +test('a bound past which a traversal cannot capture refuses instead of routing', async () => { + const endpoint = new HandwrittenEndpoint(1, 8); + const outer = createDispatcher(endpoint); + const inner = createDispatcher(outer.select(['a'])); + let routed = false; + inner.register(['read'], { + message() { + routed = true; + }, + }); + const { call } = endpoint.admit(['a', 'read']); + await settled(); + assert.equal(routed, false, 'the inner receiver was reached past the bound'); + assert.equal(call.outcomes.length, 1); + const outcome = call.outcomes[0]!.frame; + assert.equal(outcome.kind, 'response'); + // The refusal is this integration's own: a dispatcher reports `busy` for a + // bound it can recognize as one, and `invalid_message` for a refusal whose + // reason a facility did not spell in the agreed vocabulary. + if (outcome.kind === 'response') assert.equal(outcome.error?.code, 'invalid_message'); + assert.ok(endpoint.refusals > 0, 'the bound was never exercised'); +}); diff --git a/core/ts/test/invocation.test.ts b/core/ts/test/invocation.test.ts new file mode 100644 index 0000000..c31680b --- /dev/null +++ b/core/ts/test/invocation.test.ts @@ -0,0 +1,318 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/invocation.test.ts. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Endpoint, Message, Path, Receiver, ReturnAddress, AddressedWire } from '@bitspark/bitwire'; +import { + Invocation, + InvocationError, + beginInvocationBody, + captureInvocation, + defaultInvocationLimits, + invocationControl, + type InvocationLimits, +} from '../src/index.ts'; +import { createDispatcher, handle } from '../../../dispatch/ts/src/index.ts'; + +/** + * An endpoint written against the public contract alone. It admits requests, + * answers the invocation vocabulary out of its own ledger, and recognizes no + * concrete type. It is the first of the two independent integrations. + */ +class LedgerEndpoint implements Endpoint { + #receiver: Receiver | undefined; + #next = 0; + readonly #limits: InvocationLimits; + readonly admitted = new Map(); + readonly returns = new Map(); + readonly outcomes = new Map(); + retirements = 0; + + constructor(limits: InvocationLimits = defaultInvocationLimits()) { + this.#limits = limits; + } + /** Loops back into this endpoint's own attachment, never on this stack. */ + send(path: Path, message: Message): void { + queueMicrotask(() => this.deliver(path, message)); + } + receive(receiver: Receiver): () => void { + if (this.#receiver) throw new Error('receiver_exists'); + this.#receiver = receiver; + return () => { + if (this.#receiver === receiver) this.#receiver = undefined; + }; + } + close(code = 1000, reason = ''): void { + const receiver = this.#receiver; + this.#receiver = undefined; + receiver?.closed?.(code, reason); + } + deliver(path: Path, message: Message): void | Promise { + return this.#receiver?.message?.(path, message); + } + admit(path: Path, params: unknown = null): { id: string; invocation: Invocation; delivery: void | Promise } { + const id = `x:${++this.#next}`; + const invocation = new Invocation(this.#limits, () => { + this.retirements++; + }); + const outcomes: Message[] = []; + this.outcomes.set(id, outcomes); + const address: ReturnAddress = { + wire: { + send: (suffix, reply) => { + if (suffix.length) { + invocation.deliver(suffix, reply); + return; + } + outcomes.push(reply); + invocation.settle(); + }, + }, + }; + this.admitted.set(id, invocation); + this.returns.set(id, address); + const delivery = this.deliver(path, { + frame: { version: 1, kind: 'request', id, params }, + return: address, + }); + const finish = () => invocation.dispatchDone(); + if (delivery) return { id, invocation, delivery: delivery.then(finish) }; + finish(); + return { id, invocation, delivery: undefined }; + } + cancel(id: string): void { + const address = this.returns.get(id); + address?.wire.send([invocationControl], { + frame: { version: 1, kind: 'cancel', id }, + return: address, + }); + } +} + +/** Passes the complete message and return capability through, and nothing else. */ +class OpaqueEndpoint implements Endpoint { + readonly #inner: Endpoint; + constructor(inner: Endpoint) { + this.#inner = inner; + } + send(path: Path, message: Message): void { + this.#inner.send(path, message); + } + receive(receiver: Receiver): () => void { + return this.#inner.receive(receiver); + } + close(code?: number, reason?: string): void { + this.#inner.close(code, reason); + } +} + +const settled = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +test('an independent endpoint participates through the public vocabulary alone', async () => { + const endpoint = new LedgerEndpoint(); + const dispatch = createDispatcher(new OpaqueEndpoint(endpoint)); + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let cancelled: boolean | undefined; + handle(dispatch, ['read'], async (_params, context) => { + await held; + cancelled = context.signal.aborted; + return 'answer'; + }); + const { id, invocation } = endpoint.admit(['read']); + await settled(); + assert.equal(invocation.retired, false, 'retired while the body was running'); + endpoint.cancel(id); + release(); + await settled(); + assert.equal(cancelled, true, 'cancellation did not reach the captured traversal'); + assert.equal(endpoint.outcomes.get(id)?.length, 1); + assert.equal(invocation.retired, true); + assert.equal(endpoint.retirements, 1); +}); + +test('a captured traversal keeps its receiver across detach and rebind', async () => { + const endpoint = new LedgerEndpoint(); + const dispatch = createDispatcher(endpoint); + const first: Message[] = []; + const second: Message[] = []; + const detach = dispatch.register(['read'], { + message(_path, message) { + first.push(message); + }, + }); + const { id } = endpoint.admit(['read']); + await settled(); + assert.equal(first.length, 1); + detach(); + dispatch.register(['read'], { + message(_path, message) { + second.push(message); + }, + }); + endpoint.cancel(id); + await settled(); + assert.equal(first.length, 2); + assert.equal(first[1]?.frame.kind, 'cancel'); + assert.equal(second.length, 0, 'a control reached the rebound receiver'); +}); + +test('each traversal of one dispatcher captures separately', async () => { + const endpoint = new LedgerEndpoint(); + const dispatch = createDispatcher(endpoint); + const seen: string[] = []; + dispatch.register(['outer'], { + message(_path, message) { + seen.push(`outer:${message.frame.kind}`); + if (message.frame.kind === 'request') dispatch.send(['inner'], message); + }, + }); + dispatch.register(['inner'], { + message(_path, message) { + seen.push(`inner:${message.frame.kind}`); + }, + }); + const { id } = endpoint.admit(['outer']); + await settled(); + assert.deepEqual(seen, ['outer:request', 'inner:request']); + endpoint.cancel(id); + await settled(); + assert.deepEqual(seen.slice(2).sort(), ['inner:cancel', 'outer:cancel']); +}); + +test('a capture installed while cancellation is latched still receives it', async () => { + const endpoint = new LedgerEndpoint(); + const dispatch = createDispatcher(endpoint); + const inner = createDispatcher(dispatch.select(['a'])); + const controls: Message[] = []; + inner.register(['read'], { + message(_path, message) { + if (message.frame.kind === 'request') { + endpoint.cancel(message.frame.id); + return; + } + controls.push(message); + }, + }); + endpoint.admit(['a', 'read']); + await settled(); + assert.equal(controls.length, 1, 'a capture installed while cancellation was latched never received it'); + assert.equal(controls[0]?.frame.kind, 'cancel'); +}); + +test('an invocation bounds its total captures and bodies', () => { + const endpoint = new LedgerEndpoint({ captures: 2, bodies: 1 }); + const { id } = endpoint.admit(['read']); + const message: Message = { + frame: { version: 1, kind: 'request', id, params: null }, + return: endpoint.returns.get(id)!, + }; + captureInvocation(message, () => {}); + captureInvocation(message, () => {}); + assert.throws( + () => captureInvocation(message, () => {}), + (error: unknown) => error instanceof InvocationError && error.code === 'limit', + ); + const body = beginInvocationBody(message); + assert.throws( + () => beginInvocationBody(message), + (error: unknown) => error instanceof InvocationError && error.code === 'limit', + ); + // The bound is a total, so a finished body gives back no slot: neither depth + // nor shallow fan-out can grow what one invocation retains. + body.done(); + assert.throws( + () => beginInvocationBody(message), + (error: unknown) => error instanceof InvocationError && error.code === 'limit', + ); +}); + +test('retirement waits for the body and the control drain', () => { + const endpoint = new LedgerEndpoint(); + const { id, invocation } = endpoint.admit(['read']); + const message: Message = { + frame: { version: 1, kind: 'request', id, params: null }, + return: endpoint.returns.get(id)!, + }; + const capture = captureInvocation(message, () => {}); + const body = beginInvocationBody(message); + invocation.settle(); + assert.equal(invocation.retired, false, 'retired with an undelivered capture and a running body'); + capture.ready(); + assert.equal(invocation.retired, false, 'retired while the body was still running'); + body.done(); + assert.equal(invocation.retired, true); + assert.throws( + () => captureInvocation(message, () => {}), + (error: unknown) => error instanceof InvocationError && error.code === 'ended', + ); + assert.throws( + () => beginInvocationBody(message), + (error: unknown) => error instanceof InvocationError && error.code === 'ended', + ); +}); + +test('sequential completions beyond capacity retain nothing', async () => { + const endpoint = new LedgerEndpoint({ captures: 2, bodies: 2 }); + const dispatch = createDispatcher(endpoint); + handle(dispatch, ['read'], () => 'answer'); + for (let index = 0; index < 32; index++) { + const { invocation, id } = endpoint.admit(['read']); + await settled(); + assert.equal(endpoint.outcomes.get(id)?.length, 1, `outcome ${index}`); + assert.equal(invocation.retired, true, `retired ${index}`); + } + assert.equal(endpoint.retirements, 32); +}); + +test('a traversal past the capture bound is refused as busy', async () => { + const endpoint = new LedgerEndpoint({ captures: 1, bodies: 8 }); + const outer = createDispatcher(endpoint); + const inner = createDispatcher(outer.select(['a'])); + let routed = false; + inner.register(['read'], { + message() { + routed = true; + }, + }); + const { id } = endpoint.admit(['a', 'read']); + await settled(); + assert.equal(routed, false, 'the inner receiver was reached past the bound'); + const outcomes = endpoint.outcomes.get(id) ?? []; + assert.equal(outcomes.length, 1); + const outcome = outcomes[0]!.frame; + assert.equal(outcome.kind, 'response'); + if (outcome.kind === 'response') assert.equal(outcome.error?.code, 'busy'); +}); + +test('a dispatcher refuses an invocation whose return capability carries no lifecycle', async () => { + const endpoint = new LedgerEndpoint(); + const dispatch = createDispatcher(endpoint); + let routed = false; + dispatch.register(['read'], { + message() { + routed = true; + }, + }); + const answers: Message[] = []; + let uses = 0; + const bare: AddressedWire = { + send(path, message) { + if (path.length) throw new Error('this return capability carries no lifecycle'); + uses++; + answers.push(message); + }, + }; + endpoint.deliver(['read'], { + frame: { version: 1, kind: 'request', id: 'b:1', params: null }, + return: { wire: bare }, + }); + await settled(); + assert.equal(routed, false, 'an unmanaged invocation was routed with weaker guarantees'); + assert.equal(answers.length, 1); + const answer = answers[0]!.frame; + assert.equal(answer.kind, 'response'); + if (answer.kind === 'response') assert.equal(answer.error?.code, 'invalid_message'); + assert.equal(uses, 1, 'the refusal did not use the original return capability once'); +}); diff --git a/core/ts/test/pair.test.ts b/core/ts/test/pair.test.ts new file mode 100644 index 0000000..a183f03 --- /dev/null +++ b/core/ts/test/pair.test.ts @@ -0,0 +1,432 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/wire-pair.test.ts (wirePair is +// now pair, with PairOptions of its own), followed by the regressions of the +// defects this port fixes: nightseam#722, nightseam#658, R26 and R27. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AddressedWire, Message, ReturnAddress } from '@bitspark/bitwire'; +import { pair, PublicError, UnpublishedError } from '../src/index.ts'; +import { setDispatchContext } from '../src/internal/context.ts'; +import { call, createDispatcher, emit, handle, onEvent } from '../../../dispatch/ts/src/index.ts'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} + +test('local pair supports reverse calls and independent construction', async (t) => { + const [a, b] = pair(); + const [x, y] = pair(); + const leftDispatcher = createDispatcher(a); + const rightDispatcher = createDispatcher(b); + const independentDispatcher = createDispatcher(y); + t.after(() => { + leftDispatcher.close(); + rightDispatcher.close(); + independentDispatcher.close(); + a.close(); + x.close(); + }); + handle(leftDispatcher, ['reverse'], (value) => value); + handle(rightDispatcher, ['call'], (value) => call(b, ['reverse'], value)); + handle(independentDispatcher, ['call'], () => 'independent'); + assert.equal(await call(a, ['call'], 7), 7); + a.close(); + assert.equal(await call(x, ['call']), 'independent'); +}); + +test('local pending budget lives until the response and can then be reused', async (t) => { + const [a, b] = pair({ maxPendingRequests: 1 }); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const started = deferred(), + release = deferred(); + handle(dispatcher, ['hold'], async () => { + started.resolve(); + await release.promise; + return 1; + }); + const first = call(a, ['hold']); + await started.promise; + await assert.rejects(call(a, ['hold']), { code: 'busy' }); + release.resolve(); + assert.equal(await first, 1); + assert.equal(await call(a, ['hold']), 1); +}); + +test('local events await serially and cancellation owns capacity outside the full data queue', async (t) => { + const [a, b] = pair({ queueCapacity: 1, maxPendingRequests: 1 }); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const started = deferred(), + cancelled = deferred(), + entered = deferred(), + release = deferred(), + drained = deferred(); + const seen: number[] = []; + handle(dispatcher, ['hold'], async (_params, context) => { + started.resolve(); + await new Promise((resolve) => + context.signal.addEventListener( + 'abort', + () => { + cancelled.resolve(); + resolve(); + }, + { once: true }, + ), + ); + }); + onEvent(dispatcher, ['event'], async (value) => { + seen.push(value as number); + if (value === 1) { + entered.resolve(); + await release.promise; + } else drained.resolve(); + }); + const controller = new AbortController(); + const first = call(a, ['hold'], null, { signal: controller.signal }).catch((error: unknown) => error); + await started.promise; + emit(a, ['event'], 1); + await entered.promise; + emit(a, ['event'], 2); + controller.abort(); + assert.equal(((await first) as { code: string }).code, 'cancelled'); + release.resolve(); + await Promise.all([cancelled.promise, drained.promise]); + assert.deepEqual(seen, [1, 2]); +}); + +test('a full local carrier closes even while its event consumer is held', async (t) => { + const [a, b] = pair({ queueCapacity: 1 }); + t.after(() => a.close()); + const entered = deferred(), + release = deferred(), + ended = deferred(); + b.receive({ + message: async () => { + entered.resolve(); + await release.promise; + }, + closed: () => ended.resolve(), + }); + emit(a, ['event']); + await entered.promise; + emit(a, ['event']); + // R26: the refused send reports the carrier ended, keeping why as its cause. + assert.throws( + () => emit(a, ['event']), + (error: unknown) => + error instanceof UnpublishedError && + error.code === 'disconnected' && + ((error.cause as PublicError).cause as PublicError).code === 'busy', + ); + await ended.promise; + release.resolve(); +}); + +test('request and cancellation use one mapped return capability and responses retire on throwing returns', async (t) => { + const [a, b] = pair({ maxPendingRequests: 1 }); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const received = deferred(), + cancelled = deferred(); + dispatcher.register(['raw'], { + message: (_path, message) => { + (message.frame.kind === 'request' ? received : cancelled).resolve(message); + }, + }); + const returning: ReturnAddress = { + wire: { + send: () => { + throw new Error('return failed'); + }, + }, + }; + a.send(['raw'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: returning }); + const request = await received.promise; + assert.notEqual(request.return, returning); + a.send(['raw'], { frame: { version: 1, kind: 'cancel', id: 'c:1' }, return: returning }); + assert.equal((await cancelled.promise).return, request.return); + assert.throws( + () => request.return!.wire.send([], { frame: { version: 1, kind: 'response', id: 'c:1', result: null } }), + /return failed/, + ); + handle(dispatcher, ['next'], () => 'reused'); + assert.equal(await call(a, ['next']), 'reused'); +}); + +test('local roots preserve private dispatch context and keep metadata explicit', async (t) => { + const [a, b] = pair(); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const verified = Object.freeze({ identity: 'verified locally' }); + const source = { signal: new AbortController().signal, requestId: 'physical:17' }; + Object.defineProperty(source, 'verified', { value: verified }); + const traced: AddressedWire = { + send: (path, message) => { + if (message.frame.kind === 'request') setDispatchContext(message.return!, { context: source, maxFrameBytes: 1024 }); + a.send(path, message); + }, + }; + handle(dispatcher, ['inspect'], (_value, context) => { + assert.equal((context as typeof context & { verified: unknown }).verified, verified); + assert.equal(context.requestId, 'physical:17'); + assert.deepEqual(context.meta, { explicit: 'yes' }); + return 'observed'; + }); + assert.equal(await call(traced, ['inspect'], null, { meta: { explicit: 'yes' } }), 'observed'); +}); + +test('local exact routes win over the longest namespace and detach reveals fallback', async (t) => { + const [a, b] = pair(); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const seen: string[] = []; + const receiver = (label: string) => ({ + message: (path: readonly string[], message: Message) => { + seen.push(`${label}:${path.join('/')}`); + message.return!.wire.send([], { + frame: { version: 1, kind: 'response', id: (message.frame as { id: string }).id, result: label }, + }); + }, + }); + dispatcher.registerPrefix([], receiver('root')); + dispatcher.registerPrefix(['a'], receiver('a')); + const detach = handle(dispatcher, ['a', 'b'], () => 'exact'); + assert.equal(await call(a, ['a', 'b']), 'exact'); + detach(); + assert.equal(await call(a, ['a', 'b']), 'a'); + assert.equal(await call(a, ['other']), 'root'); + assert.deepEqual(seen, ['a:a/b', 'root:other']); +}); + +test('local deadline cancels handlers but retains noncooperative handler capacity', async (t) => { + const [a, b] = pair({ requestTimeoutMs: 15, maxConcurrentHandlers: 1 }); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const release = deferred(), + started = deferred(), + cancelled = deferred(); + let invocations = 0; + handle(dispatcher, ['hold'], async (_value, context) => { + invocations++; + started.resolve(); + context.signal.addEventListener('abort', () => cancelled.resolve(), { once: true }); + await release.promise; + }); + const first = call(a, ['hold']).catch((error: unknown) => error); + await started.promise; + assert.equal(((await first) as { code: string }).code, 'cancelled'); + await cancelled.promise; + await assert.rejects(call(a, ['hold']), { code: 'busy' }); + assert.equal(invocations, 1); + release.resolve(); +}); + +test('local queued payload snapshots cannot be changed by the sender', async (t) => { + const [a, b] = pair(); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + const seen = deferred(); + onEvent(dispatcher, ['event'], (value) => seen.resolve(value)); + const data = { nested: ['original'] }; + a.send(['event'], { frame: { version: 1, kind: 'event', data } }); + data.nested[0] = 'mutated'; + assert.deepEqual(await seen.promise, { nested: ['original'] }); +}); + +test('a stalled local event reaches the configured deadline', async (t) => { + // The observer's backpressure event is not ported (observers are removed); + // the pair's own diagnostic says what stalled instead. + const stalled = deferred(); + const [a, b] = pair({ writeTimeoutMs: 15, onError: (error) => stalled.resolve(error.code) }); + t.after(() => a.close()); + const release = deferred(), + closed = deferred(); + const started = Date.now(); + b.receive({ message: () => release.promise, closed: () => closed.resolve() }); + emit(a, ['event']); + assert.equal(await stalled.promise, 'stalled_consumer'); + assert.ok(Date.now() - started >= 10, 'the pair ended before its configured deadline'); + await closed.promise; + assert.throws(() => emit(a, ['event']), { code: 'disconnected' }); + release.resolve(); +}); + +test('an oversized local response settles through the bounded internal fallback', async (t) => { + const [a, b] = pair({ maxFrameBytes: 512 }); + const dispatcher = createDispatcher(b); + t.after(() => { + dispatcher.close(); + a.close(); + }); + handle(dispatcher, ['large'], () => 'x'.repeat(2048)); + await assert.rejects(call(a, ['large'], null, { timeoutMs: 1000 }), { code: 'internal' }); +}); + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); +const outcome = (message: Message): string => { + const frame = message.frame; + if (frame.kind !== 'response') return `unexpected ${frame.kind}`; + return frame.error ? frame.error.code : String(frame.result); +}; + +test('nightseam#722: a closing pair answers every queued refusal with the refusal it was admitted with', async () => { + const [a, b] = pair({ maxPendingRequests: 1 }); + b.receive({ message: () => {} }); + const answers = new Map(); + const returning = (label: string): ReturnAddress => ({ + wire: { send: (_path, message) => void answers.set(label, outcome(message)) }, + }); + const request = (id: string, address: ReturnAddress): void => + a.send(['op'], { frame: { version: 1, kind: 'request', id, params: null }, return: address }); + const admitted = returning('admitted'); + request('c:1', admitted); + // Refused at admission and queued behind it: a duplicate identifier on the + // same return capability, and a call past the pending bound. + request('c:1', admitted); + request('c:2', returning('over the bound')); + // The pair closes before its queue drains; v0.6.0 dropped both refusals. + a.close(); + await settle(); + assert.deepEqual(Object.fromEntries(answers), { + admitted: 'disconnected', + 'over the bound': 'busy', + }); + // The duplicate shares the admitted request's return capability: what it + // was answered is recorded under that label, in the order they were given. + const both: string[] = []; + const [c, d] = pair({ maxPendingRequests: 1 }); + d.receive({ message: () => {} }); + const shared: ReturnAddress = { wire: { send: (_path, message) => void both.push(outcome(message)) } }; + c.send(['op'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: shared }); + c.send(['op'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: shared }); + c.close(); + await settle(); + assert.deepEqual(both, ['invalid_message', 'disconnected']); +}); + +test('nightseam#722: a caller of a closing pair is answered, not left to its own deadline', async () => { + const [a, b] = pair({ maxPendingRequests: 1 }); + const dispatcher = createDispatcher(b); + handle(dispatcher, ['hold'], () => new Promise(() => {})); + const first = call(a, ['hold'], null, { timeoutMs: 5_000 }).catch((error: unknown) => error); + const second = call(a, ['hold'], null, { timeoutMs: 5_000 }).catch((error: unknown) => error); + a.close(); + const settled = await Promise.race([ + Promise.all([first, second]), + new Promise<'waited'>((resolve) => setTimeout(() => resolve('waited'), 1_000)), + ]); + assert.notEqual(settled, 'waited', 'a queued refusal was left to the caller deadline'); + assert.equal(((await first) as PublicError).code, 'disconnected'); + assert.equal(((await second) as PublicError).code, 'busy'); +}); + +test('nightseam#658: a pair response frees its call slot before the caller holds the answer', async () => { + const [a, b] = pair({ maxPendingRequests: 1 }); + const dispatcher = createDispatcher(b); + handle(dispatcher, ['echo'], (value) => value); + // Each caller issues its next call from inside the delivery of its answer, + // with a fresh return capability, as a synchronous consumer does. The slot + // must already be free: v0.6.0 retired the call only after forwarding. + const answers: string[] = []; + const done = deferred(); + const next = (index: number): void => { + const returning: AddressedWire = { + send: (_path, message) => { + answers.push(outcome(message)); + if (index + 1 < 200) next(index + 1); + else done.resolve(); + }, + }; + a.send(['echo'], { frame: { version: 1, kind: 'request', id: 'c:1', params: index }, return: { wire: returning } }); + }; + next(0); + await done.promise; + assert.deepEqual( + answers, + Array.from({ length: 200 }, (_, index) => String(index)), + ); + // The same through the call helper, 200 times over. + for (let index = 0; index < 200; index++) assert.equal(await call(a, ['echo'], index), index); + dispatcher.close(); + a.close(); +}); + +test('nightseam#658: a call whose cancellation is still queued keeps its slot until it drains', async () => { + const [a, b] = pair({ maxPendingRequests: 1 }); + let held: Message | undefined; + b.receive({ + message: (_path, message) => { + if (message.frame.kind === 'request') held = message; + }, + }); + const answers: string[] = []; + const returning = (label: string): ReturnAddress => ({ + wire: { send: (_path, message) => void answers.push(`${label}:${outcome(message)}`) }, + }); + const request = (address: ReturnAddress): void => + a.send(['op'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: address }); + const first = returning('first'); + request(first); + await settle(); + a.send(['op'], { frame: { version: 1, kind: 'cancel', id: 'c:1' }, return: first }); + // The handler answers before the queued cancellation has drained. + held!.return!.wire.send([], { frame: { version: 1, kind: 'response', id: 'c:1', result: 'one' } }); + request(returning('second')); + await settle(); + request(returning('third')); + await settle(); + held!.return!.wire.send([], { frame: { version: 1, kind: 'response', id: 'c:1', result: 'three' } }); + await settle(); + assert.deepEqual(answers, ['first:one', 'second:busy', 'third:three']); + a.close(); +}); + +test('R26: a closed pair reports the one closed classification', async () => { + const [a, b] = pair(); + a.close(); + await settle(); + const event: Message = { frame: { version: 1, kind: 'event', data: null } }; + assert.throws(() => a.send(['x'], event), { code: 'disconnected' }); + assert.throws(() => b.receive({}), { code: 'disconnected' }); + await assert.rejects(call(a, ['x']), (error: unknown) => error instanceof UnpublishedError && error.code === 'disconnected'); +}); + +test('R27: a pair closed with an observe-only code tells its receivers that code', async () => { + const [a, b] = pair(); + const endings: [number, string][] = []; + a.receive({ closed: (code, reason) => void endings.push([code, reason]) }); + b.receive({ closed: (code, reason) => void endings.push([code, reason]) }); + // Nothing is transmitted in-process, so the pair closes as an abort would. + a.close(1006, 'observed'); + await settle(); + assert.deepEqual(endings, [ + [1006, 'observed'], + [1006, 'observed'], + ]); +}); diff --git a/core/ts/test/unicode.test.ts b/core/ts/test/unicode.test.ts new file mode 100644 index 0000000..87f592f --- /dev/null +++ b/core/ts/test/unicode.test.ts @@ -0,0 +1,40 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/unicode.test.ts. The rows of +// vectors/bitwire-1/unicode.json are judged at the raw frame boundary exactly +// as v0.6.0 judges them. v0.6.0 also held each row to its family validator +// (validate.ts), which is not part of this port; the runtime's own outgoing +// value guard is held to the same rows in its place. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { decodeEnvelope } from '../src/internal/envelope.ts'; +import { pair } from '../src/index.ts'; +import { emit } from '../../../dispatch/ts/src/index.ts'; + +const table = JSON.parse( + readFileSync(new URL('../../../vectors/bitwire-1/unicode.json', import.meta.url), 'utf8'), +) as { rows: { name: string; raw: string; valid: boolean }[] }; + +test('Unicode scalar input domain is shared before decoding', () => { + for (const row of table.rows) { + const checkFrame = (): void => { + decodeEnvelope('{"version":1,"kind":"event","event":"probe","data":' + row.raw + '}', 's:', 'c:'); + }; + if (row.valid) assert.doesNotThrow(checkFrame, row.name); + else assert.throws(checkFrame, /invalid Unicode: expected Unicode scalar strings/, row.name); + } +}); + +test('an outgoing value is held to the same Unicode rows before admission', (t) => { + const [a, b] = pair(); + t.after(() => a.close()); + b.receive({ message: () => {} }); + for (const row of table.rows) { + // A duplicate overwritten value is already lost after JSON.parse; it is + // held at the raw frame boundary above rather than reconstructed here. + if (row.name === 'overwritten malformed value') continue; + const value: unknown = JSON.parse(row.raw); + const check = (): void => emit(a, ['probe'], value); + if (row.valid) assert.doesNotThrow(check, row.name); + else assert.throws(check, { code: 'invalid_message' }, row.name); + } +}); diff --git a/dispatch/ts/test/dispatcher-ownership.test.ts b/dispatch/ts/test/dispatcher-ownership.test.ts new file mode 100644 index 0000000..d3cec53 --- /dev/null +++ b/dispatch/ts/test/dispatcher-ownership.test.ts @@ -0,0 +1,154 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/dispatcher-ownership.test.ts. +// A path a mount has no child for is refused with MissingPathError +// ('missing_path', was the duplex wire error 'no_route'). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Endpoint, Message, Path, Receiver } from '@bitspark/bitwire'; +import { mount, pair } from '../../../core/ts/src/index.ts'; +import { createDispatcher, emit } from '../src/index.ts'; + +const settled = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); +const anEvent = (): Message => ({ frame: { version: 1, kind: 'event', data: null } }); + +/** An endpoint that hands a test its one attachment, and nothing else. */ +class Holder implements Endpoint { + private attached: Receiver | undefined; + send(): void {} + receive(receiver: Receiver): () => void { + if (this.attached) throw new Error('receiver_exists'); + this.attached = receiver; + return () => { + if (this.attached === receiver) this.attached = undefined; + }; + } + close(): void { + this.attached = undefined; + } + deliver(path: Path, message: Message): void { + void this.attached?.message?.(path, message); + } +} + +test('an endpoint has one owning attachment', async () => { + const [left, right] = pair(); + try { + const first: Path[] = []; + const second: Path[] = []; + const detach = right.receive({ + message(path) { + first.push(path); + }, + }); + assert.throws(() => right.receive({}), { code: 'receiver_exists' }); + emit(left, ['one']); + await settled(); + assert.deepEqual(first, [['one']]); + detach(); + detach(); + right.receive({ + message(path) { + second.push(path); + }, + }); + emit(left, ['two']); + await settled(); + assert.deepEqual(second, [['two']]); + assert.deepEqual(first, [['one']], 'the detached attachment still received'); + } finally { + left.close(); + } +}); + +test('a dispatcher refuses a duplicate path and frees it on detach', () => { + const dispatch = createDispatcher(new Holder()); + const detach = dispatch.register(['a', 'b'], { message() {} }); + assert.throws(() => dispatch.register(['a', 'b'], {}), { code: 'receiver_exists' }); + // Exact and prefix are separate spaces: one of each may hold a path. + dispatch.registerPrefix(['a', 'b'], {}); + assert.throws(() => dispatch.registerPrefix(['a', 'b'], {}), { code: 'receiver_exists' }); + detach(); + dispatch.register(['a', 'b'], {}); +}); + +test('overlapping routes select the longest, then the exact', () => { + const endpoint = new Holder(); + const dispatch = createDispatcher(endpoint); + const reached: string[] = []; + const name = (label: string): Receiver => ({ + message() { + reached.push(label); + }, + }); + dispatch.registerPrefix([], name('root')); + dispatch.registerPrefix(['a'], name('a')); + dispatch.registerPrefix(['a', 'b'], name('a/b')); + dispatch.register(['a', 'b', 'c'], name('exact a/b/c')); + for (const [path, label] of [ + [['z'], 'root'], + [['a', 'z'], 'a'], + [['a', 'b', 'z'], 'a/b'], + [['a', 'b', 'c'], 'exact a/b/c'], + ] as [Path, string][]) { + reached.length = 0; + endpoint.deliver(path, anEvent()); + assert.deepEqual(reached, [label], `${JSON.stringify(path)}`); + } +}); + +test('a nested selection prepends outgoing and strips incoming', async () => { + const [left, right] = pair(); + try { + const dispatch = createDispatcher(right); + const inner = dispatch.select(['a']).select(['b']); + const delivered: Path[] = []; + inner.receive({ + message(path) { + delivered.push(path); + }, + }); + emit(left, ['a', 'b', 'read']); + await settled(); + assert.deepEqual(delivered, [['read']]); + const back: Path[] = []; + left.receive({ + message(path) { + back.push(path); + }, + }); + emit(inner, ['reply']); + await settled(); + assert.deepEqual(back, [['a', 'b', 'reply']]); + } finally { + left.close(); + } +}); + +test('a mount routes by one segment and borrows its children', async () => { + const [left, right] = pair(); + try { + const root = mount(new Map([['child', right]])); + const delivered: Path[] = []; + root.receive({ + message(path) { + delivered.push(path); + }, + }); + emit(left, ['read']); + await settled(); + assert.deepEqual(delivered, [['child', 'read']]); + assert.throws(() => root.send([], anEvent()), { code: 'missing_path' }); + assert.throws(() => root.send(['absent'], anEvent()), { code: 'missing_path' }); + root.close(); + const after: Path[] = []; + right.receive({ + message(path) { + after.push(path); + }, + }); + emit(left, ['again']); + await settled(); + assert.deepEqual(after, [['again']], 'closing the mount closed its borrowed child'); + } finally { + left.close(); + } +}); diff --git a/dispatch/ts/test/dispatcher.test.ts b/dispatch/ts/test/dispatcher.test.ts new file mode 100644 index 0000000..a1d1207 --- /dev/null +++ b/dispatch/ts/test/dispatcher.test.ts @@ -0,0 +1,71 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/dispatcher.test.ts. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Endpoint, Message, Receiver } from '@bitspark/bitwire'; +import { pair } from '../../../core/ts/src/index.ts'; +import { call, createDispatcher, handle } from '../src/index.ts'; + +test('one dispatcher attachment supports siblings and leaves its borrowed endpoint usable', async () => { + const [left, right] = pair(); + try { + const dispatch = createDispatcher(right); + assert.throws(() => right.receive({}), { code: 'receiver_exists' }); + for (const name of ['a', 'b']) { + const binding = createDispatcher(dispatch.select([name])); + handle(binding, ['read'], () => name); + } + for (const name of ['a', 'b']) assert.equal(await call(left, [name, 'read']), name); + dispatch.close(); + const rebound = createDispatcher(right); + handle(rebound, ['read'], () => 'rebound'); + assert.equal(await call(left, ['read']), 'rebound'); + } finally { + left.close(); + } +}); + +test('an unmanaged invocation is explicitly refused with its original return capability', () => { + let attached: Receiver | undefined; + const endpoint: Endpoint = { + send() {}, + receive(receiver) { + attached = receiver; + return () => {}; + }, + close() {}, + }; + const dispatch = createDispatcher(endpoint); + let called = false; + dispatch.register([], { + message() { + called = true; + }, + }); + let reply: Message | undefined; + // A return capability refuses what it does not implement, as every addressed + // receiver in this profile does; this one carries outcomes and nothing else. + const address = { + wire: { + send(path: readonly string[], message: Message) { + if (path.length) throw new Error('this return capability carries outcomes only'); + reply = message; + }, + }, + }; + attached!.message!([], { frame: { version: 1, kind: 'request', id: 'one', params: null }, return: address }); + assert.equal(called, false); + assert.equal(reply?.frame.kind, 'response'); + if (reply?.frame.kind === 'response') assert.equal(reply.frame.error?.code, 'invalid_message'); +}); + +test('an explicitly owned dispatcher closes its endpoint', () => { + const [left, right] = pair(); + try { + const dispatcher = createDispatcher(right, { ownEndpoint: true }); + dispatcher.close(1002, 'wire event rejected'); + assert.throws(() => right.receive({}), { code: 'disconnected' }); + dispatcher.close(); + } finally { + left.close(); + } +}); diff --git a/engine/ts/test/carriage.test.ts b/engine/ts/test/carriage.test.ts new file mode 100644 index 0000000..242f388 --- /dev/null +++ b/engine/ts/test/carriage.test.ts @@ -0,0 +1,231 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/carriage.test.ts. Handlers and +// calls go through the peer's root with the dispatch helpers. Not ported: the +// test that no meta value reaches an observer (observers are removed). +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { decodeEnvelope } from '../../../core/ts/src/internal/envelope.ts'; +import type { Meta } from '../../../core/ts/src/index.ts'; +import type { WebSocketLike } from '../../../transports/ts/src/index.ts'; +import { + call, + createDispatcher, + emit, + handle, + onEvent, + type EventContext, + type RequestContext, +} from '../../../dispatch/ts/src/index.ts'; +import { Peer } from '../src/index.ts'; + +/** + * The carriage a request and an event may take: what is about the call rather + * than the call. The peer accepts it and keeps it on the decoded envelope, and + * sends what a call's or an event's options gave it โ€” never one of its own. + */ + +/** The socket of peer.test.ts, with only what a carriage assertion needs of it. */ +class Socket extends EventTarget implements WebSocketLike { + readyState = 1; + bufferedAmount = 0; + sent: Record[] = []; + partner?: Socket; + closed?: { code: number; reason: string }; + send(text: string): void { + this.sent.push(JSON.parse(text)); + const partner = this.partner; + if (partner) + queueMicrotask(() => { + if (partner.readyState === 1) partner.receive(text); + }); + } + receive(frame: unknown): void { + this.dispatchEvent( + new MessageEvent('message', { data: typeof frame === 'string' ? frame : JSON.stringify(frame) }), + ); + } + close(code?: number, reason?: string): void { + this.closed ??= { code: code ?? 1005, reason: reason ?? '' }; + this.readyState = 3; + this.dispatchEvent(new Event('close')); + } +} + +/** decodeEnvelope as a peer reads an incoming frame: the remote is the client. */ +function decode(frame: unknown): Record { + return decodeEnvelope(JSON.stringify(frame), 's:', 'c:'); +} + +const REQUEST = { version: 1, kind: 'request', id: 'c:1', method: 'read', params: {} }; +const EVENT = { version: 1, kind: 'event', event: 'updated', data: 1 }; +const RESPONSE = { version: 1, kind: 'response', id: 's:1', result: 1 }; +const CANCEL = { version: 1, kind: 'cancel', id: 'c:1' }; + +test('a request and an event carry meta, and it is kept on the decoded envelope', () => { + const carried = { tenant: 'acme', idempotency: 'k-1' }; + assert.deepEqual(decode({ ...REQUEST, meta: carried }).meta, carried); + assert.deepEqual(decode({ ...EVENT, meta: { cause: 'nightly' } }).meta, { cause: 'nightly' }); + // A carriage with nothing in it is a carriage, and is kept as one. + assert.deepEqual(decode({ ...REQUEST, meta: {} }).meta, {}); + // The members are independent: a frame may carry both, and each arrives whole. + const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + const both = decode({ ...EVENT, meta: { tenant: 'acme' }, traceparent }); + assert.deepEqual(both.meta, { tenant: 'acme' }); + assert.equal(both.traceparent, traceparent); + // A frame carrying none leaves the member absent rather than empty, so the + // emitting half can tell a carriage with nothing in it from no carriage. + assert.equal(Object.hasOwn(decode(REQUEST), 'meta'), false); +}); + +test('meta is refused on a response and a cancel, in any other form, and under the reserved prefix', () => { + const refused: unknown[] = [ + // A response says what it says in its result; a cancel withdraws a call + // rather than making one. + { ...RESPONSE, meta: { tenant: 'acme' } }, + { ...CANCEL, meta: { tenant: 'acme' } }, + // An object of strings, and nothing else. + { ...REQUEST, meta: 'acme' }, + { ...REQUEST, meta: ['acme'] }, + { ...REQUEST, meta: 7 }, + { ...REQUEST, meta: null }, + { ...REQUEST, meta: { attempt: 2 } }, + { ...REQUEST, meta: { tenant: null } }, + { ...EVENT, meta: { live: true } }, + { ...EVENT, meta: { who: { id: 'u1' } } }, + // The namespace the profile keeps for itself, which it fills with nothing + // in this version. + { ...REQUEST, meta: { 'nightseam.deadline': '2026-01-01T00:00:00Z' } }, + { ...EVENT, meta: { 'nightseam.cause': 'nightly' } }, + ]; + for (const frame of refused) { + assert.throws(() => decode(frame), JSON.stringify(frame)); + } +}); + +test('every row of the conformance table is judged as the table judges it', () => { + const table = JSON.parse( + readFileSync(new URL('../../../vectors/bitwire-1/frames.json', import.meta.url), 'utf8'), + ) as { + rows: { name: string; to: string; frame: string; valid: boolean }[]; + }; + let carriages = 0; + for (const row of table.rows) { + // A row addressed to the server carries the client's ids and answers the + // server's; one addressed to either is read as a server's. + const [local, remote] = row.to === 'client' ? ['c:', 's:'] : ['s:', 'c:']; + let accepted = true; + try { + decodeEnvelope(row.frame, local, remote); + } catch { + accepted = false; + } + assert.equal(accepted, row.valid, row.name); + let members: unknown; + try { + members = JSON.parse(row.frame); + } catch { + continue; + } + if (typeof members === 'object' && members !== null && Object.hasOwn(members, 'meta')) carriages++; + } + assert.ok( + table.rows.length >= 70 && carriages >= 12, + `the table holds ${table.rows.length} rows and names meta in ${carriages}; the wire is held by more than that`, + ); +}); + +test('a frame with a refused meta closes the connection with 4011', async () => { + const socket = new Socket(); + const peer = new Peer({ role: 'server' }); + await peer.attach(socket); + socket.receive({ ...REQUEST, meta: { 'nightseam.cause': 'nightly' } }); + assert.equal(peer.status, 'disconnected'); + assert.equal(socket.closed?.code, 4011); +}); + +/** Two peers over one pair of sockets, as peer.test.ts pairs them. */ +async function pair() { + const clientSocket = new Socket(); + const serverSocket = new Socket(); + clientSocket.partner = serverSocket; + serverSocket.partner = clientSocket; + const client = new Peer({ role: 'client' }); + const server = new Peer({ role: 'server' }); + await client.attach(clientSocket); + await server.attach(serverSocket); + return { client, server, clientSocket }; +} + +test('a call and an event carry the meta their options gave, and a bare one carries none', async (t) => { + const { client, server, clientSocket } = await pair(); + t.after(() => { + client.close(); + server.close(); + }); + handle(createDispatcher(server.wire()), ['read'], () => 1); + const carried = { tenant: 'acme', idempotency: 'k-1' }; + await call(client.wire(), ['read'], {}, { meta: carried }); + emit(client.wire(), ['updated'], 1, { meta: { cause: 'nightly' } }); + emit(client.wire(), ['updated'], 1); + // A key of the reserved prefix is the profile's; the option drops it rather + // than sending a frame the far peer would refuse. + emit(client.wire(), ['updated'], 1, { meta: { 'nightseam.cause': 'nightly', tenant: 'acme' } }); + await tick(); + const sent = clientSocket.sent; + assert.deepEqual(sent.find((frame) => frame.kind === 'request')?.meta, carried); + const events = sent.filter((frame) => frame.kind === 'event'); + assert.deepEqual(events[0]?.meta, { cause: 'nightly' }); + assert.equal(Object.hasOwn(events[1] ?? {}, 'meta'), false, 'a bare event carries the member nowhere'); + assert.deepEqual(events[2]?.meta, { tenant: 'acme' }); + // A carriage left with nothing in it is not sent at all. + emit(client.wire(), ['updated'], 1, { meta: { 'nightseam.cause': 'nightly' } }); + await tick(); + const last = sent.filter((frame) => frame.kind === 'event').at(-1); + assert.equal(Object.hasOwn(last ?? {}, 'meta'), false); +}); + +test('a handler reads the meta of its frame and forwards nothing of itself', async (t) => { + const { client, server } = await pair(); + t.after(() => { + client.close(); + server.close(); + }); + const nested: (Meta | undefined)[] = []; + const delivered: (Meta | undefined)[] = []; + const clients = createDispatcher(client.wire()); + const servers = createDispatcher(server.wire()); + handle(clients, ['reverse'], (_params, context: RequestContext) => { + nested.push(context.meta); + return 'back'; + }); + // Reads its own meta, then calls back without saying to forward it. + handle(servers, ['read'], async (_params, context: RequestContext) => { + await call(context.wire, ['reverse']); + return context.meta ?? null; + }); + // Reads its own meta, then forwards it as a handler must say to. + handle(servers, ['relay'], async (_params, context: RequestContext) => { + await call(context.wire, ['reverse'], {}, { meta: context.meta }); + return null; + }); + onEvent(servers, ['updated'], (_data, context: EventContext) => { + delivered.push(context.meta); + }); + + const carried = { tenant: 'acme' }; + assert.deepEqual(await call(client.wire(), ['read'], {}, { meta: carried }), carried); + assert.equal(nested.at(-1), undefined, "a call from the handler carried the caller's meta of its own accord"); + await call(client.wire(), ['relay'], {}, { meta: carried }); + assert.deepEqual(nested.at(-1), carried); + + emit(client.wire(), ['updated'], 1, { meta: { cause: 'nightly' } }); + await tick(); + assert.deepEqual(delivered.at(-1), { cause: 'nightly' }); + emit(client.wire(), ['updated'], 1); + await tick(); + assert.equal(delivered.at(-1), undefined, 'a listener of a bare event reads none'); +}); + +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/engine/ts/test/peer-pacing.test.ts b/engine/ts/test/peer-pacing.test.ts new file mode 100644 index 0000000..2c14cfd --- /dev/null +++ b/engine/ts/test/peer-pacing.test.ts @@ -0,0 +1,107 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/peer-pacing.test.ts. The peer +// paces only what it sends of its own accord โ€” a response โ€” for one write +// deadline; what the root hands it is admitted or refused at once. Not ported, +// because they held the pacing of the removed raw emit and call: "public emit +// waits for room while a shorter caller wrapper times out before the write +// deadline", "a paced public emit enters in order when the transport drains +// within its deadline" and "a paced raw call retains its own timeout and +// cancellation without publishing afterward". +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { forward, pair, PublicError } from '../../../core/ts/src/index.ts'; +import { pipe, type Frame, type FrameConnection } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, emit, handle } from '../../../dispatch/ts/src/index.ts'; +import { Peer } from '../src/index.ts'; + +function heldConnection() { + const [a, b] = pipe(); + let buffered = 1; + const sent: Frame[] = []; + const connection: FrameConnection = { + get state() { + return a.state; + }, + get buffered() { + return buffered; + }, + send(frame) { + sent.push(frame); + a.send(frame); + }, + close: (code, reason) => a.close(code, reason), + listen: (receiver) => a.listen(receiver), + }; + return { + connection, + sent, + receive: (frame: Frame) => b.send(frame), + drain: () => { + buffered = 0; + }, + close: () => { + a.close(); + b.close(); + }, + }; +} + +test('a public raw response waits for queue room and then follows the accepted event', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const held = heldConnection(); + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 1000 }); + t.after(() => { + peer.close(); + held.close(); + }); + handle(createDispatcher(peer.wire()), ['echo'], (value) => value); + await peer.attach(held.connection); + emit(peer.wire(), ['accepted']); + await Promise.resolve(); + held.receive({ + kind: 'text', + data: JSON.stringify({ version: 1, kind: 'request', id: 's:1', method: '4:echo', params: 7 }), + }); + await nextTurn(); + assert.equal(peer.status, 'connected'); + held.drain(); + t.mock.timers.tick(5); + await nextTurn(); + assert.deepEqual( + held.sent.map((frame) => (frame.kind === 'text' ? JSON.parse(frame.data).kind : null)), + ['event', 'response'], + ); + assert.equal(JSON.parse((held.sent[1] as { data: string }).data).result, 7); +}); + +test('a composed wire handoff still refuses a full physical queue immediately and ends only its destination', async (t) => { + const held = heldConnection(); + const destination = new Peer({ queueCapacity: 1, writeTimeoutMs: 5000 }); + const [caller, forwarding] = pair(); + const detach = forward(forwarding, destination.wire()); + t.after(() => { + detach(); + caller.close(); + forwarding.close(); + destination.close(); + held.close(); + }); + await destination.attach(held.connection); + emit(destination.wire(), ['accepted']); + await Promise.resolve(); + const refused = await Promise.race([ + call(caller, ['refused']).catch((error: unknown) => error), + nextTurn().then(() => 'waited'), + ]); + // R26: the overflow ended the destination, and a forwarded request whose + // destination ended is answered disconnected (v0.6.0: busy). + assert.ok(refused instanceof PublicError); + assert.equal(refused.code, 'disconnected'); + assert.equal(destination.status, 'disconnected'); + detach(); + const dispatcher = createDispatcher(forwarding); + t.after(() => dispatcher.close()); + handle(dispatcher, ['healthy'], () => 'still open'); + assert.equal(await call(caller, ['healthy']), 'still open'); + assert.equal(held.sent.length, 0); +}); diff --git a/engine/ts/test/peer.test.ts b/engine/ts/test/peer.test.ts new file mode 100644 index 0000000..ef463d0 --- /dev/null +++ b/engine/ts/test/peer.test.ts @@ -0,0 +1,1475 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/peer.test.ts. The peer presents +// the protocol only through its root, wire(): what v0.6.0 served with the raw +// handle and onEvent is served here by a dispatcher on the root, what it sent +// with the raw call and emit goes through the root with call and emit, and a +// raw frame names a path by its canonical encoding ('4:wait' for ['wait']). +// +// Not ported: tests of the observer alone โ€” "no payload reaches an observer", +// "for one call, one event and one close an observer sees the events in +// order", "a handler that throws yields handler.panic", "an outcome is what +// ended the call", "an observer that throws interrupts no routing" and "a +// frame is observed sent immediately before its bytes reach the transport" โ€” +// and the observer's assertions in the tests kept below, each named where it +// stood. Every other change is noted where it stands. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import type { Message } from '@bitspark/bitwire'; +import { + forward, + InvalidPathError, + pair, + PublicError, + UnpublishedError, +} from '../../../core/ts/src/index.ts'; +import { decodeEnvelope } from '../../../core/ts/src/internal/envelope.ts'; +import { positiveInteger } from '../../../core/ts/src/internal/limits.ts'; +import { encodePath } from '../../../core/ts/src/internal/path.ts'; +import { + pipe, + webSocketConnection, + type ConnectionHandlers, + type ConnectionState, + type Frame, + type FrameConnection, + type WebSocketLike, +} from '../../../transports/ts/src/index.ts'; +import { + call, + createDispatcher, + emit, + handle, + onEvent, + type RequestContext, +} from '../../../dispatch/ts/src/index.ts'; +import { Peer, type PeerOptions } from '../src/index.ts'; + +/** The name a frame carries for a path of one segment. */ +const name = (segment: string): string => encodePath([segment]); + +test('component limits share validation while the peer keeps its safe-integer bound', () => { + for (const safe of [false, true]) { + assert.equal(positiveInteger(32, 'window', safe), 32); + for (const value of [undefined, null, '32', 0, -1, 1.5, NaN, Infinity]) { + assert.throws(() => positiveInteger(value, 'window', safe), { + // The public error class is PublicError (v0.6.0: DuplexError). + name: 'PublicError', + code: 'invalid_options', + message: `window must be a positive ${safe ? 'safe ' : ''}integer.`, + }); + } + } + assert.equal(positiveInteger(Number.MAX_SAFE_INTEGER + 1, 'window'), Number.MAX_SAFE_INTEGER + 1); + assert.throws(() => positiveInteger(Number.MAX_SAFE_INTEGER + 1, 'timeoutMs', true), { + code: 'invalid_options', + message: 'timeoutMs must be a positive safe integer.', + }); +}); + +class Socket extends EventTarget implements WebSocketLike { + readyState = 1; + bufferedAmount = 0; + /** What the handshake selected, as a real WebSocket spells it. */ + protocol = ''; + sent: Record[] = []; + partner?: Socket; + closeCount = 0; + send(text: string): void { + if (this.readyState !== 1) throw new Error('Closed'); + this.sent.push(JSON.parse(text)); + const partner = this.partner; + if (partner) + queueMicrotask(() => { + if (partner.readyState === 1) partner.receive(text); + }); + } + receive(frame: unknown): void { + this.dispatchEvent( + new MessageEvent('message', { data: typeof frame === 'string' ? frame : JSON.stringify(frame) }), + ); + } + close(): void { + this.closeCount++; + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event('close')); + this.partner?.close(); + } +} + +/** An in-memory frames duplex connection; no WebSocket is involved anywhere. */ +class Pipe implements FrameConnection { + state: ConnectionState = 'open'; + readonly buffered = 0; + partner!: Pipe; + readonly frames: Frame[] = []; + private readonly listeners = new Set(); + static pair(): [Pipe, Pipe] { + const left = new Pipe(); + const right = new Pipe(); + left.partner = right; + right.partner = left; + return [left, right]; + } + send(frame: Frame): void { + if (this.state !== 'open') throw new Error('Not open'); + this.frames.push(frame); + const partner = this.partner; + queueMicrotask(() => { + if (partner.state === 'open') for (const handlers of [...partner.listeners]) handlers.frame?.(frame); + }); + } + close(code = 1000, reason = ''): void { + if (this.state === 'closed') return; + this.state = 'closed'; + for (const handlers of [...this.listeners]) handlers.close?.(code, reason); + this.partner.close(code, reason); + } + listen(handlers: ConnectionHandlers): () => void { + this.listeners.add(handlers); + return () => { + this.listeners.delete(handlers); + }; + } +} + +async function paired(clientOptions: PeerOptions = {}, serverOptions: PeerOptions = {}) { + const left = new Socket(); + const right = new Socket(); + left.partner = right; + right.partner = left; + const client = new Peer(clientOptions); + const server = new Peer({ ...serverOptions, role: 'server' }); + await Promise.all([client.attach(left), server.attach(right)]); + const clients = createDispatcher(client.wire()); + const servers = createDispatcher(server.wire()); + return { client, server, left, right, clients, servers }; +} + +test('peer refuses malformed outgoing Unicode without losing valid strings', async (t) => { + const { client, servers, left } = await paired(); + t.after(() => client.close()); + handle(servers, ['echo'], (value) => value); + handle(servers, ['bad'], () => '\uD800'); + for (const value of ['\uD800', { x: ['\uDC00'] }, { ['\uD800']: 1 }, { toJSON: () => '\uD800' }]) { + assert.throws(() => emit(client.wire(), ['probe'], value), { code: 'invalid_message' }); + await assert.rejects(call(client.wire(), ['echo'], value), { code: 'invalid_message' }); + } + // An event's name is a path now, and a malformed segment is refused as one + // before anything is admitted (v0.6.0's raw emit: invalid_message). + assert.throws( + () => emit(client.wire(), ['\uD800'], null), + (error: unknown) => error instanceof UnpublishedError && error.cause instanceof InvalidPathError, + ); + assert.throws(() => emit(client.wire(), ['probe'], null, { meta: { x: '\uD800' } }), { code: 'invalid_message' }); + await settled(); + assert.equal(left.sent.length, 0); + await assert.rejects(call(client.wire(), ['bad']), { code: 'internal' }); + assert.equal(await call(client.wire(), ['echo'], '๐Ÿ˜€๏ฟฝ'), '๐Ÿ˜€๏ฟฝ'); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + +/** Lets whatever a frame set going run: the handlers, the queues, the writer. */ +function settled(): Promise { + return new Promise((resolve) => setTimeout(resolve, 10)); +} + +/** Waits until a condition holds, for what a frame sets going asynchronously. */ +async function eventually(ready: () => boolean): Promise { + for (let waited = 0; waited < 200 && !ready(); waited++) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(ready(), 'the condition never held'); +} + +test('duplex routing permits reverse calls during an outstanding request', async (t) => { + const { client, clients, servers, left, right } = await paired(); + t.after(() => client.close()); + handle(clients, ['multiply'], (params) => (params as { value: number }).value * 3); + handle(servers, ['roundtrip'], async (_params, context) => ({ + value: await call(context.wire, ['multiply'], { value: 7 }), + })); + assert.deepEqual(await call(client.wire(), ['roundtrip']), { value: 21 }); + assert.equal(left.sent[0]!.id, 'c:1'); + assert.equal(right.sent[0]!.id, 's:1'); + assert.equal(left.sent[1]!.kind, 'response'); +}); + +test('responses correlate out of order while events run independently', async (t) => { + const { client, server, clients, servers } = await paired(); + t.after(() => client.close()); + const first = deferred(); + const blockEvent = deferred(); + const eventStarted = deferred(); + handle(servers, ['first'], () => first.promise); + handle(servers, ['second'], () => 2); + onEvent(clients, ['notice'], async () => { + eventStarted.resolve(); + await blockEvent.promise; + }); + const one = call(client.wire(), ['first']); + emit(server.wire(), ['notice'], { value: 1 }); + await eventStarted.promise; + assert.equal(await call(client.wire(), ['second']), 2); + first.resolve(1); + assert.equal(await one, 1); + blockEvent.resolve(); +}); + +test('public handler errors survive and unexpected errors remain private', async (t) => { + const { client, servers } = await paired(); + t.after(() => client.close()); + handle(servers, ['public'], () => { + throw new PublicError('denied', 'Access denied', { field: 'project' }); + }); + handle(servers, ['private'], () => { + throw new Error('Database password: secret'); + }); + await assert.rejects(call(client.wire(), ['public']), { + code: 'denied', + message: 'Access denied', + data: { field: 'project' }, + }); + await assert.rejects(call(client.wire(), ['private']), { code: 'internal', message: 'Request handler failed.' }); + await assert.rejects(call(client.wire(), ['missing']), { code: 'method_not_found' }); +}); + +test('AbortSignal sends cancellation and aborts the remote handler', async (t) => { + const { client, servers, left } = await paired(); + t.after(() => client.close()); + const started = deferred(); + const aborted = deferred(); + handle( + servers, + ['wait'], + (_params, context) => + new Promise((resolve) => { + context.signal.addEventListener( + 'abort', + () => { + aborted.resolve(); + resolve('ignored late result'); + }, + { once: true }, + ); + started.resolve(); + }), + ); + const controller = new AbortController(); + const pending = call(client.wire(), ['wait'], {}, { signal: controller.signal }); + const failure = assert.rejects(pending, { code: 'cancelled' }); + await started.promise; + controller.abort(); + await failure; + await aborted.promise; + assert.equal(left.sent.at(-1)?.kind, 'cancel'); + assert.equal(client.status, 'connected'); +}); + +test('a cancel answers nothing itself: the response is the handler returning, and it is cancelled', async (t) => { + // Not ported: the observer's request.ended assertions. + const { client, server, servers, right } = await paired(); + t.after(() => { + client.close(); + server.close(); + }); + const started = deferred(); + const release = deferred(); + // A handler that ignores its signal. The cancel withdraws the request; what + // answers it is this returning, whenever it does, as the profile says and + // the Go peer does. + handle(servers, ['deaf'], () => { + started.resolve(); + return release.promise; + }); + const controller = new AbortController(); + const pending = assert.rejects(call(client.wire(), ['deaf'], {}, { signal: controller.signal }), { + code: 'cancelled', + }); + await started.promise; + controller.abort(); + await pending; + await settled(); + // The caller has given up and nothing has answered the request, because + // nothing has finished it. + assert.deepEqual( + right.sent.filter((frame) => frame.kind === 'response'), + [], + ); + release.resolve('a result nobody is waiting for'); + await settled(); + const responses = right.sent.filter((frame) => frame.kind === 'response'); + assert.equal(responses.length, 1); + assert.equal(responses[0]!.id, 'c:1'); + assert.deepEqual(responses[0]!.error, { code: 'cancelled', message: 'Request was cancelled.' }); +}); + +test('pre-aborted calls and outstanding capacity do not send extra requests', async (t) => { + const { client, servers, left } = await paired({ maxPendingRequests: 1 }); + t.after(() => client.close()); + const blocked = deferred(); + handle(servers, ['wait'], () => blocked.promise); + const waiting = call(client.wire(), ['wait']); + const failed = assert.rejects(waiting, { code: 'disconnected' }); + await assert.rejects(call(client.wire(), ['overflow']), { code: 'busy' }); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(call(client.wire(), ['cancelled'], {}, { signal: controller.signal }), { code: 'cancelled' }); + await settled(); + assert.equal(left.sent.length, 1); + client.close(); + blocked.resolve(); + await failed; +}); + +test('local request deadline cancels remotely without retrying', async (t) => { + const { client, servers, left } = await paired(); + t.after(() => client.close()); + handle( + servers, + ['wait'], + (_params, context) => + new Promise((resolve) => { + context.signal.addEventListener('abort', () => resolve(null), { once: true }); + }), + ); + await assert.rejects(call(client.wire(), ['wait'], {}, { timeoutMs: 10 }), { code: 'request_timeout' }); + await settled(); + assert.deepEqual( + left.sent.map((frame) => frame.kind), + ['request', 'cancel'], + ); +}); + +test('incoming deadlines abort handlers and retain occupied slots until completion', async (t) => { + const { client, servers } = await paired({}, { maxConcurrentHandlers: 1, requestTimeoutMs: 10 }); + t.after(() => client.close()); + const blocked = deferred(); + let signal: AbortSignal | undefined; + handle(servers, ['wait'], (_params, context) => { + signal = context.signal; + return blocked.promise; + }); + // The receiver's own deadline abandons the request, and `cancelled` is what + // it answers: `request_timeout` is a caller's own error and never a frame. + await assert.rejects(call(client.wire(), ['wait']), { code: 'cancelled' }); + assert.equal(signal?.aborted, true); + await assert.rejects(call(client.wire(), ['wait']), { code: 'busy' }); + blocked.resolve(); +}); + +test('disconnect cancels handlers and rejects pending calls without reconnecting', async () => { + const { client, server, servers, left } = await paired(); + const started = deferred(); + const aborted = deferred(); + handle( + servers, + ['wait'], + (_params, context) => + new Promise((resolve) => { + context.signal.addEventListener( + 'abort', + () => { + aborted.resolve(); + resolve(1); + }, + { once: true }, + ); + started.resolve(); + }), + ); + const waiting = call(client.wire(), ['wait']); + const failed = assert.rejects(waiting, { code: 'disconnected' }); + await started.promise; + client.close(); + await Promise.all([failed, aborted.promise]); + assert.equal(client.status, 'disconnected'); + assert.equal(server.status, 'disconnected'); + assert.equal(left.sent.filter((frame) => frame.kind === 'request').length, 1); + // The root refuses a call on an ended peer with the one closed + // classification (v0.6.0's raw call: not_connected). + await assert.rejects(call(client.wire(), ['another']), { code: 'disconnected' }); +}); + +test('incoming saturation responds busy without blocking responses', async (t) => { + const { client, server, clients, servers } = await paired({}, { maxConcurrentHandlers: 1 }); + t.after(() => client.close()); + const occupied = deferred(); + const started = deferred(); + handle(servers, ['wait'], () => { + started.resolve(); + return occupied.promise; + }); + handle(clients, ['ping'], () => 'pong'); + const first = call(client.wire(), ['wait']); + await started.promise; + await assert.rejects(call(client.wire(), ['wait']), { code: 'busy' }); + assert.equal(await call(server.wire(), ['ping']), 'pong'); + occupied.resolve(); + assert.equal(await first, null); +}); + +test('wire output overflow ends the carrier without waiting for the socket to drain', async (t) => { + const socket = new Socket(); + socket.bufferedAmount = 1; + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 5_000 }); + t.after(() => peer.close()); + await peer.attach(socket); + // The first is accepted for sending, which is queued and no more. + emit(peer.wire(), ['first']); + await Promise.resolve(); + // The consumer remains blocked. Admission must settle before another turn, + // independently of the much longer transport write deadline. + const ended = deferred(); + peer.onClose(ended.resolve); + emit(peer.wire(), ['second']); + const outcome = await Promise.race([ + ended.promise.then((error) => { + assert.equal(error.code, 'busy'); + return 'refused'; + }), + nextTurn().then(() => 'waited'), + ]); + assert.equal(outcome, 'refused'); + assert.equal(peer.status, 'disconnected'); + assert.equal(socket.closeCount, 1); + assert.equal(socket.sent.length, 0); +}); + +test('the accepted output prefix drains in order within its bound', async (t) => { + for (const capacity of [2, 8]) { + await t.test(`capacity ${capacity}`, async (t) => { + const socket = new Socket(); + socket.bufferedAmount = 1; + const drained = deferred(); + const send = socket.send.bind(socket); + socket.send = (text) => { + send(text); + if (socket.sent.length === capacity) drained.resolve(); + }; + const peer = new Peer({ queueCapacity: capacity, writeTimeoutMs: 5_000 }); + t.after(() => peer.close()); + await peer.attach(socket); + for (let sequence = 0; sequence < capacity; sequence++) emit(peer.wire(), ['item'], sequence); + await nextTurn(); + assert.equal(socket.sent.length, 0); + // Every emit already completed while the destination remained held. + socket.bufferedAmount = 0; + await drained.promise; + assert.equal(peer.status, 'connected'); + assert.deepEqual( + socket.sent.map((frame) => frame.data), + Array.from({ length: capacity }, (_, i) => i), + ); + emit(peer.wire(), ['marker'], capacity); + await nextTurn(); + assert.equal(socket.sent.at(-1)?.data, capacity); + }); + } +}); + +test('a pre-aborted call does not attempt admission to a full output queue', async (t) => { + const socket = new Socket(); + socket.bufferedAmount = 1; + const drained = deferred(); + const send = socket.send.bind(socket); + socket.send = (text) => { + send(text); + drained.resolve(); + }; + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 5_000 }); + t.after(() => peer.close()); + await peer.attach(socket); + emit(peer.wire(), ['accepted']); + await Promise.resolve(); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(call(peer.wire(), ['unadmitted'], {}, { signal: controller.signal }), { code: 'cancelled' }); + assert.equal(peer.status, 'connected'); + assert.equal(socket.closeCount, 0); + assert.equal(socket.sent.length, 0); + socket.bufferedAmount = 0; + await drained.promise; + assert.deepEqual( + socket.sent.map((frame) => frame.event), + [name('accepted')], + ); +}); + +test('a sent call cancels promptly when its best-effort cancellation cannot be queued', async (t) => { + const { client, server, servers, left } = await paired({ queueCapacity: 1, writeTimeoutMs: 5_000 }); + t.after(() => client.close()); + const started = deferred(); + handle(servers, ['wait'], (_params, context) => { + started.resolve(); + return new Promise((resolve) => context.signal.addEventListener('abort', () => resolve(null), { once: true })); + }); + const controller = new AbortController(); + const cancelled = assert.rejects(call(client.wire(), ['wait'], {}, { signal: controller.signal }), { + code: 'cancelled', + }); + await started.promise; + left.bufferedAmount = 1; + emit(client.wire(), ['accepted']); + await Promise.resolve(); + controller.abort(); + const outcome = await Promise.race([cancelled.then(() => 'cancelled'), nextTurn().then(() => 'waited')]); + assert.equal(outcome, 'cancelled'); + assert.equal(client.status, 'connected'); + assert.equal(server.status, 'connected'); + assert.deepEqual( + left.sent.map((frame) => frame.kind), + ['request'], + ); +}); + +test('an emit over a transport that never drains resolves anyway, and the write deadline still ends the connection', async () => { + // What an emit promises is that the frame was accepted for sending, as the + // profile says and the Go peer returns: queued for this connection, with + // the drain and its deadline continuing behind the caller. + const socket = new Socket(); + socket.bufferedAmount = 1; + const peer = new Peer({ writeTimeoutMs: 10 }); + const closed = deferred(); + peer.onClose(closed.resolve); + await peer.attach(socket); + emit(peer.wire(), ['blocked']); + await Promise.resolve(); + assert.equal(peer.status, 'connected'); + assert.equal(socket.sent.length, 0); + assert.equal((await closed.promise).code, 'write_timeout'); + assert.equal(peer.status, 'disconnected'); +}); + +test('event queues are bounded and a slow listener is paced, then disconnected', async () => { + const socket = new Socket(); + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 40 }); + const closed = deferred(); + peer.onClose(closed.resolve); + await peer.attach(socket); + const blocked = deferred(); + peer.wire().receive({ message: () => blocked.promise }); + socket.receive({ version: 1, kind: 'event', event: name('one'), data: {} }); + socket.receive({ version: 1, kind: 'event', event: name('two'), data: {} }); + // A full queue is a burst until its deadline passes. This listener never + // returns, so its own deadline is the first to pass and names what stalled; + // the queue's deadline behind it is the backstop for a consumer that does + // return, only never fast enough. + assert.equal(peer.status, 'connected'); + assert.equal((await closed.promise).code, 'stalled_consumer'); + assert.equal(peer.status, 'disconnected'); + blocked.resolve(); +}); + +test('a producer that outruns its consumer for a whole deadline is a stalled consumer', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const socket = new Socket(); + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 40 }); + const closed = deferred(); + peer.onClose(closed.resolve); + await peer.attach(socket); + // The first listener finishes before its deadline and the second is still + // within its deadline when the backlog gives out. Advance a controlled + // clock so that scheduler load cannot make the listener lose that race. + const first = deferred(); + const second = deferred(); + let calls = 0; + peer.wire().receive({ message: () => (++calls === 1 ? first.promise : second.promise) }); + t.after(() => { + first.resolve(); + second.resolve(); + peer.close(); + }); + for (let i = 0; i < 20; i++) socket.receive({ version: 1, kind: 'event', event: name(`burst-${i}`), data: {} }); + t.mock.timers.tick(20); + first.resolve(); + await nextTurn(); + assert.equal(calls, 2); + // The backlog has now lasted 40 ms; neither listener has reached its own deadline. + t.mock.timers.tick(20); + assert.equal((await closed.promise).code, 'busy'); +}); + +test('an event burst that drains within the deadline is paced, not disconnected', async (t) => { + const socket = new Socket(); + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 1_000 }); + await peer.attach(socket); + const held = deferred(); + const drained = deferred(); + t.after(() => { + held.resolve(); + peer.close(); + }); + const delivered: string[] = []; + peer.wire().receive({ + message: async (path) => { + await held.promise; + delivered.push(path[0]!); + if (delivered.length === 2) drained.resolve(); + }, + }); + socket.receive({ version: 1, kind: 'event', event: name('one'), data: {} }); + socket.receive({ version: 1, kind: 'event', event: name('two'), data: {} }); + held.resolve(); + // The backlog clears inside the deadline, so the burst was a burst: both + // events arrive in order and the connection is whole. + await drained.promise; + assert.deepEqual(delivered, ['one', 'two']); + assert.equal(peer.status, 'connected'); +}); + +test('stalled asynchronous event listener closes the peer', async () => { + const socket = new Socket(); + const peer = new Peer({ writeTimeoutMs: 10 }); + const closed = deferred(); + const blocked = deferred(); + peer.onClose(closed.resolve); + peer.wire().receive({ message: () => blocked.promise }); + await peer.attach(socket); + socket.receive({ version: 1, kind: 'event', event: name('one'), data: {} }); + assert.equal((await closed.promise).code, 'stalled_consumer'); + blocked.resolve(); +}); + +test('large replay bursts do not queue already completed synchronous listeners', async (t) => { + const socket = new Socket(); + const peer = new Peer(); + await peer.attach(socket); + t.after(() => peer.close()); + const received: number[] = []; + onEvent(createDispatcher(peer.wire()), ['replay'], (value) => { + received.push(value as number); + }); + for (let sequence = 1; sequence <= 300; sequence++) { + socket.receive({ version: 1, kind: 'event', event: name('replay'), data: sequence }); + } + assert.equal(peer.status, 'connected'); + assert.deepEqual( + received, + Array.from({ length: 300 }, (_, index) => index + 1), + ); +}); + +test('event subscriptions preserve order, isolate failures, and unsubscribe', async (t) => { + // v0.6.0 held two raw listeners of one name, the first of which threw. A + // path has one receiver now, so the one receiver throws on the first event: + // the failure is reported, the next event is still delivered, in order, and + // nothing is delivered once it is detached. + const errors: string[] = []; + const { client, server, clients } = await paired({ onError: (error) => errors.push(error.code) }); + t.after(() => client.close()); + const observed: unknown[] = []; + const done = deferred(); + const off = clients.register(['notice'], { + message: (_path, message) => { + if (message.frame.kind !== 'event') return; + observed.push(message.frame.data); + if (observed.length === 1) throw new Error('listener failure'); + if (observed.length === 2) done.resolve(); + }, + }); + emit(server.wire(), ['notice'], 1); + emit(server.wire(), ['notice'], 2); + await done.promise; + off(); + emit(server.wire(), ['notice'], 3); + await settled(); + assert.deepEqual(observed, [1, 2]); + assert.equal(errors[0], 'event_handler_failed'); +}); + +test('malformed envelopes, binary messages, opposite IDs, and oversize frames close the peer', async () => { + const invalid: unknown[] = [ + '{}', + '{bad', + '{"version":1,"version":1,"kind":"cancel","id":"c:1"}', + { version: 2, kind: 'event', event: 'notice', data: null }, + { version: 1, kind: 'request', id: 'c:1', method: 'x', params: {} }, + { version: 1, kind: 'response', id: 'c:1', result: 1, error: { code: 'bad', message: 'bad' } }, + { version: 1, kind: 'event', event: 'x', data: 1, extra: true }, + // An error is a code and a message and both are non-empty, as the Go peer + // refuses them: a response nobody can read is no answer to a call. + { version: 1, kind: 'response', id: 'c:1', error: { code: 'denied', message: '' } }, + { version: 1, kind: 'response', id: 'c:1', error: { code: '', message: 'Denied' } }, + ]; + for (const frame of invalid) { + const socket = new Socket(); + const peer = new Peer(); + await peer.attach(socket); + socket.receive(frame); + assert.equal(peer.status, 'disconnected', JSON.stringify(frame)); + } + const socket = new Socket(); + const peer = new Peer({ maxFrameBytes: 70 }); + await peer.attach(socket); + socket.receive({ version: 1, kind: 'event', event: 'x', data: 'รฉ'.repeat(30) }); + assert.equal(peer.status, 'disconnected'); + const binary = new Socket(); + const binaryPeer = new Peer(); + await binaryPeer.attach(binary); + binary.dispatchEvent(new MessageEvent('message', { data: new Uint8Array([1]) })); + assert.equal(binaryPeer.status, 'disconnected'); +}); + +test('outgoing oversize or unserializable values reject without sending', async (t) => { + const socket = new Socket(); + const peer = new Peer({ maxFrameBytes: 100 }); + await peer.attach(socket); + t.after(() => peer.close()); + assert.throws(() => emit(peer.wire(), ['large'], 'รฉ'.repeat(100)), { code: 'frame_too_large' }); + await assert.rejects(call(peer.wire(), ['bigint'], 1n), { code: 'invalid_message' }); + await assert.rejects( + call(peer.wire(), ['function'], () => 1), + { code: 'invalid_message' }, + ); + assert.throws(() => emit(peer.wire(), ['nan'], Number.NaN), { code: 'invalid_message' }); + await settled(); + assert.equal(socket.sent.length, 0); + assert.equal(peer.status, 'connected'); +}); + +test('connection requires an explicit ws/wss endpoint and never sets browser headers', async (t) => { + const socket = new Socket(); + socket.readyState = 0; + let observedURL = ''; + const peer = new Peer({ + webSocketFactory: (url) => { + observedURL = url; + return socket; + }, + }); + t.after(() => peer.close()); + await assert.rejects(peer.connect('/api'), { code: 'invalid_url' }); + await assert.rejects(peer.connect('https://localhost/api'), { code: 'invalid_url' }); + await assert.rejects(peer.connect('ws://user:password@localhost/api'), { code: 'invalid_url' }); + const connecting = peer.connect('ws://localhost/api'); + assert.equal(peer.status, 'connecting'); + socket.readyState = 1; + socket.dispatchEvent(new Event('open')); + await connecting; + assert.equal(observedURL, 'ws://localhost/api'); + await assert.rejects(peer.connect('ws://localhost/api'), { code: 'already_connected' }); +}); + +test('connection timeout closes the socket; manual reconnection remains explicit', async (t) => { + const socket = new Socket(); + socket.readyState = 0; + let created = 0; + const peer = new Peer({ + connectTimeoutMs: 10, + webSocketFactory: () => { + created++; + return socket; + }, + }); + await assert.rejects(peer.connect('ws://localhost/api'), { code: 'connect_timeout' }); + assert.equal(socket.readyState, 3); + assert.equal(created, 1); + await peer.attach(new Socket()); + t.after(() => peer.close()); + assert.equal(peer.status, 'connected'); +}); + +test('late work from an old connection cannot answer a new connection', async (t) => { + const peer = new Peer(); + const old = new Socket(); + const gate = deferred(); + const started = deferred(); + handle(createDispatcher(peer.wire()), ['wait'], () => { + started.resolve(); + return gate.promise; + }); + await peer.attach(old); + old.receive({ version: 1, kind: 'request', id: 's:1', method: name('wait'), params: {} }); + await started.promise; + peer.close(); + const current = new Socket(); + await peer.attach(current); + t.after(() => peer.close()); + gate.resolve('old'); + await gate.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(current.sent, []); +}); + +test('handler registration is explicit, removable, and rejects duplicates', async (t) => { + const { client, servers } = await paired(); + t.after(() => client.close()); + const remove = handle(servers, ['x'], () => 1); + // A path has one registration: a second is refused as a second receiver + // (v0.6.0's raw handle refused it with duplicate_handler). + assert.throws(() => handle(servers, ['x'], () => 2), { code: 'receiver_exists' }); + assert.equal(await call(client.wire(), ['x']), 1); + remove(); + await assert.rejects(call(client.wire(), ['x']), { code: 'method_not_found' }); +}); + +test('two peers complete a call, an event and a cancel over an in-memory frame pipe', async (t) => { + const [left, right] = Pipe.pair(); + const client = new Peer(); + const server = new Peer({ role: 'server' }); + await Promise.all([client.attach(left), server.attach(right)]); + t.after(() => client.close()); + const notice = deferred(); + const started = deferred(); + const aborted = deferred(); + const servers = createDispatcher(server.wire()); + handle(servers, ['add'], (params) => { + const { a, b } = params as { a: number; b: number }; + return a + b; + }); + handle( + servers, + ['wait'], + (_params, context) => + new Promise((resolve) => { + context.signal.addEventListener( + 'abort', + () => { + aborted.resolve(); + resolve(null); + }, + { once: true }, + ); + started.resolve(); + }), + ); + onEvent(createDispatcher(client.wire()), ['notice'], (data) => { + notice.resolve(data); + }); + assert.equal(await call(client.wire(), ['add'], { a: 2, b: 3 }), 5); + emit(server.wire(), ['notice'], { value: 1 }); + assert.deepEqual(await notice.promise, { value: 1 }); + const controller = new AbortController(); + const cancelled = assert.rejects(call(client.wire(), ['wait'], {}, { signal: controller.signal }), { + code: 'cancelled', + }); + await started.promise; + controller.abort(); + await Promise.all([cancelled, aborted.promise]); + await settled(); + assert.equal(client.status, 'connected'); + assert.equal(server.status, 'connected'); + assert.deepEqual( + left.frames.map((frame) => frame.kind), + ['text', 'text', 'text'], + ); + assert.deepEqual( + left.frames.map((frame) => JSON.parse(frame.data as string).kind), + ['request', 'request', 'cancel'], + ); + assert.deepEqual( + right.frames.map((frame) => JSON.parse(frame.data as string).kind), + ['response', 'event', 'response'], + ); +}); + +/** One W3C traceparent, the example of the specification, and a vendor's state beside it. */ +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; +const TRACESTATE = 'vendor=t61rcWkgMzE'; +/** Every kind as a server peer receives it: a request and a cancel from the client, a response to its own. */ +const everyKind: Record[] = [ + { version: 1, kind: 'request', id: 'c:1', method: 'echo', params: {} }, + { version: 1, kind: 'response', id: 's:1', result: 1 }, + { version: 1, kind: 'cancel', id: 'c:1' }, + { version: 1, kind: 'event', event: 'notice', data: null }, +]; + +test('trace context is kept on the decoded envelope of every kind, and tracestate stands alone', () => { + for (const kind of everyKind) { + const traced = { ...kind, traceparent: TRACEPARENT, tracestate: TRACESTATE }; + assert.deepEqual(decodeEnvelope(JSON.stringify(traced), 's:', 'c:'), traced, kind.kind as string); + // An intermediary may strip one member and not the other. + const alone = { ...kind, tracestate: TRACESTATE }; + const decoded = decodeEnvelope(JSON.stringify(alone), 's:', 'c:'); + assert.deepEqual(decoded, alone); + assert.equal(Object.hasOwn(decoded, 'traceparent'), false); + // A frame without either decodes as before. + assert.deepEqual(decodeEnvelope(JSON.stringify(kind), 's:', 'c:'), kind); + } +}); + +test("a traced frame of every kind routes as before, and a response carries its request's trace", async (t) => { + const socket = new Socket(); + const peer = new Peer(); + await peer.attach(socket); + t.after(() => peer.close()); + const trace = { traceparent: TRACEPARENT, tracestate: TRACESTATE }; + const notice = deferred(); + const started = deferred(); + const aborted = deferred(); + const handlers = createDispatcher(peer.wire()); + onEvent(handlers, ['notice'], (data) => { + notice.resolve(data); + }); + handle(handlers, ['echo'], (params) => params); + handle( + handlers, + ['wait'], + (_params, context) => + new Promise((resolve) => { + context.signal.addEventListener( + 'abort', + () => { + aborted.resolve(); + resolve(null); + }, + { once: true }, + ); + started.resolve(); + }), + ); + const pending = call(peer.wire(), ['ping']); + // The root hands the call to the peer, which publishes it as c:1. + await eventually(() => socket.sent.length === 1); + socket.receive({ version: 1, kind: 'response', id: 'c:1', result: 'pong', ...trace }); + assert.equal(await pending, 'pong'); + socket.receive({ version: 1, kind: 'event', event: name('notice'), data: { value: 1 }, ...trace }); + assert.deepEqual(await notice.promise, { value: 1 }); + socket.receive({ version: 1, kind: 'request', id: 's:1', method: name('echo'), params: { value: 2 }, ...trace }); + socket.receive({ version: 1, kind: 'request', id: 's:2', method: name('wait'), params: {}, ...trace }); + await started.promise; + socket.receive({ version: 1, kind: 'cancel', id: 's:2', ...trace }); + await aborted.promise; + await settled(); + assert.equal(peer.status, 'connected'); + assert.deepEqual( + socket.sent.map((frame) => frame.id), + ['c:1', 's:1', 's:2'], + ); + assert.deepEqual(socket.sent.find((frame) => frame.id === 's:1')?.result, { value: 2 }); + // Each response repeats the members of the request it answers; trace.test.ts holds the rest. + for (const id of ['s:1', 's:2']) { + const response = socket.sent.find((frame) => frame.id === id); + assert.equal(response?.traceparent, TRACEPARENT, id); + assert.equal(response?.tracestate, TRACESTATE, id); + } +}); + +test('a malformed traceparent is refused as any invalid frame is', async () => { + const malformed = [ + '', + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7', + '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01', + '00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01', + ' 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ]; + for (const kind of everyKind) { + for (const traceparent of malformed) { + const socket = new Socket(); + const peer = new Peer({ role: 'server' }); + await peer.attach(socket); + socket.receive({ ...kind, traceparent }); + assert.equal(peer.status, 'disconnected', `${kind.kind as string} ${traceparent}`); + } + // A member that is present but not a string is refused the same way. + const socket = new Socket(); + const peer = new Peer({ role: 'server' }); + await peer.attach(socket); + socket.receive({ ...kind, traceparent: TRACEPARENT, tracestate: 7 }); + assert.equal(peer.status, 'disconnected', kind.kind as string); + } +}); + +test('the WebSocket adapter maps state, buffered bytes, frames, and the close code and reason', async () => { + const socket = new Socket(); + socket.readyState = 0; + const connection = webSocketConnection(socket); + assert.equal(connection.state, 'connecting'); + assert.throws(() => connection.send({ kind: 'text', data: 'early' })); + socket.readyState = 1; + assert.equal(connection.state, 'open'); + socket.bufferedAmount = 7; + assert.equal(connection.buffered, 7); + connection.send({ kind: 'text', data: '{"a":1}' }); + assert.deepEqual(socket.sent, [{ a: 1 }]); + const frames: Frame[] = []; + let closed: [number, string] | undefined; + const off = connection.listen({ + frame: (frame) => { + frames.push(frame); + }, + close: (code, reason) => { + closed = [code, reason]; + }, + }); + socket.receive('"text"'); + socket.dispatchEvent(new MessageEvent('message', { data: new Uint8Array([1, 2]) })); + socket.dispatchEvent(new MessageEvent('message', { data: new ArrayBuffer(3) })); + assert.deepEqual(frames, [ + { kind: 'text', data: '"text"' }, + { kind: 'binary', data: new Uint8Array([1, 2]) }, + { kind: 'binary', data: new ArrayBuffer(3) }, + ]); + // A Blob is read asynchronously; a text frame behind it keeps its place. + socket.dispatchEvent(new MessageEvent('message', { data: new Blob([new Uint8Array([9])]) })); + socket.receive('"after"'); + assert.equal(frames.length, 3); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(frames.slice(3), [ + { kind: 'binary', data: new Uint8Array([9]).buffer }, + { kind: 'text', data: '"after"' }, + ]); + socket.readyState = 2; + assert.equal(connection.state, 'closing'); + assert.throws(() => connection.send({ kind: 'text', data: 'late' })); + socket.readyState = 3; + socket.dispatchEvent(new CloseEvent('close', { code: 4001, reason: 'gone' })); + assert.equal(connection.state, 'closed'); + assert.deepEqual(closed, [4001, 'gone']); + off(); + // The peer refuses a binary frame delivered through the adapter. + const binary = new Socket(); + const peer = new Peer(); + const failure = deferred(); + peer.onClose(failure.resolve); + await peer.attach(binary); + binary.dispatchEvent(new MessageEvent('message', { data: new ArrayBuffer(1) })); + const error = await failure.promise; + assert.equal(error.code, 'invalid_message'); + assert.equal(error.message, 'Only JSON text frames are supported.'); + assert.equal(peer.status, 'disconnected'); + assert.equal(binary.readyState, 3); +}); + +test('a close from the far side surfaces its code and reason to the close handler', async () => { + const [left, right] = Pipe.pair(); + const client = new Peer(); + const server = new Peer({ role: 'server' }); + await Promise.all([client.attach(left), server.attach(right)]); + let observed: [number, string] | undefined; + left.listen({ + close: (code, reason) => { + observed = [code, reason]; + }, + }); + const closed = deferred(); + client.onClose(closed.resolve); + server.close(); + assert.deepEqual(observed, [1000, 'Duplex connection closed']); + assert.equal((await closed.promise).code, 'disconnected'); + assert.equal(client.status, 'disconnected'); + // The same through the adapter: the socket's close event carries the far side's code. + const socket = new Socket(); + const connection = webSocketConnection(socket); + const peer = new Peer(); + await peer.attach(connection); + let seen: [number, string] | undefined; + connection.listen({ + close: (code, reason) => { + seen = [code, reason]; + }, + }); + const failure = deferred(); + peer.onClose(failure.resolve); + socket.readyState = 3; + socket.dispatchEvent(new CloseEvent('close', { code: 1008, reason: 'policy violation' })); + assert.deepEqual(seen, [1008, 'policy violation']); + assert.equal((await failure.promise).code, 'disconnected'); + assert.equal(socket.closeCount, 0); +}); + +for (const code of ['cancelled', 'request_timeout']) { + test(`a public ${code} refusal crosses as that code`, async (t) => { + // v0.6.0: "... is observed as an error in both directions"; the observer's + // request.ended assertions are not ported. + const { client, servers } = await paired(); + t.after(() => client.close()); + handle(servers, ['deny'], () => { + throw new PublicError(code, 'Refused.'); + }); + await assert.rejects(call(client.wire(), ['deny']), { code }); + }); +} + +test('a handler public refusal stays an error after the caller withdraws', async (t) => { + // The observer's request.ended (outcome error, code cancelled) is not + // ported; the response frame that says the same is held instead. + const started = deferred(); + const { client, servers, right } = await paired(); + t.after(() => client.close()); + handle( + servers, + ['deny'], + (_params, context) => + new Promise((_resolve, reject) => { + started.resolve(); + context.signal.addEventListener('abort', () => reject(new PublicError('cancelled', 'Refused.')), { + once: true, + }); + }), + ); + const controller = new AbortController(); + const pending = assert.rejects(call(client.wire(), ['deny'], {}, { signal: controller.signal }), { + code: 'cancelled', + }); + await started.promise; + controller.abort(); + await pending; + await eventually(() => right.sent.some((frame) => frame.kind === 'response')); + assert.deepEqual(right.sent.find((frame) => frame.kind === 'response')?.error, { + code: 'cancelled', + message: 'Refused.', + }); +}); + +test('a cancellation before handler dispatch is answered once as a local cancellation', async (t) => { + // v0.6.0: "... is observed once as a local cancellation"; the observer's + // request.ended and handler.panic assertions are not ported. + const socket = new Socket(); + const peer = new Peer(); + t.after(() => peer.close()); + await peer.attach(socket); + let dispatched = false; + handle(createDispatcher(peer.wire()), ['wait'], () => { + dispatched = true; + return null; + }); + socket.receive({ version: 1, kind: 'request', id: 's:1', method: name('wait'), params: {} }); + socket.receive({ version: 1, kind: 'cancel', id: 's:1' }); + await eventually(() => socket.sent.some((frame) => frame.kind === 'response')); + await settled(); + assert.equal(dispatched, false); + assert.equal(socket.sent.filter((frame) => frame.kind === 'response').length, 1); + assert.deepEqual(socket.sent.find((frame) => frame.kind === 'response')?.error, { + code: 'cancelled', + message: 'Request was cancelled.', + }); +}); + +for (const outcome of ['cancelled', 'timeout'] as const) { + test(`a local ${outcome} sends its cancel, and the receiver answers a withdrawal`, async (t) => { + // v0.6.0: "... is observed before its cancel, and the receiver observes a + // withdrawal"; the observer's ordering is not ported, the frames are held. + const { client, servers, left, right } = await paired(); + t.after(() => client.close()); + const started = deferred(); + handle(servers, ['wait'], (_params, context) => { + started.resolve(); + return new Promise((resolve) => { + context.signal.addEventListener('abort', () => resolve(null), { once: true }); + }); + }); + const controller = new AbortController(); + const code = outcome === 'timeout' ? 'request_timeout' : 'cancelled'; + const pending = assert.rejects( + call(client.wire(), ['wait'], {}, { signal: controller.signal, timeoutMs: outcome === 'timeout' ? 20 : 5000 }), + { code }, + ); + await started.promise; + if (outcome === 'cancelled') controller.abort(); + await pending; + await eventually(() => left.sent.some((frame) => frame.kind === 'cancel')); + assert.deepEqual( + left.sent.map((frame) => [frame.kind, frame.id]), + [ + ['request', 'c:1'], + ['cancel', 'c:1'], + ], + ); + await eventually(() => right.sent.some((frame) => frame.kind === 'response')); + assert.deepEqual(right.sent.find((frame) => frame.kind === 'response')?.error, { + code: 'cancelled', + message: 'Request was cancelled.', + }); + }); +} + +test('a handler deadline is answered to the caller as a refusal', async (t) => { + // v0.6.0: "... is observed locally as a timeout and remotely as a refusal"; + // the observer's request.ended assertions are not ported. + const { client, servers, right } = await paired({}, { requestTimeoutMs: 20 }); + t.after(() => client.close()); + handle( + servers, + ['wait'], + (_params, context) => + new Promise((resolve) => { + context.signal.addEventListener('abort', () => resolve(null), { once: true }); + }), + ); + await assert.rejects(call(client.wire(), ['wait']), { code: 'cancelled' }); + assert.deepEqual(right.sent.find((frame) => frame.kind === 'response')?.error, { + code: 'cancelled', + message: 'Request deadline exceeded.', + }); +}); + +test('wire output overflow ends the carrier and accepted writes retain their transport deadline', async () => { + // v0.6.0: "... is observed once and ..."; the observer's backpressure + // events are not ported, and the delivery it observed is held by the + // listener instead. + const socket = new Socket(); + socket.bufferedAmount = 1; + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 40 }); + await peer.attach(socket); + emit(peer.wire(), ['first']); + await Promise.resolve(); + // The queue limit ends admission immediately; a transport timeout is a + // separate case below. + const ended = deferred(); + peer.onClose(ended.resolve); + emit(peer.wire(), ['second']); + assert.equal((await ended.promise).code, 'busy'); + assert.equal(peer.status, 'disconnected'); + + const slow = new Socket(); + slow.bufferedAmount = 1; + const blocked = new Peer({ writeTimeoutMs: 10 }); + const gaveOut = deferred(); + blocked.onClose(gaveOut.resolve); + await blocked.attach(slow); + // The emit is accepted for sending and the caller is told so; what the + // deadline holds is the queue it went into, and a frame that never drains + // is what passes it. + emit(blocked.wire(), ['blocked']); + assert.equal((await gaveOut.promise).code, 'write_timeout'); + + // The event queue is the other side of the same limit. + const listening = new Socket(); + const receiver = new Peer({ queueCapacity: 1 }); + await receiver.attach(listening); + const held = deferred(); + const delivered: string[] = []; + receiver.wire().receive({ + message: (path) => { + delivered.push(path[0]!); + return held.promise; + }, + }); + listening.receive({ version: 1, kind: 'event', event: name('one'), data: {} }); + listening.receive({ version: 1, kind: 'event', event: name('two'), data: {} }); + // Paced, not disconnected: the consumer has a deadline to drain in and has + // not passed it. This peer cannot pause what a socket hands it, so the + // event is held where the Go peer stops reading; the deadline is the same. + assert.equal(receiver.status, 'connected'); + held.resolve(); + await Promise.resolve(); + await settled(); + // Both are delivered, in order: the second was held while the first was in + // hand and went the moment it was free, which is what pacing is for. + assert.deepEqual(delivered, ['one', 'two']); + assert.equal(receiver.status, 'connected'); + receiver.close(); +}); + +test('a subprotocol is offered at the handshake and the selection is what the peer reports', async (t) => { + const socket = new Socket(); + socket.readyState = 0; + let offered: string[] | undefined; + const peer = new Peer({ + subprotocols: ['a', 'b'], + webSocketFactory: (_url, protocols) => { + offered = protocols; + return socket; + }, + }); + t.after(() => peer.close()); + const connecting = peer.connect('ws://localhost/api'); + assert.deepEqual(offered, ['a', 'b'], 'the factory is handed what to offer, so a custom one honours it'); + assert.equal(peer.subprotocol, '', 'nothing is selected until the handshake is done'); + socket.protocol = 'b'; + socket.readyState = 1; + socket.dispatchEvent(new Event('open')); + await connecting; + assert.equal(peer.subprotocol, 'b'); + peer.close(); + assert.equal(peer.subprotocol, '', 'a peer with no connection negotiated nothing'); +}); + +test('an offer the server selected none of leaves the peer with none, and the profile is spoken anyway', async (t) => { + const socket = new Socket(); + const peer = new Peer({ subprotocols: ['c'], webSocketFactory: () => socket }); + t.after(() => peer.close()); + await peer.connect('ws://localhost/api'); + assert.equal(peer.subprotocol, ''); + emit(peer.wire(), ['progress'], 1); + await eventually(() => socket.sent.length === 1); + const { version, kind, event, data } = socket.sent[0] as Record; + assert.deepEqual({ version, kind, event, data }, { version: 1, kind: 'event', event: name('progress'), data: 1 }); +}); + +test('a peer that offers no subprotocol offers nothing at all', async (t) => { + const socket = new Socket(); + let offered: string[] | undefined = ['unasked']; + const peer = new Peer({ + webSocketFactory: (_url, protocols) => { + offered = protocols; + return socket; + }, + }); + t.after(() => peer.close()); + await peer.connect('ws://localhost/api'); + assert.equal(offered, undefined); + assert.equal(peer.subprotocol, ''); +}); + +test('a peer over a connection that is no WebSocket negotiated nothing', async (t) => { + const [near, far] = Pipe.pair(); + const peer = new Peer(); + const other = new Peer({ role: 'server' }); + t.after(() => { + peer.close(); + other.close(); + }); + await peer.attach(near); + await other.attach(far); + assert.equal(peer.subprotocol, ''); + assert.equal(other.subprotocol, ''); +}); + +// What the port adds: the protocol presented only through the root. + +test('a request whose method encodes no path is answered method_not_found, as by a v0.6.0 peer without that handler', async (t) => { + const socket = new Socket(); + const peer = new Peer({ role: 'server' }); + await peer.attach(socket); + t.after(() => peer.close()); + const handlers = createDispatcher(peer.wire()); + handle(handlers, ['raw.method'], () => 'served'); + const events: unknown[] = []; + onEvent(handlers, ['raw.event'], (data) => void events.push(data)); + socket.receive({ version: 1, kind: 'request', id: 'c:1', method: 'raw.method', params: null }); + socket.receive({ version: 1, kind: 'request', id: 'c:2', method: '1:a1', params: null }); + socket.receive({ version: 1, kind: 'request', id: 'c:3', method: name('raw.method'), params: null }); + // An event whose name encodes no path reaches nothing, as an event with no + // listener does; the connection stays whole. + socket.receive({ version: 1, kind: 'event', event: 'raw.event', data: 'dropped' }); + socket.receive({ version: 1, kind: 'event', event: name('raw.event'), data: 'delivered' }); + await eventually(() => socket.sent.length === 3); + await settled(); + assert.equal(peer.status, 'connected'); + const byId = Object.fromEntries(socket.sent.map((frame) => [frame.id, frame])); + assert.deepEqual(byId['c:1']!.error, { code: 'method_not_found', message: 'Unknown method raw.method.' }); + assert.deepEqual(byId['c:2']!.error, { code: 'method_not_found', message: 'Unknown method 1:a1.' }); + assert.equal(byId['c:3']!.result, 'served'); + assert.deepEqual(events, ['delivered']); + // With nothing attached to the root, every request is answered the same way. + const bare = new Socket(); + const idle = new Peer({ role: 'server' }); + await idle.attach(bare); + t.after(() => idle.close()); + bare.receive({ version: 1, kind: 'request', id: 'c:1', method: name('raw.method'), params: null }); + await eventually(() => bare.sent.length === 1); + assert.deepEqual(bare.sent[0]!.error, { code: 'method_not_found', message: `Unknown method ${name('raw.method')}.` }); +}); + +test('R19/R23: a handler context does not reach the peer that delivered its request', async (t) => { + const { client, servers } = await paired(); + t.after(() => client.close()); + const seen = deferred(); + handle(servers, ['inspect'], (_params, context) => { + seen.resolve(context); + return null; + }); + await call(client.wire(), ['inspect']); + const context = await seen.promise; + assert.equal('peer' in context, false); + for (let prototype = Object.getPrototypeOf(context); prototype; prototype = Object.getPrototypeOf(prototype)) + for (const key of Reflect.ownKeys(prototype)) assert.ok(!(prototype[key] instanceof Peer), String(key)); +}); + +test('a request the peer handed on and its end cuts off is answered disconnected, not cancelled', async (t) => { + for (const ending of ['closed here', 'closed there', 'refused frame'] as const) { + await t.test(ending, async () => { + const { client, server, clients, servers, left } = await paired(); + const started = deferred(); + handle(servers, ['hold'], () => { + started.resolve(); + return new Promise(() => {}); + }); + // Straight through the root, and forwarded to it through a local pair. + const [caller, forwarding] = pair(); + const stop = forward(forwarding, clients.select([])); + const direct = call(client.wire(), ['hold']).catch((error: unknown) => error); + const forwarded = call(caller, ['hold']).catch((error: unknown) => error); + await started.promise; + await eventually(() => left.sent.filter((frame) => frame.kind === 'request').length === 2); + if (ending === 'closed here') client.close(); + else if (ending === 'closed there') server.close(); + else left.partner!.send('{"version":2}'); + for (const outcome of await Promise.all([direct, forwarded])) { + assert.ok(outcome instanceof PublicError, String(outcome)); + assert.equal(outcome.code, 'disconnected'); + assert.equal(outcome instanceof UnpublishedError, false); + } + stop(); + caller.close(); + server.close(); + }); + } +}); + +test('a peer ended from outside transmits the close code it chose', async (t) => { + const endings: [string, (peer: Peer) => void, [number, string]][] = [ + ['close()', (peer) => peer.close(), [1000, 'Duplex connection closed']], + ['wire().close(code, reason)', (peer) => peer.wire().close(4001, 'bye'), [4001, 'bye']], + ['a refused frame', () => {}, [4011, 'Duplex connection closed']], + ]; + for (const [label, end, expected] of endings) { + await t.test(label, async () => { + const [near, far] = pipe(); + const peer = new Peer(); + await peer.attach(near); + let observed: [number, string] | undefined; + far.listen({ close: (code, reason) => void (observed = [code, reason]) }); + const pending = call(peer.wire(), ['held']).catch((error: unknown) => error); + await nextTurn(); + if (label === 'a refused frame') far.send({ kind: 'text', data: '{"version":2}' }); + else end(peer); + await eventually(() => observed !== undefined); + assert.deepEqual(observed, expected); + assert.equal(((await pending) as PublicError).code, 'disconnected'); + }); + } +}); + +test('R27: a peer asked to close with an observe-only code never transmits it', async () => { + for (const code of [1005, 1006, 1015]) { + const [near, far] = pipe(); + const peer = new Peer(); + await peer.attach(near); + let observed: [number, string] | undefined; + far.listen({ close: (closed, reason) => void (observed = [closed, reason]) }); + const ended = deferred(); + peer.onClose(ended.resolve); + const pending = call(peer.wire(), ['held']).catch((error: unknown) => error); + await nextTurn(); + peer.wire().close(code, 'observed'); + // A connection of the seam ends only by a close, so the peer closes with a + // normal one instead of the code (Go aborts, and the far side reads 1006). + await eventually(() => observed !== undefined); + assert.deepEqual(observed, [1000, ''], `${code}`); + assert.equal(peer.status, 'disconnected'); + assert.equal((await ended.promise).code, 'disconnected'); + assert.equal(((await pending) as PublicError).code, 'disconnected'); + } +}); + +test('R28: a closing peer answers every request its root still holds', async (t) => { + const { client, servers } = await paired({ maxPendingRequests: 1 }); + handle(servers, ['hold'], () => new Promise(() => {})); + const answers = new Map(); + const returning = (label: string) => ({ + wire: { + send: (_path: readonly string[], message: Message) => { + const frame = message.frame; + answers.set(label, frame.kind === 'response' && frame.error ? frame.error.code : 'result'); + }, + }, + }); + const request = (id: string, label: string) => + client.wire().send(['hold'], { frame: { version: 1, kind: 'request', id, params: null }, return: returning(label) }); + request('c:1', 'admitted'); + request('c:2', 'over the bound'); + // The peer ends before its root has handed either on. + client.close(); + await settled(); + t.after(() => client.close()); + assert.deepEqual(Object.fromEntries(answers), { admitted: 'disconnected', 'over the bound': 'busy' }); +}); diff --git a/engine/ts/test/publication.test.ts b/engine/ts/test/publication.test.ts new file mode 100644 index 0000000..837fcc9 --- /dev/null +++ b/engine/ts/test/publication.test.ts @@ -0,0 +1,219 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/publication.test.ts, with the +// raw peer calls and handlers expressed through the root and the dispatch +// helpers. Where the root changes what can be observed, the test says so. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PublicError, UnpublishedError } from '../../../core/ts/src/index.ts'; +import { pipe, type FrameConnection } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, emit, handle } from '../../../dispatch/ts/src/index.ts'; +import { Peer } from '../src/index.ts'; + +function deferred(): { promise: Promise; resolve: (value: V) => void } { + let resolve!: (value: V) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +test('unpublished proof belongs to the local send attempt', async () => { + const [a, b] = pipe(); + const client = new Peer({ maxPendingRequests: 1, maxFrameBytes: 512 }); + const server = new Peer({ role: 'server' }); + const entered = deferred(); + const finish = deferred(); + const handlers = createDispatcher(server.wire()); + handle(handlers, ['wait'], async () => { + entered.resolve(); + await finish.promise; + return null; + }); + handle(handlers, ['busy'], async () => { + throw new PublicError('busy', 'retained before refusing'); + }); + handle(handlers, ['nested'], async (_params, context) => { + const controller = new AbortController(); + controller.abort(); + return call(context.wire, ['never.sent'], undefined, { signal: controller.signal }); + }); + await Promise.all([client.attach(a), server.attach(b)]); + try { + const controller = new AbortController(); + controller.abort(); + await assert.rejects(call(client.wire(), ['wait'], undefined, { signal: controller.signal }), (error: unknown) => { + assert.ok(error instanceof UnpublishedError); + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'cancelled'); + assert.ok(error.cause instanceof PublicError); + assert.equal(error.cause.code, 'cancelled'); + return true; + }); + for (const request of [() => 1, 'x'.repeat(1024)]) { + await assert.rejects(call(client.wire(), ['wait'], request), UnpublishedError); + assert.throws(() => emit(client.wire(), ['event'], request), UnpublishedError); + } + const held = call(client.wire(), ['wait']); + await entered.promise; + // v0.6.0's raw call refused the call past the pending bound before it + // queued, with proof. The root refuses it at admission and answers it + // through its return capability, as every wire refusal is answered, so + // the caller receives the refusal as a response and holds no proof. + await assert.rejects(call(client.wire(), ['busy']), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'busy'); + return true; + }); + finish.resolve(); + await held; + for (const [method, code] of [ + ['busy', 'busy'], + ['nested', 'cancelled'], + ]) { + await assert.rejects(call(client.wire(), [method!]), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error.code, code); + assert.ok(!(error instanceof UnpublishedError), 'remote error carried local publication proof'); + return true; + }); + } + } finally { + finish.resolve(); + client.close(); + server.close(); + } +}); + +test('a queued write failure has no unpublished proof', async () => { + const [a, b] = pipe(); + const cause = new PublicError('send_failed', 'transport failed after queue acceptance'); + const attempted = deferred(); + const connection: FrameConnection = { + get state() { + return a.state; + }, + get buffered() { + return a.buffered; + }, + send() { + attempted.resolve(); + throw cause; + }, + close: (code, reason) => a.close(code, reason), + listen: (handlers) => a.listen(handlers), + }; + const peer = new Peer(); + await peer.attach(connection); + try { + await assert.rejects(call(peer.wire(), ['supply']), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.ok(!(error instanceof UnpublishedError)); + // R26: the write failed after the queue accepted it, which ended the + // carrier; the call reports the carrier ended (v0.6.0: its cause's code). + assert.equal(error.code, 'disconnected'); + assert.equal(cause.code, 'send_failed'); + return true; + }); + await attempted.promise; + } finally { + peer.close(); + b.close(); + } +}); + +test('a refused reverse reply cannot lend its proof to an already delivered call', async () => { + const [a, b] = pipe(); + // The reply and its bounded fallback, which repeats the request's trace, + // are both over the client's frame limit. v0.6.0's raw handler refused them + // at the client peer, which ended with frame_too_large at once and settled + // the delivered call with that. Behind the root the local return capability + // refuses them first, so nothing answers the reverse call before the + // deadlines: the delivered call ends at its own (request_timeout), and the + // client peer ends as v0.6.0's did when its deadline's answer is refused in + // turn. The deadline is shortened here. The delivered call holds no proof + // either way, which is what this holds. + const client = new Peer({ maxFrameBytes: 160, requestTimeoutMs: 100 }); + const server = new Peer({ role: 'server' }); + let delivered = false; + const ended = deferred(); + client.onClose((error) => ended.resolve(error.code)); + handle(createDispatcher(client.wire()), ['b'], async () => 'x'.repeat(2000)); + handle(createDispatcher(server.wire()), ['a'], async (_params, context) => { + delivered = true; + return call(context.wire, ['b']); + }); + await Promise.all([client.attach(a), server.attach(b)]); + try { + await assert.rejects(call(client.wire(), ['a']), (error: unknown) => { + assert.equal(delivered, true); + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'request_timeout'); + assert.ok(!(error instanceof UnpublishedError), 'another reply lent proof to this delivered request'); + return true; + }); + assert.equal(await ended.promise, 'frame_too_large'); + } finally { + client.close(); + server.close(); + } +}); + +test('an adapter getter failure after writing cannot prove its queued request unpublished', async () => { + const [a, b] = pipe(); + const client = new Peer(); + const server = new Peer({ role: 'server' }); + const delivered = deferred(); + let writes = 0; + let failAfterWrite = false; + let nested!: UnpublishedError; + const connection: FrameConnection = { + get state() { + return a.state; + }, + get buffered() { + if (failAfterWrite) { + failAfterWrite = false; + throw nested; + } + return a.buffered; + }, + send(frame) { + a.send(frame); + writes++; + if (writes === 1) failAfterWrite = true; + }, + close: (code, reason) => a.close(code, reason), + listen: (handlers) => a.listen(handlers), + }; + const handlers = createDispatcher(server.wire()); + handle(handlers, ['supply'], async () => { + delivered.resolve(); + return null; + }); + handle(handlers, ['ordinary'], async () => 42); + await Promise.all([client.attach(connection), server.attach(b)]); + try { + const controller = new AbortController(); + controller.abort(); + await assert.rejects(call(client.wire(), ['nested'], undefined, { signal: controller.signal }), (error: unknown) => { + assert.ok(error instanceof UnpublishedError); + nested = error; + return true; + }); + await assert.rejects(call(client.wire(), ['supply']), (error: unknown) => { + assert.equal(writes, 1); + assert.ok(error instanceof PublicError); + assert.ok(!(error instanceof UnpublishedError), 'a post-write adapter error carried nested proof'); + assert.equal(error.code, nested.code); + // Not ported: the peer's own error object as this error's cause. The + // root answers through the caller's return capability with the public + // fields alone, which is also what strips the proof. + return true; + }); + await delivered.promise; + assert.equal(await call(client.wire(), ['ordinary']), 42); + assert.equal(writes, 2); + } finally { + client.close(); + server.close(); + } +}); diff --git a/engine/ts/test/serial.test.ts b/engine/ts/test/serial.test.ts new file mode 100644 index 0000000..82af524 --- /dev/null +++ b/engine/ts/test/serial.test.ts @@ -0,0 +1,130 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/serial.test.ts. The peer serves +// requests only through its root, so a request names a path: the rows of +// vectors/bitwire-1/serials.json spell the method "echo", which encodes no +// path, and are answered method_not_found โ€” an answer, which is all the table +// asks of an admitted serial. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { Peer, type PeerOptions } from '../src/index.ts'; +import type { WebSocketLike } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, handle } from '../../../dispatch/ts/src/index.ts'; + +/** A raw socket, so a test spells the frames it sends byte for byte. */ +class RawSocket extends EventTarget implements WebSocketLike { + readyState = 1; + bufferedAmount = 0; + protocol = ''; + sent: Record[] = []; + send(text: string): void { + if (this.readyState !== 1) throw new Error('Closed'); + this.sent.push(JSON.parse(text) as Record); + } + receive(frame: string): void { + this.dispatchEvent(new MessageEvent('message', { data: frame })); + } + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event('close')); + } +} + +async function raw(options: PeerOptions = {}) { + const socket = new RawSocket(); + const peer = new Peer({ ...options, role: 'server' }); + await peer.attach(socket); + return { socket, peer }; +} + +/** + * Every row of tables/serials.json, held the way the peer holds what arrives โ€” + * the first frame published, then the request that follows it โ€” so that the two + * runtimes and the suite read one description of the order, this one. + */ +interface SerialRow { + name: string; + before: string; + frame: string; + valid: boolean; +} +const serials = JSON.parse( + readFileSync(new URL('../../../vectors/bitwire-1/serials.json', import.meta.url), 'utf8'), +) as { rows: SerialRow[] }; + +assert.ok(serials.rows.length > 0, 'the serials table has no rows'); +for (const row of serials.rows) { + test(`serials table: ${row.name}`, async (t) => { + const { socket, peer } = await raw(); + t.after(() => peer.close()); + handle(createDispatcher(peer.wire()), ['echo'], (data) => data); + socket.receive(row.before); + socket.receive(row.frame); + await nextTurn(); + await nextTurn(); + if (!row.valid) { + assert.equal(socket.readyState, 3, 'a serial that did not increase was admitted'); + return; + } + assert.equal(socket.readyState, 1, 'an admissible serial ended the connection'); + assert.ok( + socket.sent.some((frame) => frame.kind === 'response'), + 'an admissible serial was never answered', + ); + }); +} + +test('only request admission advances the mark', async (t) => { + const { socket, peer } = await raw(); + t.after(() => peer.close()); + handle( + createDispatcher(peer.wire()), + ['wait'], + (_data, context) => + new Promise((_resolve, reject) => { + context.signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); + }), + ); + socket.receive(JSON.stringify({ version: 1, kind: 'request', id: 'c:4', method: '4:wait', params: null })); + socket.receive(JSON.stringify({ version: 1, kind: 'cancel', id: 'c:4' })); + socket.receive(JSON.stringify({ version: 1, kind: 'response', id: 's:1', result: null })); + socket.receive(JSON.stringify({ version: 1, kind: 'request', id: 'c:5', method: '4:wait', params: null })); + await nextTurn(); + await nextTurn(); + assert.equal(socket.readyState, 1, 'a control or a response advanced the mark'); +}); + +test('serials are published in the order they were reserved', async (t) => { + // v0.6.0 paced 24 raw calls through a queue of 4. The root admits requests + // immediately and in its own order, so no pacing can reorder them; the 24 + // calls go through the root with the default queue instead. + const { socket, peer } = await raw(); + t.after(() => peer.close()); + const calls: Promise[] = []; + for (let index = 0; index < 24; index++) calls.push(call(peer.wire(), ['probe']).catch(() => undefined)); + await nextTurn(); + await nextTurn(); + let previous = 0; + for (const frame of socket.sent) { + if (frame.kind !== 'request') continue; + const serial = Number((frame.id as string).slice('s:'.length)); + assert.ok(serial > previous, `published ${serial} after ${previous}`); + previous = serial; + } + assert.ok(previous > 0, 'nothing was published'); + peer.close(); + await Promise.all(calls); +}); + +test('a carrier bridge mints its own serials', async (t) => { + const { socket, peer } = await raw(); + t.after(() => peer.close()); + void call(peer.wire(), ['probe']).catch(() => undefined); + await nextTurn(); + const published = socket.sent.find((frame) => frame.kind === 'request'); + assert.ok(published, 'the bridge published nothing'); + const id = published.id as string; + assert.ok(id.startsWith('s:'), `the bridge published ${id} rather than a serial of its own`); + assert.ok(Number(id.slice(2)) > 0, `the bridge published ${id}`); +}); diff --git a/engine/ts/test/trace.test.ts b/engine/ts/test/trace.test.ts new file mode 100644 index 0000000..40facaa --- /dev/null +++ b/engine/ts/test/trace.test.ts @@ -0,0 +1,203 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/trace.test.ts. Handlers and +// calls go through the peer's root: a request's method and an event's name +// are the canonical encodings of their paths ('5:outer' for ['outer']), and a +// call made from a handler names its context, and its propagator, explicitly. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Propagator, Trace, TraceContext } from '../../../core/ts/src/index.ts'; +import type { WebSocketLike } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, emit, handle, type RequestContext } from '../../../dispatch/ts/src/index.ts'; +import { Peer } from '../src/index.ts'; + +/** The socket of peer.test.ts, with only what a trace assertion needs of it. */ +class Socket extends EventTarget implements WebSocketLike { + readyState = 1; + bufferedAmount = 0; + sent: Record[] = []; + send(text: string): void { + this.sent.push(JSON.parse(text)); + } + receive(frame: unknown): void { + this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(frame) })); + } + close(): void { + this.readyState = 3; + this.dispatchEvent(new Event('close')); + } +} + +/** One W3C traceparent, the example of the specification, and a vendor's state beside it. */ +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; +const TRACESTATE = 'vendor=t61rcWkgMzE'; +const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736'; +const SPAN_ID = '00f067aa0ba902b7'; +const FORM = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/; + +/** A peer with no propagator configured: every case below holds through the default. */ +async function peered() { + const socket = new Socket(); + const peer = new Peer(); + await peer.attach(socket); + return { socket, peer }; +} +function parts(frame: Record | undefined): [string, string, string, string] { + const traceparent = frame?.traceparent; + assert.ok(typeof traceparent === 'string', 'frame carries a traceparent'); + assert.match(traceparent, FORM); + return traceparent.split('-') as [string, string, string, string]; +} +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('a request and an event made inside a handler are children of the handler, and the response repeats it', async (t) => { + const { socket, peer } = await peered(); + t.after(() => peer.close()); + let observed: RequestContext | undefined; + handle(createDispatcher(peer.wire()), ['outer'], (_params, context) => { + observed = context; + void call(context.wire, ['inner'], {}, { context }).catch(() => {}); + emit(context.wire, ['progress'], 1, { context }); + return 'done'; + }); + socket.receive({ + version: 1, + kind: 'request', + id: 's:1', + method: '5:outer', + params: {}, + traceparent: TRACEPARENT, + tracestate: TRACESTATE, + }); + await tick(); + // The incoming trace is on the context the handler ran with, member for member. + assert.deepEqual(observed?.trace, { traceparent: TRACEPARENT, tracestate: TRACESTATE }); + // A child keeps the version, trace id and flags of its parent and mints a span of its own. + for (const frame of [ + socket.sent.find((f) => f.method === '5:inner'), + socket.sent.find((f) => f.event === '8:progress'), + ]) { + const [version, traceID, spanID, flags] = parts(frame); + assert.deepEqual([version, traceID, flags], ['00', TRACE_ID, '01']); + assert.notEqual(spanID, SPAN_ID); + assert.equal(frame?.tracestate, TRACESTATE); + } + assert.notEqual( + socket.sent.find((f) => f.method === '5:inner')?.traceparent, + socket.sent.find((f) => f.event === '8:progress')?.traceparent, + ); + // The response carries the request's members verbatim: it is no span of its own. + const response = socket.sent.find((frame) => frame.id === 's:1'); + assert.deepEqual(response, { + version: 1, + kind: 'response', + id: 's:1', + result: 'done', + traceparent: TRACEPARENT, + tracestate: TRACESTATE, + }); +}); + +test('a cancel carries the trace of the request it cancels', async (t) => { + const { socket, peer } = await peered(); + t.after(() => peer.close()); + const controller = new AbortController(); + const pending = assert.rejects(call(peer.wire(), ['wait'], {}, { signal: controller.signal }), { code: 'cancelled' }); + controller.abort(); + await pending; + await tick(); + const request = socket.sent.find((frame) => frame.kind === 'request'); + const cancel = socket.sent.find((frame) => frame.kind === 'cancel'); + assert.equal(cancel?.id, request?.id); + assert.equal(cancel?.traceparent, request?.traceparent); + assert.equal(Object.hasOwn(cancel ?? {}, 'tracestate'), false); +}); + +test('a call from a bare context carries a new trace, sampled, one per call', async (t) => { + const { socket, peer } = await peered(); + t.after(() => peer.close()); + void call(peer.wire(), ['first']).catch(() => {}); + void call(peer.wire(), ['second']).catch(() => {}); + emit(peer.wire(), ['notice']); + await tick(); + const [first, second, event] = socket.sent.map(parts); + for (const [version, , , flags] of [first, second, event]) assert.deepEqual([version, flags], ['00', '01']); + assert.notEqual(first[1], second[1]); + assert.notEqual(second[1], event[1]); + assert.equal( + socket.sent.some((frame) => Object.hasOwn(frame, 'tracestate')), + false, + ); +}); + +test('a custom propagator sees extract and inject, and what it mints travels verbatim', async (t) => { + const extracted: (Trace | undefined)[] = []; + const injected: (TraceContext | undefined)[] = []; + const minted: Trace = { traceparent: `00-${'a'.repeat(32)}-${'b'.repeat(16)}-00`, tracestate: 'custom=1' }; + const propagator: Propagator = { + extract(context, trace) { + extracted.push(trace); + context.trace = trace; + }, + inject(context) { + injected.push(context); + return minted; + }, + }; + const socket = new Socket(); + const peer = new Peer({ propagator }); + await peer.attach(socket); + t.after(() => peer.close()); + const handled: RequestContext[] = []; + handle(createDispatcher(peer.wire()), ['outer'], (_params, context) => { + handled.push(context); + void call(context.wire, ['inner'], {}, { context, propagator }).catch(() => {}); + return null; + }); + socket.receive({ + version: 1, + kind: 'request', + id: 's:1', + method: '5:outer', + params: {}, + traceparent: TRACEPARENT, + tracestate: TRACESTATE, + }); + await tick(); + // An intermediary may strip one member and not the other: both reach the + // propagator as they arrived, and a tracestate alone continues no trace. + socket.receive({ version: 1, kind: 'request', id: 's:2', method: '5:outer', params: {}, tracestate: TRACESTATE }); + await tick(); + assert.deepEqual(extracted, [ + { traceparent: TRACEPARENT, tracestate: TRACESTATE }, + { traceparent: '', tracestate: TRACESTATE }, + ]); + assert.equal(injected[0], handled[0]); + assert.deepEqual( + socket.sent.find((frame) => frame.method === '5:inner'), + { + version: 1, + kind: 'request', + id: 'c:1', + method: '5:inner', + params: {}, + ...minted, + }, + ); + // A response carries the request's members, whatever the propagator would mint. + assert.deepEqual( + socket.sent.find((frame) => frame.id === 's:1'), + { + version: 1, + kind: 'response', + id: 's:1', + result: null, + traceparent: TRACEPARENT, + tracestate: TRACESTATE, + }, + ); + assert.deepEqual( + socket.sent.find((frame) => frame.id === 's:2'), + { version: 1, kind: 'response', id: 's:2', result: null, tracestate: TRACESTATE }, + ); +}); diff --git a/engine/ts/test/websocket.test.ts b/engine/ts/test/websocket.test.ts new file mode 100644 index 0000000..7fd25b1 --- /dev/null +++ b/engine/ts/test/websocket.test.ts @@ -0,0 +1,124 @@ +// Two peers over a real socket: a client Peer that connects with Node's global +// WebSocket, and a server Peer attached to a connection a `ws` WebSocketServer +// accepted. A call, an event and a cancellation cross in each direction, and +// a close carries the code the closing peer chose. +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import test from 'node:test'; +import { WebSocketServer, type WebSocket as ServerSocket } from 'ws'; +import type { Endpoint } from '@bitspark/bitwire'; +import { PublicError } from '../../../core/ts/src/index.ts'; +import type { WebSocketLike } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, emit, handle, onEvent, type Dispatcher } from '../../../dispatch/ts/src/index.ts'; +import { Peer, PROTOCOL } from '../src/index.ts'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + +/** Serves one side: an echo, a request held until it is cancelled, and a notice listener. */ +function serve(root: Endpoint, side: string) { + const dispatcher: Dispatcher = createDispatcher(root); + const held = deferred(); + const cancelled = deferred(); + const notices: unknown[] = []; + const noticed = deferred(); + handle(dispatcher, ['echo'], (params) => ({ side, params })); + handle(dispatcher, ['hold'], (_params, context) => { + held.resolve(); + return new Promise((resolve) => { + context.signal.addEventListener( + 'abort', + () => { + cancelled.resolve(); + resolve('too late'); + }, + { once: true }, + ); + }); + }); + onEvent(dispatcher, ['notice'], (data) => { + notices.push(data); + noticed.resolve(); + }); + return { dispatcher, held, cancelled, notices, noticed }; +} + +async function connected(t: test.TestContext) { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await new Promise((resolve) => server.once('listening', () => resolve())); + const accepted = deferred<{ peer: Peer; socket: ServerSocket; served: ReturnType }>(); + server.on('connection', (socket) => { + let served!: ReturnType; + const peer = new Peer({ role: 'server', prepare: (peer) => void (served = serve(peer.wire(), 'server')) }); + void peer.attach(socket as unknown as WebSocketLike).then(() => accepted.resolve({ peer, socket, served })); + }); + let clientSocket: WebSocket | undefined; + let served!: ReturnType; + const client = new Peer({ + prepare: (peer) => void (served = serve(peer.wire(), 'client')), + webSocketFactory: (url, protocols) => (clientSocket = new WebSocket(url, protocols)), + }); + const { port } = server.address() as AddressInfo; + await client.connect(`ws://127.0.0.1:${port}/`); + const far = await accepted.promise; + t.after(async () => { + client.close(); + far.peer.close(); + await new Promise((resolve) => server.close(() => resolve())); + }); + return { client, clientServed: served, clientSocket: clientSocket!, server: far }; +} + +test('two peers call, emit and cancel both ways over a real WebSocket', async (t) => { + const { client, clientServed, server } = await connected(t); + assert.equal(PROTOCOL, 'bitwire/1'); + assert.equal(client.status, 'connected'); + assert.equal(server.peer.status, 'connected'); + + // Client to server, and server to client. + assert.deepEqual(await call(client.wire(), ['echo'], 1), { side: 'server', params: 1 }); + assert.deepEqual(await call(server.peer.wire(), ['echo'], 2), { side: 'client', params: 2 }); + + emit(client.wire(), ['notice'], 'to the server'); + emit(server.peer.wire(), ['notice'], 'to the client'); + await Promise.all([server.served.noticed.promise, clientServed.noticed.promise]); + assert.deepEqual(server.served.notices, ['to the server']); + assert.deepEqual(clientServed.notices, ['to the client']); + + for (const [caller, callee] of [ + [client, server.served], + [server.peer, clientServed], + ] as const) { + const controller = new AbortController(); + const pending = call(caller.wire(), ['hold'], null, { signal: controller.signal }).catch((error: unknown) => error); + await callee.held.promise; + controller.abort(); + const outcome = await pending; + assert.ok(outcome instanceof PublicError); + assert.equal(outcome.code, 'cancelled'); + await callee.cancelled.promise; + } + // Both carriers are whole after the cancellations, and still answer. + assert.deepEqual(await call(client.wire(), ['echo'], 3), { side: 'server', params: 3 }); + assert.deepEqual(await call(server.peer.wire(), ['echo'], 4), { side: 'client', params: 4 }); +}); + +test('a peer that closes over a real WebSocket transmits the code it chose', async (t) => { + const { client, clientSocket, server } = await connected(t); + const seen = deferred<[number, string]>(); + clientSocket.addEventListener('close', (event) => seen.resolve([event.code, event.reason])); + const ended = deferred(); + client.onClose(ended.resolve); + const pending = call(client.wire(), ['hold']).catch((error: unknown) => error); + await server.served.held.promise; + server.peer.wire().close(4001, 'closed by the server'); + assert.deepEqual(await seen.promise, [4001, 'closed by the server']); + assert.equal((await ended.promise).code, 'disconnected'); + assert.equal(((await pending) as PublicError).code, 'disconnected'); + assert.equal(client.status, 'disconnected'); +}); diff --git a/engine/ts/test/wire-event-context.test.ts b/engine/ts/test/wire-event-context.test.ts new file mode 100644 index 0000000..f6b8e06 --- /dev/null +++ b/engine/ts/test/wire-event-context.test.ts @@ -0,0 +1,208 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/wire-event-context.test.ts. +// The one changed assertion: a listener's context no longer reaches the peer +// that delivered its event (R19/R23 remove that escape hatch), where v0.6.0 +// held that it did. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AddressedWire, Endpoint, Message } from '@bitspark/bitwire'; +import { defaultPropagator, forward, mount, pair } from '../../../core/ts/src/index.ts'; +import { pipe } from '../../../transports/ts/src/index.ts'; +import { + call, + createDispatcher, + emit, + handle, + register, + type EventContext, +} from '../../../dispatch/ts/src/index.ts'; +import { Peer, type PeerOptions } from '../src/index.ts'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +async function physical(serverOptions: PeerOptions = {}) { + const [a, b] = pipe(); + const client = new Peer(), + server = new Peer({ ...serverOptions, role: 'server' }); + await Promise.all([client.attach(a), server.attach(b)]); + return { + client, + server, + close: () => { + client.close(); + server.close(); + }, + }; +} + +test('physical events keep verified context through forwarding local pair and mount without ambient outgoing metadata', async (t) => { + const verified = Object.freeze({ source: 'trusted context' }); + const marker = Symbol('verified'); + const peers = await physical({ + propagator: { + extract: (context, trace) => { + defaultPropagator.extract(context, trace); + Object.defineProperty(context, marker, { value: verified }); + }, + inject: defaultPropagator.inject, + }, + }); + t.after(peers.close); + const [access, binding] = pair({ maxPendingRequests: 1 }); + t.after(() => access.close()); + const accessMount = mount(new Map([['local', access]])); + const accessDispatcher = createDispatcher(accessMount); + const modelMount = mount(new Map([['model', binding]])); + const model = createDispatcher(modelMount); + const clientDispatcher = createDispatcher(peers.client.wire()); + t.after(() => { + accessDispatcher.close(); + model.close(); + clientDispatcher.close(); + accessMount.close(); + modelMount.close(); + }); + t.after(forward(peers.server.wire(), accessDispatcher.select(['local']))); + const observed = deferred(); + let effects = 0; + register(model, ['model', 'events', 'change'], { + event: (_data, context) => { + observed.resolve(context); + if ((context as unknown as Record)[marker] !== verified) throw new Error('Unverified event'); + effects++; + }, + }); + const meta = { tenant: 'explicit', verified: 'cannot manufacture context' }; + emit(peers.client.wire(), ['events', 'change'], null, { meta }); + const context = await observed.promise; + assert.equal((context as unknown as Record)[marker], verified); + assert.deepEqual(context.meta, meta); + // R19/R23: the peer is not reachable from a listener's context. + assert.equal('peer' in context, false); + handle(model, ['model', 'barrier'], () => effects); + assert.equal(await call(access, ['barrier']), 1); + const received: EventContext[] = []; + const arrived = deferred(); + register(clientDispatcher, ['outgoing'], { + event: (_value, context) => { + received.push(context); + if (received.length === 2) arrived.resolve(); + }, + }); + emit(peers.server.wire(), ['outgoing'], null, { context }); + emit(peers.server.wire(), ['outgoing'], null, { context, meta: context.meta }); + await arrived.promise; + assert.equal((received[0] as unknown as Record)[marker], undefined); + assert.equal(received[0]!.meta, undefined); + assert.deepEqual(received[1]!.meta, meta); +}); + +test('event metadata cannot create the private context required by an effect guard', async (t) => { + const marker = Symbol('verified'); + const peers = await physical(); + t.after(peers.close); + const [access, binding] = pair(); + t.after(() => access.close()); + t.after(forward(peers.server.wire(), access)); + const denied = deferred(); + const dispatcher = createDispatcher(binding); + t.after(() => dispatcher.close()); + register(dispatcher, ['guard'], { + event: (_value, context) => { + if (!(context as unknown as Record)[marker]) { + denied.resolve(true); + throw new Error('Denied'); + } + denied.resolve(false); + }, + }); + emit(peers.client.wire(), ['guard'], null, { meta: { verified: 'yes' } }); + assert.equal(await denied.promise, true); +}); + +test('a forwarded event context ends at the next physical transport boundary', async (t) => { + const marker = Symbol('verified'); + const source = await physical({ + propagator: { + extract: (context, trace) => { + defaultPropagator.extract(context, trace); + Object.defineProperty(context, marker, { value: true }); + }, + inject: defaultPropagator.inject, + }, + }); + const destination = await physical(); + t.after(source.close); + t.after(destination.close); + t.after(forward(source.server.wire(), destination.client.wire())); + const observed = deferred(); + const dispatcher = createDispatcher(destination.server.wire()); + t.after(() => dispatcher.close()); + register(dispatcher, ['event'], { event: (_value, context) => observed.resolve(context) }); + emit(source.client.wire(), ['event'], null, { meta: { explicit: 'yes' } }); + const context = await observed.promise; + assert.equal((context as unknown as Record)[marker], undefined); + assert.deepEqual(context.meta, { explicit: 'yes' }); +}); + +test('pure local events use the configured propagator', async (t) => { + const marker = Symbol('local'); + const [a, b] = pair({ + propagator: { + extract: (context, trace) => { + defaultPropagator.extract(context, trace); + Object.defineProperty(context, marker, { value: true }); + }, + inject: defaultPropagator.inject, + }, + }); + t.after(() => a.close()); + const observed = deferred(); + const dispatcher = createDispatcher(b); + t.after(() => dispatcher.close()); + register(dispatcher, ['event'], { event: (_value, context) => observed.resolve(context) }); + emit(a, ['event']); + assert.equal(((await observed.promise) as unknown as Record)[marker], true); +}); + +test('a custom event propagator cannot rewrite the received frame when it is forwarded', async (t) => { + const changed = { traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01' }; + const peers = await physical({ + propagator: { + extract: (context) => { + context.trace = changed; + }, + inject: defaultPropagator.inject, + }, + }); + t.after(peers.close); + const [access, binding] = pair(); + t.after(() => access.close()); + const sent = deferred(), + received = deferred(); + const source: AddressedWire = { + send: (path, message) => { + sent.resolve(message); + peers.client.wire().send(path, message); + }, + }; + const destination: Endpoint = { + ...access, + send: (path, message) => { + received.resolve(message); + access.send(path, message); + }, + }; + t.after(forward(peers.server.wire(), destination)); + const delivered = deferred(); + const dispatcher = createDispatcher(binding); + t.after(() => dispatcher.close()); + register(dispatcher, ['event'], { event: (_value, context) => delivered.resolve(context) }); + emit(source, ['event']); + assert.equal((await received.promise).frame.traceparent, (await sent.promise).frame.traceparent); + assert.equal((await delivered.promise).trace, changed); +}); diff --git a/engine/ts/test/wire-forward.test.ts b/engine/ts/test/wire-forward.test.ts new file mode 100644 index 0000000..54e8f53 --- /dev/null +++ b/engine/ts/test/wire-forward.test.ts @@ -0,0 +1,395 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/wire-forward.test.ts, with +// forward as forward and the wire helpers as the dispatch helpers. +// Changed, each where it stands: the raw-handler lines of the first test, +// forward keeping on after a refusal (research 0001 row 14), and the model +// context of the register test, which v0.6.0's generated adapters supplied. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AddressedWire, Endpoint, Message, Path, Receiver } from '@bitspark/bitwire'; +import { + at, + defaultPropagator, + forward, + mount, + PublicError, + UnpublishedError, + type Meta, +} from '../../../core/ts/src/index.ts'; +import { pipe } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, emit, handle, register, type RequestContext } from '../../../dispatch/ts/src/index.ts'; +import { Peer } from '../src/index.ts'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +async function pair() { + const [a, b] = pipe(); + const left = new Peer(), + right = new Peer({ role: 'server' }); + await Promise.all([left.attach(a), right.attach(b)]); + return { + left, + right, + close: () => { + left.close(); + right.close(); + }, + }; +} +function reply(message: Message, value: unknown) { + if (message.frame.kind === 'request') { + message.return!.wire.send([], { frame: { version: 1, kind: 'response', id: message.frame.id, result: value } }); + } +} + +test('wire namespace dispatch selects exact then longest segment prefix and preserves raw handlers', async (t) => { + const peers = await pair(); + t.after(peers.close); + const events: string[] = []; + const dispatcher = createDispatcher(peers.right.wire()); + t.after(() => dispatcher.close()); + const receive = (path: Path, label: string, prefix = true) => + (prefix ? dispatcher.registerPrefix.bind(dispatcher) : dispatcher.register.bind(dispatcher))(path, { + message: (received, message) => { + if (message.frame.kind === 'event') events.push(label); + else reply(message, { label, path: received }); + }, + }); + const removeRoot = receive([], 'root'); + receive(['a'], 'a'); + receive(['a', 'b'], 'ab'); + const removeExact = receive(['a', 'b'], 'exact', false); + assert.throws(() => receive(['a'], 'duplicate'), { code: 'receiver_exists' }); + for (const [path, label] of [ + [['a', 'b'], 'exact'], + [['a', 'b', 'c'], 'ab'], + [['a', 'bc'], 'a'], + [['ab'], 'root'], + ] as const) { + assert.deepEqual(await call(peers.left.wire(), path), { label, path }); + emit(peers.left.wire(), path); + } + // A subsequent request is a delivery barrier for the prior serial events. + await call(peers.left.wire(), ['barrier']); + assert.deepEqual(events, ['exact', 'ab', 'a', 'root']); + removeExact(); + assert.equal((await call<{ label: string }>(peers.left.wire(), ['a', 'b'])).label, 'ab'); + // Not ported: a raw handler beside the root ('raw.method'), and a raw call + // of the non-canonical name '1:a1'. The peer serves only its root now; the + // answer to a name that encodes no path is held in peer.test.ts. + removeRoot(); + await assert.rejects(call(peers.left.wire(), ['unmatched']), { code: 'method_not_found' }); +}); + +test('forward carries nested paths and reverse traffic through a physical root and mounted local model', async (t) => { + const peers = await pair(); + t.after(peers.close); + let outgoing: Receiver | undefined, + closes = 0; + const arrived = deferred(), + cancelled = deferred(), + event = deferred(); + const received: { path: Path; message: Message }[] = []; + const model: Endpoint = { + send: (path, message) => { + queueMicrotask(() => { + received.push({ path, message }); + if (message.frame.kind === 'request') { + if (path.at(-1) === 'held') arrived.resolve(); + else reply(message, { path, params: message.frame.params }); + } else if (message.frame.kind === 'cancel') cancelled.resolve(); + else if (message.frame.kind === 'event') event.resolve(message.frame.data); + }); + }, + receive: (receiver) => { + assert.equal(outgoing, undefined); + outgoing = receiver; + return () => { + outgoing = undefined; + }; + }, + close: () => { + closes++; + }, + }; + const rootBefore = peers.right.wire(); + const detach = forward(rootBefore, mount(new Map([['service', model]]))); + t.after(detach); + assert.equal(peers.right.wire(), rootBefore); + const selected = at(peers.left.wire(), ['service', 'deep']); + assert.deepEqual(await call(selected, ['echo'], 7), { path: ['deep', 'echo'], params: 7 }); + emit(selected, ['notice'], 'event'); + assert.equal(await event.promise, 'event'); + const leftDispatcher = createDispatcher(peers.left.wire()); + t.after(() => leftDispatcher.close()); + handle(leftDispatcher, ['service', 'deep', 'reverse'], (value) => value); + const reverse: AddressedWire = { + send: (path, message) => { + queueMicrotask(() => { + void outgoing!.message!(path, message); + }); + }, + }; + assert.equal(await call(reverse, ['deep', 'reverse'], 'back'), 'back'); + const controller = new AbortController(); + const pending = call(selected, ['held'], null, { signal: controller.signal }); + const result = pending.catch((error: unknown) => error); + await arrived.promise; + controller.abort(); + await assert.rejects(pending, { code: 'cancelled' }); + await cancelled.promise; + await result; + const request = received.find( + ({ message }) => message.frame.kind === 'request' && 'params' in message.frame && message.frame.params === null, + )!; + const cancel = received.find(({ message }) => message.frame.kind === 'cancel')!; + assert.equal(cancel.message.return, request.message.return); + assert.deepEqual(cancel.path, request.path); + detach(); + detach(); + assert.equal(outgoing, undefined); + assert.equal(closes, 0); + assert.equal(peers.right.status, 'connected'); + await assert.rejects(call(selected, ['echo']), { code: 'method_not_found' }); +}); + +test('forward is a non-owning pair of attachments and preserves message identity', () => { + const receivers: Receiver[] = [], + detached: number[] = [], + sent: { path: Path; message: Message }[] = []; + const endpoint = (index: number): Endpoint => ({ + send: (path, message) => { + sent.push({ path, message }); + }, + receive: (receiver) => { + receivers[index] = receiver; + return () => { + detached.push(index); + }; + }, + close: () => { + assert.fail('forwarding owns no endpoint'); + }, + }); + const a = endpoint(0), + b = endpoint(1); + const stop = forward(a, b); + const path = ['opaque', ''], + message: Message = { frame: { version: 1, kind: 'event', data: null } }; + receivers[0]!.message!(path, message); + assert.equal(sent[0]!.path, path); + assert.equal(sent[0]!.message, message); + receivers[1]!.closed!(1000, 'closed'); + stop(); + assert.deepEqual(detached, [0, 1]); +}); + +test('structured wire request and event traces cross the physical bridge verbatim', async (t) => { + const peers = await pair(); + t.after(peers.close); + const trace = { traceparent: '00-11111111111111111111111111111111-2222222222222222-01', tracestate: 'test=value' }; + const request = deferred(), + event = deferred(); + const dispatcher = createDispatcher(peers.right.wire()); + t.after(() => dispatcher.close()); + dispatcher.registerPrefix(['trace'], { + message: (_path, message) => { + if (message.frame.kind === 'request') { + request.resolve(message); + reply(message, null); + } else event.resolve(message); + }, + }); + const returning: AddressedWire = { send: () => {} }; + peers.left.wire().send(['trace', 'call'], { + frame: { version: 1, kind: 'request', id: 'c:1', params: null, ...trace }, + return: { wire: returning }, + }); + peers.left.wire().send(['trace', 'event'], { frame: { version: 1, kind: 'event', data: null, ...trace } }); + for (const delivered of await Promise.all([request.promise, event.promise])) { + assert.equal(delivered.frame.traceparent, trace.traceparent); + assert.equal(delivered.frame.tracestate, trace.tracestate); + } +}); + +test('forward cleans partial setup and replies to send failure without lending publication proof', async () => { + let receiver: Receiver | undefined, + removed = 0; + const a: Endpoint = { + send: () => {}, + receive: (next) => { + receiver = next; + return () => { + removed++; + }; + }, + close: () => assert.fail('borrowed endpoint closed'), + }; + const b: Endpoint = { + send: () => { + throw new UnpublishedError(new PublicError('busy', 'Refused downstream.')); + }, + receive: () => { + throw new Error('Registration refused'); + }, + close: () => assert.fail('borrowed endpoint closed'), + }; + assert.throws(() => forward(a, b), /Registration refused/); + assert.equal(removed, 1); + b.receive = () => () => { + removed++; + }; + const stop = forward(a, b); + const origin: AddressedWire = { + send: (path, message) => { + void receiver!.message!(path, message); + }, + }; + await assert.rejects(call(origin, ['rejected']), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'busy'); + assert.equal(error instanceof UnpublishedError, false); + return true; + }); + // Research 0001 row 14: the refusal fails that request alone and forwarding + // goes on; v0.6.0 detached both directions here (removed was 3). + assert.equal(removed, 1); + stop(); + assert.equal(removed, 3); +}); + +test('detaching a forward leaves captured request cancellation routed to its original destination', async (t) => { + const peers = await pair(); + t.after(peers.close); + const started = deferred(), + cancelled = deferred(); + const destination: Endpoint = { + send: (_path, message) => { + if (message.frame.kind === 'request') started.resolve(); + if (message.frame.kind === 'cancel') cancelled.resolve(); + }, + receive: () => () => {}, + close: () => {}, + }; + const mounted = mount(new Map([['route', peers.right.wire()]])); + const dispatcher = createDispatcher(mounted); + t.after(() => { + dispatcher.close(); + mounted.close(); + }); + const selected = dispatcher.select(['route']); + const stop = forward(selected, destination); + t.after(stop); + const controller = new AbortController(); + const result = call(peers.left.wire(), ['held'], {}, { signal: controller.signal }).catch( + (error: unknown) => error, + ); + await started.promise; + stop(); + controller.abort(); + await result; + await cancelled.promise; +}); + +test('register groups request event and cancellation while preserving verified model context', async (t) => { + const [a, b] = pipe(); + const left = new Peer(), + right = new Peer({ + role: 'server', + propagator: { + inject: (context) => defaultPropagator.inject(context), + extract: (context, trace) => { + defaultPropagator.extract(context, trace); + Object.defineProperty(context, 'verified', { value: 'trusted', enumerable: false }); + }, + }, + }); + await Promise.all([left.attach(a), right.attach(b)]); + t.after(() => { + left.close(); + right.close(); + }); + const leftDispatcher = createDispatcher(left.wire()); + const rightDispatcher = createDispatcher(right.wire()); + t.after(() => { + leftDispatcher.close(); + rightDispatcher.close(); + }); + handle(leftDispatcher, ['metadata'], (_params, context) => context.meta ?? null); + // v0.6.0 took the outgoing meta and deadline from a generated adapter's + // model context; the call states them itself here. + const model = (context?: RequestContext, outgoingMeta?: Meta) => + call(right.wire(), ['metadata'], null, { + context, + signal: context?.signal, + meta: outgoingMeta, + }); + const arrived = deferred(), + cancelled = deferred(), + event = deferred(); + const detach = register(rightDispatcher, ['both'], { + request: async (params, context) => { + assert.equal(Reflect.get(context, 'verified'), 'trusted'); + if (params === 'hold') { + context.signal.addEventListener('abort', () => cancelled.resolve(), { once: true }); + arrived.resolve(); + await cancelled.promise; + return null; + } + return { + incoming: context.meta, + implicit: await model(context), + explicit: await model(context, { selected: 'yes' }), + }; + }, + event: async (data) => { + event.resolve(data); + }, + }); + assert.deepEqual(await call(left.wire(), ['both'], 'read', { meta: { incoming: 'only' } }), { + incoming: { incoming: 'only' }, + implicit: null, + explicit: { selected: 'yes' }, + }); + emit(left.wire(), ['both'], 'notice'); + assert.equal(await event.promise, 'notice'); + const controller = new AbortController(); + const pending = call(left.wire(), ['both'], 'hold', { signal: controller.signal }).catch( + (error: unknown) => error, + ); + await arrived.promise; + controller.abort(); + await cancelled.promise; + assert.equal(((await pending) as PublicError).code, 'cancelled'); + detach(); + await assert.rejects(call(left.wire(), ['both']), { code: 'method_not_found' }); +}); + +test('register sanitizes synchronous and asynchronous event failures into its registry close', async () => { + for (const failure of ['sync', 'async'] as const) { + let receiver!: Receiver; + const closed: unknown[] = []; + const wire: Endpoint = { + send: () => {}, + receive: (next) => { + receiver = next; + return () => {}; + }, + close: () => assert.fail('registry owns no borrowed endpoint'), + }; + const dispatcher = createDispatcher(wire); + dispatcher.registerPrefix([], { closed: (code, reason) => closed.push([code, reason]) }); + register(dispatcher, ['event'], { + event: () => { + if (failure === 'sync') throw new Error('private panic'); + return Promise.reject(new PublicError('private', 'private refusal')); + }, + }); + await receiver.message!(['event'], { frame: { version: 1, kind: 'event', data: null } }); + assert.deepEqual(closed, [[1002, 'wire event rejected']]); + } +}); diff --git a/engine/ts/test/wire.test.ts b/engine/ts/test/wire.test.ts new file mode 100644 index 0000000..1ac67f1 --- /dev/null +++ b/engine/ts/test/wire.test.ts @@ -0,0 +1,674 @@ +// Ported from Nightseam v0.6.0 runtime/ts/src/wire.test.ts. Not ported: the +// observer's request.ended and handler.panic assertions, and the 'peer' route +// of the cancellation matrix, which served and called through the removed raw +// peer API โ€” the 'wire' route is that same peer now. Each other change is +// where it stands. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AddressedWire, Message, ReturnAddress } from '@bitspark/bitwire'; +import { + at, + defaultPropagator, + forward, + mount, + pair as localPair, + PublicError, + UnpublishedError, + type Trace, +} from '../../../core/ts/src/index.ts'; +import { pipe, type ConnectionHandlers, type Frame, type FrameConnection } from '../../../transports/ts/src/index.ts'; +import { call, createDispatcher, emit, handle, onEvent } from '../../../dispatch/ts/src/index.ts'; +import { Peer, type PeerOptions } from '../src/index.ts'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} + +test('receiver deadline wins immediate handler refusal', async (t) => { + const pair = await paired({ requestTimeoutMs: 1 }, { requestTimeoutMs: 5000 }); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + handle( + server, + ['deadline'], + (_params, context) => + new Promise((_resolve, reject) => { + context.signal.addEventListener( + 'abort', + () => reject(new PublicError('declined', 'Body completed at deadline')), + { once: true }, + ); + }), + ); + for (let i = 0; i < 32; i++) { + await assert.rejects(call(pair.client.wire(), ['deadline'], null), { code: 'cancelled' }); + } +}); + +for (const mode of ['cancel', 'caller-deadline', 'receiver-deadline', 'public-refusal']) { + for (const route of ['wire', 'forwarded']) { + test(`wire cancellation retains executing handler budget (${mode}/${route})`, async (t) => { + const pair = await paired( + { + maxConcurrentHandlers: 1, + requestTimeoutMs: mode === 'receiver-deadline' ? 100 : 5000, + }, + { maxConcurrentHandlers: 1, requestTimeoutMs: 5000 }, + ); + t.after(pair.close); + const entered = deferred(), + cancelled = deferred(), + release = deferred(); + t.after(() => release.resolve()); + let calls = 0; + const handler = async (_params: unknown, context: { signal: AbortSignal }) => { + if (++calls === 1) { + context.signal.addEventListener('abort', () => cancelled.resolve(), { once: true }); + entered.resolve(); + await release.promise; + if (mode === 'public-refusal') throw new PublicError('cancelled', 'Application refusal'); + } + return 'finished'; + }; + let model = pair.server.wire(); + if (route === 'forwarded') { + const [left, right] = localPair(); + t.after(() => left.close(1000, '')); + t.after(forward(pair.server.wire(), left)); + model = right; + } + const dispatcher = createDispatcher(model); + t.after(() => dispatcher.close()); + handle(dispatcher, ['hold'], handler); + const hold = (options: { signal?: AbortSignal; timeoutMs?: number } = {}) => + call(pair.client.wire(), ['hold'], null, options); + const controller = new AbortController(); + const first = hold({ + signal: controller.signal, + timeoutMs: mode === 'caller-deadline' ? 100 : 5000, + }); + const result = first.catch((error: unknown) => error); + await entered.promise; + if (mode === 'cancel' || mode === 'public-refusal') controller.abort(); + assert.equal(((await result) as PublicError).code, mode === 'caller-deadline' ? 'request_timeout' : 'cancelled'); + await cancelled.promise; + await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects(hold(), { code: 'busy' }); + assert.equal(calls, 1); + release.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await hold(), 'finished'); + }); + } +} +async function paired(options: PeerOptions = {}, clientOptions: PeerOptions = options) { + const [left, right] = pipe(); + let writes = 0; + const frames: { kind: string }[] = []; + const client = new Peer(clientOptions), + server = new Peer({ ...options, role: 'server' }); + await Promise.all([ + client.attach({ + get state() { + return left.state; + }, + get buffered() { + return left.buffered; + }, + send: (frame: Frame) => { + writes++; + if (frame.kind === 'text') frames.push(JSON.parse(frame.data)); + left.send(frame); + }, + close: (code, reason) => left.close(code, reason), + listen: (receiver) => left.listen(receiver), + }), + server.attach(right), + ]); + return { + client, + server, + writes: () => writes, + frames, + close: () => { + client.close(); + server.close(); + }, + }; +} + +test('mounted wire keeps independent c:1 origins and cancellation', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + const started = { first: deferred(), second: deferred() }, + cancelled = deferred(), + finish = deferred(); + handle(server, ['worker', 'run'], async (params, context) => { + const name = params as 'first' | 'second'; + started[name].resolve(); + const stopped = deferred(); + context.signal.addEventListener( + 'abort', + () => { + cancelled.resolve(name); + stopped.resolve(); + }, + { once: true }, + ); + await Promise.race([finish.promise, stopped.promise]); + return name; + }); + const selected = at(mount(new Map([['service', pair.client.wire()]])), ['service', 'worker']); + const sent: Message[] = []; + const opaque: AddressedWire = { + send: (path, message) => { + sent.push(message); + selected.send(path, message); + }, + }; + assert.equal(pair.client.wire(), pair.client.wire()); + const controller = new AbortController(); + const first = call(opaque, ['run'], 'first', { signal: controller.signal }); + const firstResult = first.catch((error: unknown) => error); + const second = call(opaque, ['run'], 'second'); + await Promise.all([started.first.promise, started.second.promise]); + assert.equal(sent[0]!.frame.kind, 'request'); + assert.equal(sent[1]!.frame.kind, 'request'); + assert.equal('id' in sent[0]!.frame && sent[0]!.frame.id, 'c:1'); + assert.equal('id' in sent[1]!.frame && sent[1]!.frame.id, 'c:1'); + assert.notEqual(sent[0]!.return, sent[1]!.return); + controller.abort(); + assert.equal(((await firstResult) as PublicError).code, 'cancelled'); + assert.equal(await cancelled.promise, 'first'); + handle(server, ['worker', 'echo'], (value) => value); + assert.equal(await call(opaque, ['echo'], 'still open'), 'still open'); + finish.resolve(); + assert.equal(await second, 'second'); +}); + +test('wire paths preserve opaque scalar segments over the existing envelope', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + for (const [path, value] of [ + [['a.b'], 'one'], + [['a', 'b'], 'two'], + [[''], 'empty'], + [['๐Ÿ˜€', '\ufeff'], 'unicode'], + ] as const) { + handle(server, path, () => value); + } + for (const [path, value] of [ + [['a.b'], 'one'], + [['a', 'b'], 'two'], + [[''], 'empty'], + [['๐Ÿ˜€', '\ufeff'], 'unicode'], + ] as const) { + assert.equal(await call(pair.client.wire(), path), value); + } +}); + +test('call and event helpers accept an empty suffix at a selected leaf', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + handle(server, ['a.b'], (value) => value); + assert.equal(await call(at(pair.client.wire(), ['a.b']), [], 'selected'), 'selected'); + const arrived = deferred(); + onEvent(server, [''], (value) => { + arrived.resolve(value); + }); + emit(at(pair.client.wire(), ['']), [], 'event'); + assert.equal(await arrived.promise, 'event'); +}); + +test('wire root queues asynchronously and preserves request/event send order', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + const seen: string[] = [], + event = deferred(); + handle(server, ['first'], () => { + seen.push('request'); + return null; + }); + onEvent(server, ['second'], () => { + seen.push('event'); + event.resolve(); + }); + const first = call(pair.client.wire(), ['first']); + emit(pair.client.wire(), ['second']); + assert.equal(pair.writes(), 0, 'wire send entered the physical writer inline'); + assert.deepEqual(seen, []); + await Promise.all([first, event.promise]); + assert.deepEqual( + pair.frames.map((frame) => frame.kind), + ['request', 'event'], + ); + assert.deepEqual([...seen].sort(), ['event', 'request']); +}); + +test('wire carries same-call metadata but outgoing application calls copy it only explicitly', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + const client = createDispatcher(pair.client.wire()); + t.after(() => client.close()); + handle(client, ['echoMeta'], (_value, context) => context.meta ?? null); + handle(server, ['relay'], async (_value, context) => ({ + received: context.meta, + implicit: await call(context.wire, ['echoMeta'], null, { context }), + explicit: await call(context.wire, ['echoMeta'], null, { context, meta: context.meta }), + })); + assert.deepEqual(await call(pair.client.wire(), ['relay'], null, { meta: { tenant: 'one' } }), { + received: { tenant: 'one' }, + implicit: null, + explicit: { tenant: 'one' }, + }); +}); + +test('wire public refusals survive while dispatched nested publication proof does not', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + handle(server, ['refuse'], () => { + throw new UnpublishedError(new PublicError('denied', 'No', { why: 7 })); + }); + await assert.rejects(call(pair.client.wire(), ['refuse']), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error instanceof UnpublishedError, false); + assert.equal(error.code, 'denied'); + assert.deepEqual(error.data, { why: 7 }); + return true; + }); + await assert.rejects(call(pair.client.wire(), ['missing']), { code: 'method_not_found' }); + pair.client.close(); + await assert.rejects(call(pair.client.wire(), ['refuse']), (error: unknown) => error instanceof UnpublishedError); +}); + +test('wire full admission ends the root immediately and settles queued calls', async (t) => { + const pair = await paired({ queueCapacity: 1 }); + t.after(pair.close); + const first = call(pair.client.wire(), ['first']).catch((error: unknown) => error); + const second = call(pair.client.wire(), ['second']).catch((error: unknown) => error); + assert.equal(pair.client.status, 'disconnected'); + assert.ok((await first) instanceof PublicError); + assert.equal((await first) instanceof UnpublishedError, false); + assert.ok((await second) instanceof UnpublishedError); +}); + +test('wire close settles active calls and detaches registrations once', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + const started = deferred(), + cancelled = deferred(); + handle(server, ['held'], async (_value, context) => { + context.signal.addEventListener('abort', () => cancelled.resolve(), { once: true }); + started.resolve(); + await cancelled.promise; + return null; + }); + const response = call(pair.client.wire(), ['held']).catch((error: unknown) => error); + await started.promise; + pair.client.wire().close(1000, 'done'); + assert.equal(((await response) as PublicError).code, 'disconnected'); + await cancelled.promise; + assert.throws(() => pair.client.wire().receive({})); +}); + +test('mounted async event receivers keep serial order and peer backpressure accounting', async (t) => { + const pair = await paired({ queueCapacity: 2, writeTimeoutMs: 20 }); + t.after(pair.close); + const entered = deferred(), + unblock = deferred(), + ended = deferred(); + t.after(() => unblock.resolve()); + pair.server.onClose((error) => ended.resolve(error)); + const mounted = mount(new Map([['outer', pair.server.wire()]])); + const dispatcher = createDispatcher(mounted); + t.after(() => { + dispatcher.close(); + mounted.close(); + }); + const view = createDispatcher(dispatcher.select(['outer'])); + t.after(() => view.close()); + const seen: number[] = []; + onEvent(view, ['notice'], async (data) => { + seen.push(data as number); + entered.resolve(); + await unblock.promise; + }); + emit(pair.client.wire(), ['notice'], 1); + await entered.promise; + emit(pair.client.wire(), ['notice'], 2); + // Give the root admission queue its scheduled drain before the next frame. + await Promise.resolve(); + emit(pair.client.wire(), ['notice'], 3); + await ended.promise; + assert.deepEqual(seen, [1]); +}); + +test('a full wire queue preserves unpublished proof only for the refused attempt', async (t) => { + const [a, b] = pipe(); + let writes = 0, + buffered = 0; + const held: FrameConnection = { + get state() { + return a.state; + }, + get buffered() { + return buffered; + }, + send: (frame) => { + writes++; + a.send(frame); + buffered = 1; + }, + close: (code, reason) => a.close(code, reason), + listen: (receiver) => a.listen(receiver), + }; + const peer = new Peer({ queueCapacity: 1, writeTimeoutMs: 5_000 }); + await peer.attach(held); + t.after(() => { + peer.close(); + b.close(); + }); + const accepted = call(peer.wire(), ['accepted']).catch((error: unknown) => error); + await Promise.resolve(); + const queued = call(peer.wire(), ['queued']).catch((error: unknown) => error); + const rejected = await call(peer.wire(), ['rejected']).catch((error: unknown) => error); + const earlier = await accepted; + assert.equal(writes, 1); + assert.ok(!(earlier instanceof UnpublishedError), 'an accepted call inherited another attemptโ€™s proof'); + assert.ok(!((await queued) instanceof UnpublishedError), 'a queued call inherited another attemptโ€™s proof'); + assert.ok(rejected instanceof UnpublishedError, 'a rejected call lost its own pre-queue proof'); +}); + +test('wire handlers inherit verified receive context', async (t) => { + const pair = await paired({ + propagator: { + extract: (context, trace) => { + defaultPropagator.extract(context, trace); + Object.defineProperty(context, 'verified', { value: 'authenticated', enumerable: false }); + }, + inject: (context) => defaultPropagator.inject(context), + }, + }); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + let inherited: unknown; + handle(server, ['panic'], (_value, context) => { + inherited = Reflect.get(context, 'verified'); + throw new Error('deliberate handler panic'); + }); + await assert.rejects(call(pair.client.wire(), ['panic']), { code: 'internal' }); + assert.equal(inherited, 'authenticated'); +}); + +test('wire cancellation reserves bounded admission behind a full data queue', async (t) => { + const pair = await paired({ queueCapacity: 1 }); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + const started = deferred(), + cancelled = deferred(); + handle(server, ['held'], async (_value, context) => { + context.signal.addEventListener('abort', () => cancelled.resolve(), { once: true }); + started.resolve(); + await cancelled.promise; + return null; + }); + const controller = new AbortController(); + let request: Message | undefined; + const root = pair.client.wire(); + const selected: AddressedWire = { + send: (path, message) => { + if (message.frame.kind === 'request') request = message; + root.send(path, message); + }, + }; + const result = call(selected, ['held'], {}, { signal: controller.signal }).catch((error: unknown) => error); + await started.promise; + emit(root, ['fills-root'], null); + controller.abort(); + assert.equal(pair.client.status, 'connected', 'cancel closed a full data queue'); + const cancel: Message = { frame: { version: 1, kind: 'cancel', id: 'c:1' }, return: request!.return }; + for (let i = 0; i < 20; i++) root.send(['held'], cancel); + root.send(['unknown'], { ...cancel, return: { wire: root } }); + assert.equal(((await result) as PublicError).code, 'cancelled'); + await cancelled.promise; + assert.equal(pair.client.status, 'connected'); + assert.deepEqual( + pair.frames.map((frame) => frame.kind), + ['request', 'event', 'cancel'], + ); +}); + +test('completed calls retain their budget until a reserved cancellation is drained', async (t) => { + const listeners = new Set(), + sent: { id: string; kind: string }[] = []; + const connection: FrameConnection = { + state: 'open', + buffered: 0, + send: (frame) => { + if (frame.kind === 'text') sent.push(JSON.parse(frame.data)); + }, + close: () => {}, + listen: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + const peer = new Peer({ queueCapacity: 1, maxPendingRequests: 1 }); + await peer.attach(connection); + t.after(() => peer.close()); + const root = peer.wire(), + second = deferred(); + const returning = (onResponse: (message: Message) => void): ReturnAddress => ({ + wire: { + send: (_path, message) => onResponse(message), + }, + }); + const secondAddress = returning((message) => second.resolve(message)); + const firstAddress = returning(() => { + // The response resolves before the root's queued cancellation runs. That + // stale control still occupies its original request's bounded reservation. + root.send(['second'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: secondAddress }); + }); + root.send(['first'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: firstAddress }); + await Promise.resolve(); + assert.equal(sent.length, 1); + for (const listener of listeners) + listener.frame?.({ + kind: 'text', + data: JSON.stringify({ version: 1, kind: 'response', id: sent[0]!.id, result: null }), + }); + root.send(['first'], { frame: { version: 1, kind: 'cancel', id: 'c:1' }, return: firstAddress }); + const refused = await second.promise; + assert.equal(refused.frame.kind, 'response'); + assert.equal('error' in refused.frame && refused.frame.error?.code, 'busy'); + assert.equal(sent.length, 1, 'a stale cancellation or a call over budget reached the carrier'); + root.send(['third'], { frame: { version: 1, kind: 'request', id: 'c:1', params: null }, return: secondAddress }); + await Promise.resolve(); + assert.equal(sent.length, 2, 'drained control did not release its request reservation'); +}); + +test('root wire validates structured profile frames before admission without ending its carrier', async (t) => { + const pair = await paired({ queueCapacity: 1 }); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + let invoked = 0; + handle(server, ['checked'], (value) => { + invoked++; + return value; + }); + const root = pair.client.wire(); + const request = { version: 1, kind: 'request', id: 'c:1', params: null }; + const invalid: [string, unknown][] = [ + ['version', { ...request, version: 2 }], + ['kind', { ...request, kind: 'unknown' }], + ['extra member', { ...request, extra: true }], + ['duplicate method source', { ...request, method: 'other' }], + ['missing params', { version: 1, kind: 'request', id: 'c:1' }], + ['id prefix', { ...request, id: 'x:1' }], + ['id zero', { ...request, id: 'c:0' }], + ['trace', { ...request, traceparent: 'invalid' }], + ['meta', { ...request, meta: { invalid: 1 } }], + ['reserved meta', { ...request, meta: { 'nightseam.reserved': 'no' } }], + ['missing event data', { version: 1, kind: 'event' }], + ['duplicate event source', { version: 1, kind: 'event', event: 'other', data: null }], + ['cancel extra member', { version: 1, kind: 'cancel', id: 'c:1', params: null }], + ['null frame', null], + ]; + for (const [name, frame] of invalid) { + assert.throws( + () => root.send(['checked'], { frame, return: { wire: root } } as Message), + (error: unknown) => error instanceof PublicError && error.code === 'invalid_message', + name, + ); + assert.equal(pair.client.status, 'connected', name); + } + assert.equal(pair.writes(), 0); + assert.equal(invoked, 0); + // Local origins may use either logical prefix, independently of the carrier role. + for (const id of ['c:1', 's:1']) { + const reply = deferred(); + const returning: AddressedWire = { + send: (_path, message) => reply.resolve(message), + }; + root.send(['checked'], { frame: { version: 1, kind: 'request', id, params: id }, return: { wire: returning } }); + const response = await reply.promise; + assert.equal(response.frame.kind, 'response'); + assert.equal('result' in response.frame && response.frame.result, id); + } + assert.equal(invoked, 2); +}); + +test('root wire refuses oversized structured frames before accepting them', async (t) => { + const pair = await paired({ maxFrameBytes: 512, queueCapacity: 1 }); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + const root = pair.client.wire(); + assert.throws(() => root.send(['large'], { frame: { version: 1, kind: 'event', data: '๐Ÿ˜€'.repeat(200) } }), { + code: 'frame_too_large', + }); + assert.equal(pair.client.status, 'connected'); + assert.equal(pair.writes(), 0); + handle(server, ['small'], () => 'ok'); + assert.equal(await call(root, ['small']), 'ok'); +}); + +test('local wire return addresses validate responses before settling the call', async () => { + const controller = new AbortController(); + let address!: ReturnAddress; + const opaque: AddressedWire = { + send: (_path, message) => { + address = message.return!; + }, + }; + const pending = call(opaque, ['test'], undefined, { signal: controller.signal }); + const response = { version: 1, kind: 'response', id: 'c:1', result: null }; + const invalid: unknown[] = [ + { ...response, version: 2 }, + { ...response, extra: true }, + { version: 1, kind: 'response', id: 'c:1' }, + { ...response, error: { code: 'bad', message: 'Bad' } }, + { ...response, traceparent: 'invalid' }, + { version: 1, kind: 'response', id: 'c:1', error: { code: '', message: 'Bad' } }, + ]; + try { + for (const frame of invalid) { + assert.throws(() => address.wire.send([], { frame } as Message), { code: 'invalid_message' }); + } + address.wire.send([], { frame: { version: 1, kind: 'response', id: 'c:1', result: 'ok' } }); + assert.equal(await pending, 'ok'); + } finally { + controller.abort(); + await pending.catch(() => {}); + } +}); + +test('wire handlers sanitize empty public error fields without disconnecting', async (t) => { + const pair = await paired(); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + for (const [name, code, message] of [ + ['code', '', 'No'], + ['message', 'denied', ''], + ] as const) { + handle(server, [name], () => { + throw new PublicError(code, message); + }); + await assert.rejects(call(pair.client.wire(), [name]), { code: 'internal' }); + assert.equal(pair.client.status, 'connected'); + } +}); + +test('wire handler responses over the peer frame limit settle as internal errors', async (t) => { + const pair = await paired({ maxFrameBytes: 512 }); + t.after(pair.close); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + handle(server, ['large'], () => 'x'.repeat(1_024)); + await assert.rejects(call(pair.client.wire(), ['large'], {}, { timeoutMs: 100 }), { code: 'internal' }); + assert.equal(pair.client.status, 'connected'); +}); + +test('configured outgoing traces cross a local Wire and a physical peer verbatim', async (t) => { + // v0.6.0 held, through the observer, that the trace object a propagator + // minted keeps its identity as far as the physical peer; with observers + // removed nothing reads that identity, so this holds what crosses: the + // members verbatim, reconstructed as public values at the far side. + const trace = { traceparent: '00-11111111111111111111111111111111-2222222222222222-01', tracestate: 'vendor=kept' }; + const pair = await paired(); + const [access, binding] = localPair(); + t.after(() => { + access.close(); + pair.close(); + }); + const server = createDispatcher(pair.server.wire()); + t.after(() => server.close()); + forward(binding, pair.client.wire()); + let received: Trace | undefined; + handle(server, ['call'], (_params, context) => { + received = context.trace; + return null; + }); + const delivered = deferred(); + onEvent(server, ['event'], (_data, context) => delivered.resolve(context.trace)); + const propagator = { inject: () => trace, extract: () => {} }; + await call(access, ['call'], {}, { propagator }); + emit(access, ['event'], null, { propagator }); + const eventTrace = await delivered.promise; + const outgoing = pair.frames.filter((frame) => frame.kind === 'request' || frame.kind === 'event') as unknown as Trace[]; + assert.equal(outgoing.length, 2); + for (const frame of outgoing) { + assert.equal(frame.traceparent, trace.traceparent); + assert.equal(frame.tracestate, trace.tracestate); + } + // A frame reconstructed at the receiving carrier retains public values only. + for (const seen of [received, eventTrace]) { + assert.notEqual(seen, trace); + assert.deepEqual(seen, trace); + } +}); diff --git a/transports/ts/test/conformance.ts b/transports/ts/test/conformance.ts new file mode 100644 index 0000000..8fb343d --- /dev/null +++ b/transports/ts/test/conformance.ts @@ -0,0 +1,290 @@ +/** + * The suite every transport of the seam is held to in TypeScript: what a + * `FrameConnection` promises and nothing of the transport that keeps it, so + * that a peer written against the seam can trust the same things wherever it + * runs โ€” frames arrive in order and whole, in either direction, every + * listener is given every one of them and detaching one leaves the others, + * a close carries its code and its reason to both sides and refuses what is + * sent after it, and `buffered` counts what a send left with the transport + * and nothing once the transport has taken it. + * + * It is the twin of `transports/go/transporttest` and holds the seam as this + * language has it, which is not in every respect as Go has it. Two of the Go + * suite's promises are therefore not asked for here. A receive limit is not + * the seam's: a connection takes none, and an oversized frame is refused by + * the peer and by the tunnel, each with 1009 and each held to it by its own + * tests. An abort is not the seam's either โ€” the Go `Conn` has one and a + * `FrameConnection` has not, a close being how a connection ends here. Nor + * is the order of a last frame against the close behind it: a transport may + * deliver the close first, so a peer that needs a frame seen sends it and + * waits rather than closing on top of it. + * + * The Go suite's remaining promise, that a send waits while the receiver does + * not receive, is asked here of every transport that holds anything at all: + * `send` cannot wait in this language, so what a transport cannot hand on it + * holds, and `buffered` counts it until the far end takes it โ€” the pipe past + * its bound, a channel past the other side's window. A transport that hands + * every frame to something else inside the send holds nothing and reads zero + * throughout; it says so with `holds: false` and the case then asks it only + * what it can keep. + * + * A transport's own test calls `run` with a name and a way to make a + * connected pair: `transports/ts` runs it over the in-memory pipe and over the + * WebSocket adapter, as the Go packages run the Go suite. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Frame, FrameConnection } from '../src/index.ts'; + +/** A connected pair and what ends it, whatever the suite left it in. */ +export interface Pair { + a: FrameConnection; + b: FrameConnection; + /** Ends both ends and whatever carries them; the suite calls it once, closed or not. */ + end(): void; +} + +/** Makes a fresh connected pair. The suite ends what it opened. */ +export type Connect = () => Promise; + +/** What a transport keeps, where the suite cannot ask it of every one. */ +export interface Keeps { + /** + * Whether a frame the far end has not taken is held by the transport and + * counted by `buffered`. True of the pipe, which bounds what it has in + * flight, and of a tunnel channel, which bounds it by the other side's + * window; false of an adapter that hands every frame on inside the send, as + * a socket does, which holds nothing and reads zero throughout. + */ + holds?: boolean; +} + +/** How long a promise of the seam is given before the suite calls it broken. */ +const DEADLINE = 5_000; + +/** Run holds a transport to the seam, naming it in every test it fails. */ +export function run(what: string, connect: Connect, keeps: Keeps = {}): void { + test(`${what}: frames arrive in order and whole, text and binary alike`, async () => { + const pair = await connect(); + try { + const seen = collect(pair.b); + const sent: Frame[] = []; + for (let i = 0; i < 64; i++) { + const text = `frame ${String(i).padStart(2, '0')}`; + const frame: Frame = + i % 3 === 0 ? { kind: 'binary', data: new TextEncoder().encode(text) } : { kind: 'text', data: text }; + sent.push(frame); + pair.a.send(frame); + } + await eventually('64 frames arrive', () => seen.frames.length >= 64); + assert.equal(seen.closed, undefined, 'the connection closed while the frames were crossing'); + assert.equal(seen.frames.length, 64, 'more frames arrived than were sent'); + for (let i = 0; i < 64; i++) same(seen.frames[i]!, sent[i]!, `frame ${i}`); + } finally { + pair.end(); + } + }); + + test(`${what}: a frame arrives as what it carried, empty or multi-byte or every byte there is`, async () => { + const pair = await connect(); + try { + const seen = collect(pair.b); + const sent: Frame[] = [ + { kind: 'text', data: '' }, + { kind: 'text', data: 'รค โ˜ƒ ๐„ž โ€” one frame, not the pieces of one' }, + { kind: 'binary', data: new Uint8Array(0) }, + { kind: 'binary', data: Uint8Array.from({ length: 256 }, (_, byte) => byte) }, + ]; + for (const frame of sent) pair.a.send(frame); + await eventually('four frames arrive', () => seen.frames.length >= sent.length); + assert.equal(seen.frames.length, sent.length, 'more frames arrived than were sent'); + for (let i = 0; i < sent.length; i++) same(seen.frames[i]!, sent[i]!, `frame ${i}`); + } finally { + pair.end(); + } + }); + + test(`${what}: both sides send, and each is given the other's frames alone`, async () => { + const pair = await connect(); + try { + const atA = collect(pair.a); + const atB = collect(pair.b); + for (let i = 0; i < 3; i++) { + pair.a.send({ kind: 'text', data: `a to b ${i}` }); + pair.b.send({ kind: 'text', data: `b to a ${i}` }); + } + await eventually('three frames arrive each way', () => atA.frames.length >= 3 && atB.frames.length >= 3); + assert.deepEqual( + atB.frames, + [0, 1, 2].map((i) => ({ kind: 'text', data: `a to b ${i}` })), + 'what b was given', + ); + assert.deepEqual( + atA.frames, + [0, 1, 2].map((i) => ({ kind: 'text', data: `b to a ${i}` })), + 'what a was given', + ); + } finally { + pair.end(); + } + }); + + test(`${what}: every listener is given every frame, and the function listen returns detaches that one alone`, async () => { + const pair = await connect(); + try { + const first: Frame[] = []; + const second: Frame[] = []; + const detach = pair.b.listen({ + frame: (frame) => { + first.push(frame); + }, + }); + pair.b.listen({ + frame: (frame) => { + second.push(frame); + }, + }); + pair.a.send({ kind: 'text', data: 'to both' }); + await eventually('the frame reaches both listeners', () => first.length >= 1 && second.length >= 1); + detach(); + pair.a.send({ kind: 'text', data: 'to the one still listening' }); + await eventually('the second frame reaches the listener that stayed', () => second.length >= 2); + assert.deepEqual( + second.map((frame) => frame.data), + ['to both', 'to the one still listening'], + ); + assert.deepEqual( + first.map((frame) => frame.data), + ['to both'], + 'a detached listener was given a frame', + ); + } finally { + pair.end(); + } + }); + + test(`${what}: a close carries its code and its reason to both sides, and nothing crosses after it`, async () => { + const pair = await connect(); + try { + assert.equal(pair.a.state, 'open', 'a fresh connection was not open'); + assert.equal(pair.b.state, 'open', 'the other end of a fresh connection was not open'); + const closing = collect(pair.a); + const far = collect(pair.b); + const closed = { code: 4010, reason: 'an observer sent a deciding frame' }; + pair.a.close(closed.code, closed.reason); + await eventually('the close reaches the far side', () => far.closed !== undefined); + assert.deepEqual(far.closed, closed, 'the close arrived as something else'); + await eventually('the close reaches the side that closed', () => closing.closed !== undefined); + assert.deepEqual(closing.closed, closed, 'the side that closed was told something else'); + assert.equal(pair.a.state, 'closed', 'the side that closed is not closed'); + await eventually('the far side is closed', () => pair.b.state === 'closed'); + assert.throws(() => pair.a.send({ kind: 'text', data: 'late' }), 'the side that closed sent a frame'); + assert.throws(() => pair.b.send({ kind: 'text', data: 'late' }), 'the side that was closed sent a frame'); + // A close is one close: closing again tells nobody a second time, so a + // protocol above may close what it has already closed without minding. + pair.a.close(1000, 'again'); + pair.b.close(1000, 'again'); + await settled(); + assert.deepEqual(closing.closed, closed, 'a second close was a second close'); + assert.deepEqual(far.closed, closed, 'a second close reached the far side'); + assert.equal(far.frames.length, 0, 'a frame arrived that nobody sent'); + } finally { + pair.end(); + } + }); + + test(`${what}: buffered counts what the transport has not taken, and nothing once it has`, async () => { + const pair = await connect(); + try { + const seen = collect(pair.b); + assert.equal(pair.a.buffered, 0, 'a fresh connection had something buffered'); + const payload = 'x'.repeat(1024); + for (let i = 0; i < 16; i++) pair.a.send({ kind: 'text', data: payload }); + assert.ok(Number.isFinite(pair.a.buffered) && pair.a.buffered >= 0, `buffered is ${pair.a.buffered}`); + await eventually('16 frames arrive', () => seen.frames.length >= 16); + await eventually('what arrived is no longer buffered', () => pair.a.buffered === 0); + } finally { + pair.end(); + } + }); + + test(`${what}: what the far end has not taken is held and counted, and handed on in order once it takes it`, async () => { + const pair = await connect(); + try { + // Nobody listens on b, so nothing of what a sends is taken: a transport + // that bounds what it has in flight holds the rest and says how much in + // buffered, rather than taking without bound as a queue of its own. + const sent: Frame[] = []; + for (let i = 0; i < 40; i++) { + const frame: Frame = { kind: 'text', data: `held ${String(i).padStart(2, '0')}` }; + sent.push(frame); + pair.a.send(frame); + } + const held = pair.a.buffered; + if (keeps.holds ?? true) { + assert.ok(held > 0, `40 frames nothing took left ${held} buffered: the transport takes without bound`); + } + // The far end takes them: what was held is handed on, in order and whole, + // and buffered reads zero once it has. + const seen = collect(pair.b); + await eventually(`the ${held} frames the transport held are handed on`, () => seen.frames.length >= sent.length); + await eventually('what the far end took is no longer buffered', () => pair.a.buffered === 0); + assert.equal(seen.frames.length, sent.length, 'more frames arrived than were sent'); + for (let i = 0; i < sent.length; i++) same(seen.frames[i]!, sent[i]!, `frame ${i}`); + } finally { + pair.end(); + } + }); +} + +/** The frames a connection delivered and the close it was given, in order. */ +function collect(connection: FrameConnection) { + const frames: Frame[] = []; + let closed: { code: number; reason: string } | undefined; + connection.listen({ + frame: (frame) => { + frames.push(frame); + }, + close: (code, reason) => { + closed = { code, reason }; + }, + }); + return { + frames, + get closed() { + return closed; + }, + }; +} + +/** Waits for what the seam promises, and fails naming it where it does not happen. */ +async function eventually(what: string, ready: () => boolean): Promise { + const deadline = Date.now() + DEADLINE; + while (!ready()) { + if (Date.now() > deadline) assert.fail(`${what}: not within ${DEADLINE}ms`); + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + } +} + +/** Gives a transport every turn it could need to deliver what it should not. */ +async function settled(): Promise { + for (let i = 0; i < 5; i++) + await new Promise((resolve) => { + setTimeout(resolve, 2); + }); +} + +/** Holds a frame to the one that was sent: the kind, and the string or the bytes. */ +function same(got: Frame, want: Frame, at: string): void { + assert.equal(got.kind, want.kind, `${at} arrived as a ${got.kind} frame`); + if (got.kind === 'text' && want.kind === 'text') assert.equal(got.data, want.data, `${at} arrived as ${got.data}`); + else if (got.kind === 'binary' && want.kind === 'binary') + assert.deepEqual(bytes(got.data), bytes(want.data), `${at} arrived as other bytes`); +} + +/** The bytes of a binary frame, whichever of the two shapes a transport delivers. */ +function bytes(data: ArrayBuffer | Uint8Array): Uint8Array { + return data instanceof Uint8Array ? data : new Uint8Array(data); +} diff --git a/transports/ts/test/transports.test.ts b/transports/ts/test/transports.test.ts new file mode 100644 index 0000000..715c42c --- /dev/null +++ b/transports/ts/test/transports.test.ts @@ -0,0 +1,356 @@ +// The seam's two transports in TypeScript: the in-memory pipe and the +// WebSocket adapter, each held to the suite every transport is held to, and +// then the adapter held to what it alone does โ€” the shapes a socket delivers +// a message in, the close event's code and reason, and the socket surface it +// reads state and buffered off. The suite is the twin of transporttest, which +// transports/go runs over the pipe and transports/websocket/go over a real socket. +// Ported from Nightseam v0.6.0 duplex/ts/src/duplex.test.ts. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { run } from './conformance.ts'; +import { CODE_PROTOCOL, NO_STATUS, pipe, sendable, webSocketConnection } from '../src/index.ts'; +import type { Frame, WebSocketLike } from '../src/index.ts'; + +run('the in-memory pipe', async () => { + const [a, b] = pipe(); + return { + a, + b, + end: () => { + a.close(); + }, + }; +}); + +// A socket takes every frame inside the send and holds none of it back, so +// the suite asks it nothing of what a transport holds; bufferedAmount is the +// socket's own and the adapter is held to reading it below. +run( + 'a WebSocket', + async () => { + const [left, right] = Socket.pair(); + return { + a: webSocketConnection(left), + b: webSocketConnection(right), + end: () => { + left.close(); + }, + }; + }, + { holds: false }, +); + +test('the pipe delivers in a later turn, never inside the send', async () => { + const [a, b] = pipe(); + const frames: Frame[] = []; + b.listen({ + frame: (frame) => { + frames.push(frame); + }, + }); + a.send({ kind: 'text', data: 'now' }); + assert.deepEqual(frames, [], 'a pipe delivered a frame inside the send'); + await settled(); + assert.deepEqual(frames, [{ kind: 'text', data: 'now' }]); + a.close(); +}); + +test('the pipe takes eight frames in flight and holds what a send leaves past them until the far end takes one', async () => { + const [a, b] = pipe(); + // Nobody listens on b, so nothing is taken: the bound is what the transport + // itself takes, and it is the Go pipe's eight. + for (let i = 0; i < 8; i++) a.send({ kind: 'text', data: `frame ${i}` }); + assert.equal(a.buffered, 0, 'eight frames in flight left something buffered'); + a.send({ kind: 'text', data: 'frame 8' }); + assert.equal(a.buffered, 1, 'a ninth frame was taken as though the transport had room for it'); + a.send({ kind: 'text', data: 'frame 9' }); + assert.equal(a.buffered, 2, 'what a send leaves past the bound is not counted'); + await settled(); + assert.equal(a.buffered, 2, 'what nobody takes stopped being buffered on its own'); + const frames: Frame[] = []; + b.listen({ + frame: (frame) => { + frames.push(frame); + }, + }); + await settled(); + assert.equal(a.buffered, 0, 'what the far end took is still counted as buffered'); + assert.deepEqual( + frames.map((frame) => frame.data), + Array.from({ length: 10 }, (_, i) => `frame ${i}`), + 'what was held was handed on out of order, or lost', + ); + a.close(); +}); + +test('a send on a closed pipe throws, and nothing it held is counted', async () => { + const [a, b] = pipe(); + a.send({ kind: 'text', data: 'never taken' }); + a.close(4010, 'a policy of the family'); + assert.equal(a.buffered, 0, 'a closed connection buffers what it can never hand on'); + assert.equal(b.state, 'closed', 'the far end of a closed pipe is open'); + assert.throws(() => a.send({ kind: 'text', data: 'late' }), /not open/); +}); + +test('a socket that opens says so, and a string, an ArrayBuffer and a Uint8Array are the two kinds of frame', async () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + const frames: Frame[] = []; + let opened = 0; + connection.listen({ + open: () => { + opened++; + }, + frame: (frame) => { + frames.push(frame); + }, + }); + socket.dispatchEvent(new Event('open')); + socket.receive('a string'); + socket.receive(new Uint8Array([1, 2, 3]).buffer); + socket.receive(new Uint8Array([4, 5])); + await settled(); + assert.equal(opened, 1, 'the open event reached the handler once'); + assert.deepEqual( + frames.map((frame) => frame.kind), + ['text', 'binary', 'binary'], + ); + assert.equal(frames[0]!.data, 'a string'); + assert.deepEqual(new Uint8Array(frames[1]!.data as ArrayBuffer), new Uint8Array([1, 2, 3])); + assert.deepEqual(frames[2]!.data, new Uint8Array([4, 5])); +}); + +test('a Blob is read as the bytes it holds, and what a socket delivered after it waits behind it', async () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + const frames: Frame[] = []; + connection.listen({ + frame: (frame) => { + frames.push(frame); + }, + }); + socket.receive(new Blob([new Uint8Array([7, 8, 9])])); + socket.receive('behind the blob'); + await settled(); + assert.deepEqual( + frames.map((frame) => frame.kind), + ['binary', 'text'], + 'a frame overtook the blob before it', + ); + assert.deepEqual(new Uint8Array(frames[0]!.data as ArrayBuffer), new Uint8Array([7, 8, 9])); + assert.equal(frames[1]!.data, 'behind the blob'); +}); + +test('binaryType is asked for arraybuffer, and a socket that refuses it is adapted all the same', () => { + const socket = new Socket(); + webSocketConnection(socket); + assert.equal(socket.binaryType, 'arraybuffer', 'binary would arrive as a Blob, a turn behind everything else'); + const refusing = new Socket(); + Object.defineProperty(refusing, 'binaryType', { + get: () => 'blob', + set: () => { + throw new Error('Not settable here.'); + }, + }); + const connection = webSocketConnection(refusing); + assert.equal(connection.state, 'open', 'a socket that refuses binaryType was not adapted'); +}); + +test("a message of no shape the seam knows is an error and no frame, as is the socket's own error", async () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + const frames: Frame[] = []; + let errors = 0; + connection.listen({ + frame: (frame) => { + frames.push(frame); + }, + error: () => { + errors++; + }, + }); + socket.receive(42); + socket.dispatchEvent(new Event('error')); + await settled(); + assert.equal(errors, 2, 'a message of no shape and an error event are two errors'); + assert.deepEqual(frames, [], 'a message of no shape became a frame'); +}); + +test("the close event's code and reason reach the handler, and a close that carries neither is 1005", async () => { + const told = new Socket(); + const toldConnection = webSocketConnection(told); + let closed: { code: number; reason: string } | undefined; + toldConnection.listen({ + close: (code, reason) => { + closed = { code, reason }; + }, + }); + told.close(4011, 'the duplex profile closed it'); + await settled(); + assert.deepEqual(closed, { code: 4011, reason: 'the duplex profile closed it' }); + + const silent = new Socket(); + const silentConnection = webSocketConnection(silent); + let none: { code: number; reason: string } | undefined; + silentConnection.listen({ + close: (code, reason) => { + none = { code, reason }; + }, + }); + silent.readyState = 3; + silent.dispatchEvent(new Event('close')); + await settled(); + assert.deepEqual(none, { code: 1005, reason: '' }, "a close event with no code is the registry's no status present"); +}); + +test('the handlers are detached when the connection closes, so a message after it reaches nobody', async () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + const frames: Frame[] = []; + connection.listen({ + frame: (frame) => { + frames.push(frame); + }, + }); + socket.close(1000, ''); + socket.readyState = 1; + socket.receive('after the close'); + await settled(); + assert.deepEqual(frames, [], 'a message after the close reached a handler'); +}); + +test("state is the socket's readyState and buffered its bufferedAmount, and a send it cannot take throws", () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + for (const [readyState, state] of [ + [0, 'connecting'], + [1, 'open'], + [2, 'closing'], + [3, 'closed'], + [9, 'closed'], + ] as const) { + socket.readyState = readyState; + assert.equal(connection.state, state, `readyState ${readyState}`); + } + socket.readyState = 1; + socket.bufferedAmount = 4096; + assert.equal(connection.buffered, 4096); + socket.readyState = 2; + assert.throws(() => connection.send({ kind: 'text', data: 'not now' }), /not open/); +}); + +test('close passes the code and the reason it was given, and normal closure where it was given none', () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + connection.close(); + assert.deepEqual(socket.closes, [{ code: 1000, reason: '' }]); + const second = new Socket(); + webSocketConnection(second).close(4010, 'a policy of the family'); + assert.deepEqual(second.closes, [{ code: 4010, reason: 'a policy of the family' }]); +}); + +// R27: a close code that only describes what a side observed is never sent. + +test('only codes the registry lets a side send are sendable', () => { + for (const code of [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 3000, 4000, 4011, 4999]) + assert.equal(sendable(code), true, `${code}`); + for (const code of [0, 999, 1004, NO_STATUS, 1006, 1015, 1016, 2999, 5000, 1000.5, Number.NaN]) + assert.equal(sendable(code), false, `${code}`); +}); + +test('a pipe refuses an observe-only close code, sends nothing and stays open', async () => { + const [a, b] = pipe(); + const frames: Frame[] = []; + let closed: { code: number; reason: string } | undefined; + b.listen({ + frame: (frame) => { + frames.push(frame); + }, + close: (code, reason) => { + closed = { code, reason }; + }, + }); + for (const code of [NO_STATUS, 1006, 1015, 1004, 5000]) { + assert.throws(() => a.close(code, 'observed'), RangeError, `${code}`); + assert.throws(() => b.close(code, 'observed'), RangeError, `${code}`); + } + assert.equal(a.state, 'open'); + assert.equal(b.state, 'open'); + await settled(); + assert.equal(closed, undefined, 'an observe-only code reached the far side'); + a.send({ kind: 'text', data: 'still open' }); + await settled(); + assert.deepEqual(frames, [{ kind: 'text', data: 'still open' }]); + a.close(CODE_PROTOCOL, 'sent'); + assert.deepEqual(closed, { code: CODE_PROTOCOL, reason: 'sent' }); +}); + +test('the WebSocket adapter refuses an observe-only close code before the socket sees it', () => { + const socket = new Socket(); + const connection = webSocketConnection(socket); + for (const code of [NO_STATUS, 1006, 1015]) assert.throws(() => connection.close(code, 'observed'), RangeError); + assert.deepEqual(socket.closes, [], 'the socket was asked to send an observe-only code'); + assert.equal(connection.state, 'open'); + connection.close(1001, 'going'); + assert.deepEqual(socket.closes, [{ code: 1001, reason: 'going' }]); +}); + +/** + * A socket pair in memory, as faithful to a WebSocket as the adapter reads + * one: a message is delivered in a later turn, binary in the shape + * binaryType asks for, and a close ends both ends with the code and the + * reason the closing side gave. No WebSocket is involved anywhere, as in the + * runtime's own suites. + */ +class Socket extends EventTarget implements WebSocketLike { + readyState = 1; + bufferedAmount = 0; + binaryType = 'blob'; + readonly closes: { code: number; reason: string }[] = []; + partner?: Socket; + + static pair(): [Socket, Socket] { + const left = new Socket(); + const right = new Socket(); + left.partner = right; + right.partner = left; + return [left, right]; + } + + send(data: string | ArrayBuffer | Uint8Array): void { + if (this.readyState !== 1) throw new Error('The socket is not open.'); + const partner = this.partner; + if (!partner) return; + const delivered = typeof data === 'string' ? data : partner.shape(data); + queueMicrotask(() => { + if (partner.readyState === 1) partner.receive(delivered); + }); + } + + /** Delivers one message as a socket does, whatever the test made of it. */ + receive(data: unknown): void { + this.dispatchEvent(new MessageEvent('message', { data })); + } + + close(code = 1000, reason = ''): void { + this.closes.push({ code, reason }); + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(Object.assign(new Event('close'), { code, reason })); + this.partner?.close(code, reason); + } + + /** Binary arrives as binaryType asks: an ArrayBuffer of its own bytes, or a Blob. */ + private shape(data: ArrayBuffer | Uint8Array): ArrayBuffer | Blob { + const bytes = data instanceof Uint8Array ? data.slice() : new Uint8Array(data).slice(); + return this.binaryType === 'blob' ? new Blob([bytes]) : bytes.buffer; + } +} + +/** Every turn a socket or a blob could need; nothing of the seam is slower. */ +async function settled(): Promise { + for (let i = 0; i < 5; i++) + await new Promise((resolve) => { + setTimeout(resolve, 2); + }); +} From a5de95547e984ec488d500d3c3738d659fea61e7 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:26:20 +0200 Subject: [PATCH 31/39] build, docs: check, test, smoke and release the TypeScript package from the root CI and the release workflow run npm ci, check, build and test at the repository root and pack the root package. The package smoke installs the packed @bitspark/bitruntime into a fresh consumer, runs the tree smoke through /core, a pair and dispatcher call through /core and /dispatch and a peer over a pipe through /engine and /transports, and holds that no import reaches the private internals. RELEASING.md, docs/RELEASE.md, the READMEs and LAYOUT.md record the one package and its subpaths; nothing is released. docs/port-from-nightseam.md gains the TypeScript names and the TypeScript-specific behavior changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 +---- .github/workflows/release.yml | 7 +++--- README.md | 7 +++--- RELEASING.md | 14 ++++++++---- core/ts/README.md | 12 ++++++---- docs/RELEASE.md | 9 ++++++++ docs/port-from-nightseam.md | 34 +++++++++++++++++++++++++++++ scripts/package-smoke.mjs | 41 ++++++++++++++++++++++++++++++----- 8 files changed, 104 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dac9e1..0d27739 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,17 +23,13 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: core/ts/package-lock.json + cache-dependency-path: package-lock.json - name: Go formatting run: test -z "$(gofmt -l core/go)" - run: go vet ./... - run: go test -race -count=1 ./... - run: npm ci --ignore-scripts - working-directory: core/ts - run: npm run check - working-directory: core/ts - run: npm run build - working-directory: core/ts - run: npm test - working-directory: core/ts - run: node scripts/package-smoke.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec82d96..c20abc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,19 +21,18 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: core/ts/package-lock.json + cache-dependency-path: package-lock.json - name: Verify version - run: test "$GITHUB_REF_NAME" = "v$(node -p "require('./core/ts/package.json').version")" + run: test "$GITHUB_REF_NAME" = "v$(node -p "require('./package.json').version")" - run: go vet ./... - run: go test -race -count=1 ./... - run: npm ci --ignore-scripts && npm run check && npm run build && npm test - working-directory: core/ts - run: node scripts/package-smoke.mjs - run: node scripts/go-smoke.mjs "$GITHUB_REF_NAME" - name: Pack release run: | mkdir release - npm pack ./core/ts --pack-destination release + npm pack . --pack-destination release cd release sha256sum *.tgz > SHA256SUMS - name: Publish source and package artifacts diff --git a/README.md b/README.md index 2a52b26..02d2393 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,10 @@ Go uses one module, `github.com/Bitspark/bitruntime`, released by root tags: A program links only the packages it imports; `coder/websocket` and `net/http` enter only through the WebSocket packages. TypeScript uses one package, -`@bitspark/bitruntime`, with the subpaths `./core`, `./transports`, `./engine` -and `./dispatch`, so the received context its components share stays private -to the package. Both depend on the public Bitwire 0.3.0 contract. Releases +`@bitspark/bitruntime`, built at the repository root with the subpaths +`./core`, `./transports`, `./engine` and `./dispatch`, so the received context +its components share stays private to the package. (v0.1.0 shipped the +structural core alone as `@bitspark/bitruntime-core`.) Both depend on the public Bitwire 0.3.0 contract. Releases publish a root Go tag and a TypeScript tarball with checksums on GitHub; npm registry publication is not configured. Read [RELEASING.md](RELEASING.md). diff --git a/RELEASING.md b/RELEASING.md index b2720c8..bd8fb14 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,7 +7,12 @@ Nightseam consumer migration. Coordinates: - Go module `github.com/Bitspark/bitruntime`, package `core/go`. -- TypeScript package `@bitspark/bitruntime-core`, source and manifest `core/ts`. +- TypeScript package `@bitspark/bitruntime-core`, source and manifest `core/ts`, + for v0.1.0. From 0.2.0 the TypeScript implementation is one package, + `@bitspark/bitruntime`, whose manifest and lockfile are at the repository + root; its sources stay under `/ts/src` and it exports the + subpaths `@bitspark/bitruntime/core`, `/transports`, `/engine` and + `/dispatch`. - The root `v0.1.0` tag versions this initial module pair together. Further runtime components require an explicit module/versioning decision. @@ -21,7 +26,7 @@ Confirm the actual Bitwire dependency release is publicly installable and run the same checks locally where supported. Validate a fresh Go consumer against the pushed commit, then against the final tag. -Keep `core/ts/package.json`, its lockfile and `docs/RELEASE.md` consistent. Create +Keep the root `package.json`, its lockfile and `docs/RELEASE.md` consistent. Create an annotated root version tag on the verified main commit and push it. The tag workflow repeats validation, packages TypeScript, and publishes the tarball plus `SHA256SUMS` on a GitHub release. The Go module is published through the Git tag. @@ -29,8 +34,9 @@ Verify the run, artifact checksum, fresh consumer installs and remote tag before reporting a release. The tarball is an npm-compatible package, not an npm registry publication. -This initial process does not claim `npm install @bitspark/bitruntime-core` -works until registry publication is separately configured and verified. Use +This process does not claim `npm install @bitspark/bitruntime` (or +`@bitspark/bitruntime-core`) works until registry publication is separately +configured and verified. Use the GitHub release artifact URL for that package in the meantime. Never move an existing release tag or overwrite release assets to repair a diff --git a/core/ts/README.md b/core/ts/README.md index 1752866..0547f1e 100644 --- a/core/ts/README.md +++ b/core/ts/README.md @@ -1,12 +1,16 @@ -# @bitspark/bitruntime-core +# @bitspark/bitruntime/core -The TypeScript structural runtime for Bitwire 0.3.0. It depends only on the +The TypeScript structural runtime for Bitwire 0.3.0, the `core` subpath of the +`@bitspark/bitruntime` package (v0.1.0 shipped it as `@bitspark/bitruntime-core`). +The same subpath also exports the addressed operators (`at`, `mount`, +`forward`), the local `pair`, the invocation lifecycle, `PublicError`, +`respond` and trace propagation, which docs/port-from-nightseam.md describes. It depends only on the public Bitwire contract and implements the common finite, acyclic, byte-keyed Deixis structure. It does not implement carriers, RPC, or receive/close ownership. ```ts import type { Message, Wire } from '@bitspark/bitwire'; -import { asAddressed, compose, select, send } from '@bitspark/bitruntime-core'; +import { asAddressed, compose, select, send } from '@bitspark/bitruntime/core'; const destination: Wire = { send(message: Message) { /* admit the message */ } }; const key = new TextEncoder().encode('child'); @@ -48,7 +52,7 @@ usable through structural paths but cannot be named by this unchanged carrier profile. The adapter exposes no receiver or lifecycle access. There is no inverse conversion from an opaque addressed router to a complete tree. -Run `npm ci`, `npm run check`, and `npm test`. The tests include an observation +Run `npm ci`, `npm run check`, and `npm test` at the repository root. The tests include an observation driver against Bitwire's independently maintained full-tree oracle from `conformance/trees/expected.json` in contract 0.3.0, plus construction, identity, cycle, byte-key, deep traversal, refusal, and Unicode bridge checks. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 969c768..f85dc97 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -20,3 +20,12 @@ declaration. Data reading and storage remain Bitstore's responsibility. Go is available through the `v0.1.0` module tag. The TypeScript package is published as a GitHub release tarball with a SHA-256 checksum; it is not yet published to an npm registry. + +## Unreleased: one TypeScript package + +The next TypeScript version, 0.2.0, is one package, `@bitspark/bitruntime`, +built from the repository root. It replaces `@bitspark/bitruntime-core` and +exports four subpaths: `@bitspark/bitruntime/core` (the structural core, the +addressed operators, the local pair and the invocation lifecycle), +`/transports`, `/engine` (the `bitwire/1` peer) and `/dispatch`. It is not +released yet. diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index 598a7f5..3951716 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -49,6 +49,26 @@ tables, byte for byte. | `runtime.Accept`, `NewHandler`, `Dial`, `ServerOptions`, `DialOptions` | `websocket.Accept`, `NewHandler`, `Dial`, โ€ฆ (`engine/websocket/go`) | | `peer.Wire()` | `peer.Wire()`, an `Endpoint` over `bitwire.AddressedWire` | +## TypeScript names + +One package, `@bitspark/bitruntime`, exports the subpaths `/core`, +`/transports`, `/engine` and `/dispatch`. The received-context machinery, +frame validation, the envelope codec, path encoding and the Unicode guard live +in `core/ts/src/internal` and no subpath exports them. + +| v0.6.0 | bitruntime | +| --- | --- | +| `@nightseam/duplex` `Frame`, `FrameConnection`, `pipe`, `webSocketConnection`, `NO_STATUS` | `/transports`, with the close-code constants and `sendable` | +| `@nightseam/duplex` `at`, `mount`; `forwardWire` | `/core` `at`, `mount`, `forward` | +| `WireError` `'no_route'`, `'invalid_path'`, `'receiver_exists'` | `MissingPathError`, `InvalidPathError`, `ReceiverExistsError` | +| `WireError` `'closed'`, `DuplexError` `'disconnected'` | `PublicError` `'disconnected'`, the one closed classification | +| `DuplexError`, `UnpublishedError` | `PublicError`, `UnpublishedError` (`/core`) | +| `wirePair(PeerOptions)` | `pair(PairOptions)` | +| `response` | `respond` | +| `callWire`, `emitWire`, `handleWire`, `registerWire`, `onWireEvent` | `call`, `emit`, `handle`, `register`, `onEvent` (`/dispatch`) | +| `WireDispatcher`, `HandlerRegistry`, `WireRequestContext`, `WireEventContext` | `Dispatcher`, `Registry`, `RequestContext`, `EventContext` | +| `DuplexPeer`, `DUPLEX_DEFAULTS`, `DUPLEX_PROFILE` | `Peer`, `PEER_DEFAULTS`, `PROTOCOL` (`'bitwire/1'`) (`/engine`) | + ## Behavior that changed Each change is a recorded defect or research verdict, fixed rather than ported. @@ -97,6 +117,20 @@ None changes a `bitwire/1` frame. without that handler answers. Observer hooks and family labels are removed until the engine's observation hooks are designed with Bitwire's received-context revision (charter ยง1). +- **TypeScript.** The one closed classification is `PublicError` with code + `disconnected`, keeping what ended the carrier as its `cause`: a send on a + closed mount, dispatcher or pair, a call pending when its peer ends (its + `onClose` listeners still receive the reason itself), and a send that + overflows a queue and so ends its carrier. A connection's `close` throws a + `RangeError` for a code `sendable` refuses; a `FrameConnection` has no + abort, so a peer asked to close with such a code closes normally instead. + A handler's or a listener's context no longer reaches the carrier that + delivered it โ€” the peer (`context.peer`) or a pair's endpoint (R19/R23). + Since every request now takes the root, the root forwards the trace members + a request's frame arrived with rather than what a propagator placed on the + handler's context, and local structured frames omit an empty trace member, + as the peer's own frames always did; v0.6.0's root refused its own response + to a request that carried a `tracestate` alone. ## Kept as v0.6.0 behaved diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 0ea4cab..995ba06 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; -import {copyFileSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; +// Packs the root TypeScript package, installs the tarball into a fresh +// consumer and exercises its public subpaths. Run `npm run build` first. const root = fileURLToPath(new URL('../', import.meta.url)); -const pkg = join(root, 'core', 'ts'); +if (!existsSync(join(root, 'dist', 'core', 'ts', 'src', 'index.js'))) throw new Error('Run npm run build before the package smoke.'); const temp = mkdtempSync(join(tmpdir(), 'bitruntime-consumer-')); const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; function run(command, args, cwd, capture = false) { @@ -20,13 +22,20 @@ function run(command, args, cwd, capture = false) { return result.stdout; } -for (const name of ['LICENSE', 'NOTICE']) copyFileSync(join(root, name), join(pkg, name)); -const packed = JSON.parse(run(npm, ['pack', '--json', '--pack-destination', temp], pkg, true)); +const packed = JSON.parse(run(npm, ['pack', '--json', '--pack-destination', temp], root, true)); +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/dispatch/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'); run(npm, ['install', '--ignore-scripts', '--no-audit', '--@bitspark:registry=https://registry.npmjs.org', join(temp, packed[0].filename)], temp); writeFileSync(join(temp, 'smoke.mjs'), ` import assert from 'node:assert/strict'; -import {compose, select, send, asAddressed} from '@bitspark/bitruntime-core'; +import {compose, select, send, asAddressed, pair, PublicError} from '@bitspark/bitruntime/core'; +import {call, createDispatcher, handle} from '@bitspark/bitruntime/dispatch'; +import {Peer, PROTOCOL} from '@bitspark/bitruntime/engine'; +import {pipe, sendable} from '@bitspark/bitruntime/transports'; + const key = new TextEncoder().encode('child'); const seen = []; const primitive = {send(message) { seen.push(message); }}; @@ -39,9 +48,29 @@ send(tree, [key], message); asAddressed(tree).send(['child'], message); assert.deepEqual(seen, [message, message]); assert.throws(() => send(tree, [Uint8Array.of(255)], message)); + +const [caller, callee] = pair(); +const dispatcher = createDispatcher(callee); +handle(dispatcher, ['echo'], (value) => value); +assert.equal(await call(caller, ['echo'], 'through a pair'), 'through a pair'); +await assert.rejects(call(caller, ['absent']), (error) => error instanceof PublicError && error.code === 'method_not_found'); +caller.close(); + +const [near, far] = pipe(); +const client = new Peer(); +const server = new Peer({role: 'server', prepare: (peer) => handle(createDispatcher(peer.wire()), ['echo'], (value) => value)}); +await Promise.all([client.attach(near), server.attach(far)]); +assert.equal(await call(client.wire(), ['echo'], PROTOCOL), 'bitwire/1'); +client.close(); +assert.equal(sendable(1006), false); + +// The received-context machinery is module-private: no subpath reaches it. +for (const hidden of ['@bitspark/bitruntime', '@bitspark/bitruntime/core/ts/src/internal/context.js', '@bitspark/bitruntime/dist/core/ts/src/internal/context.js']) { + await assert.rejects(import(hidden), (error) => ['ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_MODULE_NOT_FOUND'].includes(error.code), hidden); +} console.log('Fresh installed npm tarball consumer passed.'); `); run(process.execPath, ['smoke.mjs'], temp); -const metadata = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')); +const metadata = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); assert.equal(metadata.version, packed[0].version); console.log(`Packed ${metadata.name}@${metadata.version}: ${join(temp, packed[0].filename)}`); From c584d78090f44b2bdbb194682593a7c3e8d15644 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:30:48 +0200 Subject: [PATCH 32/39] conformance: interoperate bitruntime's TypeScript runtime too The bitruntime TypeScript program joins the scenario. All sixteen pairings of bitruntime and Nightseam v0.6.0, Go and TypeScript, pass over real WebSockets, and bitruntime TypeScript's transcript equals Nightseam v0.6.0 TypeScript's byte for byte, as Go's equals Go's. Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/interop/bitruntime/ts/main.mjs | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 conformance/interop/bitruntime/ts/main.mjs diff --git a/conformance/interop/bitruntime/ts/main.mjs b/conformance/interop/bitruntime/ts/main.mjs new file mode 100644 index 0000000..467db4e --- /dev/null +++ b/conformance/interop/bitruntime/ts/main.mjs @@ -0,0 +1,96 @@ +// bitruntime's TypeScript program of the interoperability scenario in +// conformance/interop/README.md. It imports the package it is part of by name. +import {WebSocketServer} from 'ws'; +import {at, PublicError} from '@bitspark/bitruntime/core'; +import {call, createDispatcher, emit, handle, onEvent} from '@bitspark/bitruntime/dispatch'; +import {Peer} from '@bitspark/bitruntime/engine'; + +const name = 'bitruntime-ts'; +const maxFrameBytes = 4 * 1024 * 1024; +const [role, where] = process.argv.slice(2); + +function serve(port) { + const server = new WebSocketServer({host: '127.0.0.1', port: Number(port), path: '/wire', maxPayload: maxFrameBytes}); + let sawCancel = false; + const waiting = new Set(); + server.on('connection', (socket) => { + const peer = new Peer({ + role: 'server', + maxFrameBytes, + prepare(peer) { + const d = createDispatcher(peer.wire()); + handle(d, ['echo'], (params) => params); + handle(d, ['spaces', 'a/b', 'echo'], (params) => ({space: 'a/b', params})); + handle(d, ['spaces', 'รฉ', ''], () => 'unicode-empty'); + handle(d, ['fail'], () => { + throw new PublicError('bad_request', 'refused on purpose', {n: 1}); + }); + handle(d, ['wait'], (_params, context) => new Promise((_resolve, reject) => { + context.signal.addEventListener('abort', () => { + sawCancel = true; + for (const wake of waiting) wake(true); + reject(new PublicError('cancelled', 'Request cancelled')); + }, {once: true}); + })); + handle(d, ['cancelled'], () => sawCancel || new Promise((resolve) => { + waiting.add(resolve); + setTimeout(() => resolve(false), 2000); + })); + handle(d, ['meta'], (_params, context) => context.meta ?? {}); + handle(d, ['reverse'], (_params, context) => call(peer.wire(), ['whoami'], null, {context})); + handle(d, ['big'], (params) => 'x'.repeat(params.n)); + onEvent(d, ['ping'], (data) => emit(peer.wire(), ['pong'], {echo: data})); + }, + }); + void peer.attach(socket); + }); + server.on('listening', () => console.log(`LISTEN ws://127.0.0.1:${server.address().port}/wire`)); +} + +async function client(url) { + let pong; + const pongs = new Promise((resolve) => { pong = resolve; }); + const peer = new Peer({ + maxFrameBytes, + prepare(peer) { + const d = createDispatcher(peer.wire()); + handle(d, ['whoami'], () => `${name}-client`); + onEvent(d, ['pong'], (data) => pong(data)); + }, + }); + await peer.connect(url); + const root = peer.wire(); + const observed = {}; + const outcome = async (promise) => { + try { + return await promise; + } catch (error) { + return {code: error.code, message: error.message, ...(error.data === undefined ? {} : {data: error.data})}; + } + }; + observed.echo = await outcome(call(root, ['echo'], {a: [1, 'x', null, true], n: 1e3, u: '๐Ÿ˜€'})); + observed.nested = await outcome(call(at(root, ['spaces', 'a/b']), ['echo'], {x: 1})); + observed.unicodeEmpty = await outcome(call(root, ['spaces', 'รฉ', ''], null)); + observed.missing = (await outcome(call(root, ['missing'], null))).code; + observed.fail = await outcome(call(root, ['fail'], null)); + emit(root, ['ping'], 7); + observed.pong = await Promise.race([pongs, new Promise((resolve) => setTimeout(() => resolve('no pong'), 5000))]); + const withdrawn = await outcome(call(root, ['wait'], null, {timeoutMs: 300})); + observed.withdrawn = typeof withdrawn === 'object' && withdrawn !== null && typeof withdrawn.code === 'string'; + observed.serverSawCancel = await outcome(call(root, ['cancelled'], null)); + observed.meta = await outcome(call(root, ['meta'], null, {meta: {tenant: 't1'}})); + observed.reverse = await outcome(call(root, ['reverse'], null)); + const big = await outcome(call(root, ['big'], {n: 3 * 1024 * 1024})); + observed.bigLength = typeof big === 'string' ? big.length : big; + const results = await Promise.all(Array.from({length: 20}, (_, i) => outcome(call(root, ['echo'], {i})))); + observed.concurrent = results.filter((result, i) => result?.i === i).length; + console.log(JSON.stringify(observed)); + peer.close(); +} + +if (role === 'server') serve(where); +else if (role === 'client') await client(where); +else { + console.error('usage: server | client '); + process.exit(2); +} From e6fbcaae1959063d4fe1b5a9b2beb9647a891d97 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:35:27 +0200 Subject: [PATCH 33/39] conformance: make the recorder's cancellation independent of timing The wait handler now emits a waiting event first, and the recorder sends its cancellation only after it. A cancellation that raced the handler's start was answered with the runtime's own message in TypeScript ("Request was cancelled.") and the handler's otherwise, in Nightseam v0.6.0 as in bitruntime, so the TypeScript transcripts could differ by timing. Three consecutive runs of all sixteen pairings and both byte comparisons pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance/interop/README.md | 2 +- conformance/interop/bitruntime/go/main.go | 3 +++ conformance/interop/bitruntime/ts/main.mjs | 1 + conformance/interop/nightseam/go/main.go | 3 +++ conformance/interop/nightseam/ts/main.mjs | 1 + conformance/interop/recorder/go/main.go | 4 +++- 6 files changed, 12 insertions(+), 2 deletions(-) diff --git a/conformance/interop/README.md b/conformance/interop/README.md index 16f8682..e9e9ffa 100644 --- a/conformance/interop/README.md +++ b/conformance/interop/README.md @@ -33,7 +33,7 @@ The server serves, at its connection's root: | `["spaces", "a/b", "echo"]` | request | returns `{"space": "a/b", "params": params}` | | `["spaces", "รฉ", ""]` | request | returns `"unicode-empty"` | | `["fail"]` | request | refuses with public error `bad_request`, `refused on purpose`, data `{"n": 1}` | -| `["wait"]` | request | waits until cancelled, then records the cancellation | +| `["wait"]` | request | emits event `["waiting"]`, waits until cancelled, then records the cancellation | | `["cancelled"]` | request | returns whether a `wait` was cancelled, waiting up to 2 s | | `["meta"]` | request | returns the meta its request carried, `{}` for none | | `["reverse"]` | request | calls the client's `["whoami"]` over the same connection and returns its result | diff --git a/conformance/interop/bitruntime/go/main.go b/conformance/interop/bitruntime/go/main.go index 576cc4c..d37d099 100644 --- a/conformance/interop/bitruntime/go/main.go +++ b/conformance/interop/bitruntime/go/main.go @@ -69,6 +69,9 @@ func serve(port string) error { return nil, &core.PublicError{Code: "bad_request", Message: "refused on purpose", Data: json.RawMessage(`{"n":1}`)} }), handle([]string{"wait"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + if err := dispatch.Emit(ctx, peer.Wire(), []string{"waiting"}, nil); err != nil { + return nil, err + } <-ctx.Done() mu.Lock() select { diff --git a/conformance/interop/bitruntime/ts/main.mjs b/conformance/interop/bitruntime/ts/main.mjs index 467db4e..54e9529 100644 --- a/conformance/interop/bitruntime/ts/main.mjs +++ b/conformance/interop/bitruntime/ts/main.mjs @@ -26,6 +26,7 @@ function serve(port) { throw new PublicError('bad_request', 'refused on purpose', {n: 1}); }); handle(d, ['wait'], (_params, context) => new Promise((_resolve, reject) => { + emit(peer.wire(), ['waiting'], null, {context}); context.signal.addEventListener('abort', () => { sawCancel = true; for (const wake of waiting) wake(true); diff --git a/conformance/interop/nightseam/go/main.go b/conformance/interop/nightseam/go/main.go index 22aa291..33a3000 100644 --- a/conformance/interop/nightseam/go/main.go +++ b/conformance/interop/nightseam/go/main.go @@ -67,6 +67,9 @@ func serve(port string) error { return nil, &runtime.PublicError{Code: "bad_request", Message: "refused on purpose", Data: json.RawMessage(`{"n":1}`)} }), handle([]string{"wait"}, func(ctx context.Context, raw json.RawMessage) (any, error) { + if err := runtime.EmitWire(ctx, peer.Wire(), []string{"waiting"}, nil); err != nil { + return nil, err + } <-ctx.Done() mu.Lock() select { diff --git a/conformance/interop/nightseam/ts/main.mjs b/conformance/interop/nightseam/ts/main.mjs index 87b9a2b..66a4c3e 100644 --- a/conformance/interop/nightseam/ts/main.mjs +++ b/conformance/interop/nightseam/ts/main.mjs @@ -25,6 +25,7 @@ function serve(port) { throw new DuplexError('bad_request', 'refused on purpose', {n: 1}); }); handleWire(d, ['wait'], (_params, context) => new Promise((_resolve, reject) => { + emitWire(peer.wire(), ['waiting'], null, {context}); context.signal.addEventListener('abort', () => { sawCancel = true; for (const wake of waiting) wake(true); diff --git a/conformance/interop/recorder/go/main.go b/conformance/interop/recorder/go/main.go index cf4a0ae..4995529 100644 --- a/conformance/interop/recorder/go/main.go +++ b/conformance/interop/recorder/go/main.go @@ -39,7 +39,9 @@ var script = []exchange{ {`{"version":1,"kind":"event","event":"4:ping","data":7,"traceparent":"` + trace + `"}`, 1}, {`{"version":1,"kind":"request","id":"c:6","method":"4:meta","params":null,"meta":{"tenant":"t1","b":"2"}}`, 1}, {`{"version":1,"kind":"request","id":"c:7","method":"7:reverse","params":null,"traceparent":"` + trace + `"}`, 2}, - {`{"version":1,"kind":"request","id":"c:9","method":"4:wait","params":null}`, 0}, + // The cancellation follows the handler's "waiting" event, so it always + // reaches a running handler and the answer does not depend on timing. + {`{"version":1,"kind":"request","id":"c:9","method":"4:wait","params":null}`, 1}, {`{"version":1,"kind":"cancel","id":"c:9"}`, 1}, {`{"version":1,"kind":"request","id":"c:10","method":"echo","params":1}`, 1}, {`{"version":1,"kind":"request","id":"c:11","method":"3:big","params":{"n":5}}`, 1}, From 264e36dcbbf6d6e5311df64a59ac4277cab50e11 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:37:21 +0200 Subject: [PATCH 34/39] request (ts): settle an unencodable bridged reply instead of stranding its caller As in Go: a reply to a request a carrier admitted that cannot travel, not even as its bounded fallback, now settles as the bounded internal error, and the peer's own response path fails the connection when that does not fit. The delivered call ends with the connection at once instead of at its deadline; the updated test fails after 10 s without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/ts/src/internal/request.ts | 15 ++++++++++++++- engine/ts/test/publication.test.ts | 19 +++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/core/ts/src/internal/request.ts b/core/ts/src/internal/request.ts index 236739c..3b5ca1f 100644 --- a/core/ts/src/internal/request.ts +++ b/core/ts/src/internal/request.ts @@ -124,7 +124,20 @@ export function request( invocation.deliver(suffix, message); return; } - const frame = profileFrame(message.frame, '', dispatch?.maxFrameBytes); + let frame: ProfileFrame; + try { + frame = profileFrame(message.frame, '', dispatch?.maxFrameBytes); + } catch (error) { + // A request a carrier admitted must not be left waiting: a reply that + // cannot travel settles as the bounded internal error, which the + // carrier sends itself or ends its connection over, as its own + // response path does. + if (!dispatch || message.frame.kind !== 'response' || message.frame.id !== 'c:1') throw error; + if (completion.settled) throw ended(); + completion.reject(new PublicError('internal', 'Response could not be encoded')); + invocation.settle(); + return; + } if (frame.kind !== 'response' || frame.id !== 'c:1') throw new PublicError('invalid_message', 'Invalid wire response.'); if (completion.settled) throw ended(); diff --git a/engine/ts/test/publication.test.ts b/engine/ts/test/publication.test.ts index 837fcc9..18a4fc2 100644 --- a/engine/ts/test/publication.test.ts +++ b/engine/ts/test/publication.test.ts @@ -123,15 +123,12 @@ test('a queued write failure has no unpublished proof', async () => { test('a refused reverse reply cannot lend its proof to an already delivered call', async () => { const [a, b] = pipe(); // The reply and its bounded fallback, which repeats the request's trace, - // are both over the client's frame limit. v0.6.0's raw handler refused them - // at the client peer, which ended with frame_too_large at once and settled - // the delivered call with that. Behind the root the local return capability - // refuses them first, so nothing answers the reverse call before the - // deadlines: the delivered call ends at its own (request_timeout), and the - // client peer ends as v0.6.0's did when its deadline's answer is refused in - // turn. The deadline is shortened here. The delivered call holds no proof - // either way, which is what this holds. - const client = new Peer({ maxFrameBytes: 160, requestTimeoutMs: 100 }); + // are both over the client's frame limit. The client's return capability + // settles the reply as the bounded internal error, and the client peer's own + // response path fails its connection when that does not fit either, as + // v0.6.0's raw handler did: the delivered call ends at once with the + // connection, not at its deadline, and holds no proof. + const client = new Peer({ maxFrameBytes: 160, requestTimeoutMs: 10_000 }); const server = new Peer({ role: 'server' }); let delivered = false; const ended = deferred(); @@ -142,15 +139,17 @@ test('a refused reverse reply cannot lend its proof to an already delivered call return call(context.wire, ['b']); }); await Promise.all([client.attach(a), server.attach(b)]); + const started = Date.now(); try { await assert.rejects(call(client.wire(), ['a']), (error: unknown) => { assert.equal(delivered, true); assert.ok(error instanceof PublicError); - assert.equal(error.code, 'request_timeout'); + assert.equal(error.code, 'disconnected'); assert.ok(!(error instanceof UnpublishedError), 'another reply lent proof to this delivered request'); return true; }); assert.equal(await ended.promise, 'frame_too_large'); + assert.ok(Date.now() - started < 5_000, 'the delivered call waited for its deadline'); } finally { client.close(); server.close(); From 1f67594eb3445404ce68e55884987e4021a3f72d Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:41:54 +0200 Subject: [PATCH 35/39] core, engine, transports: fix what the independent review of the port found - A failed Prepare left the root unstarted, so what Prepare attached was never told the peer ended (v0.6.0 started the root when Prepare selected it). The root now runs before Prepare. - A peer ended by the context it was made with could answer a call its root had handed on as cancelled; Respond now classifies a closed carrier before a context error, and the waiter reads the peer's end. - Malformed-frame close reasons use v0.6.0's decoding type names again, and the pre-profile refusal keeps its v0.6.0 wording; the remaining difference is documented. - An invalid path from a pair or the root is core.ErrInvalidPath, and a transport that ends itself on an over-limit frame, an unknown message kind or a write past its context reports a closed carrier. Regression tests fail without the engine fixes. Interoperability and both byte-level comparisons are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/errors.go | 8 +- core/go/pair.go | 2 +- docs/port-from-nightseam.md | 7 ++ engine/go/peer.go | 20 ++-- engine/go/review_test.go | 101 +++++++++++++++++++++ engine/go/wire.go | 2 +- engine/websocket/go/websocket.go | 2 +- internal/profile/go/frame.go | 38 +++++++- transports/go/pipe.go | 2 +- transports/go/transporttest/conformance.go | 2 + transports/websocket/go/websocket.go | 10 +- 11 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 engine/go/review_test.go diff --git a/core/go/errors.go b/core/go/errors.go index 64bbddb..418ab6a 100644 --- a/core/go/errors.go +++ b/core/go/errors.go @@ -64,17 +64,19 @@ func Respond(request wire.Message, result json.RawMessage, err error) error { switch { case errors.As(err, &public) && public != nil && public.Code != "" && public.Message != "": f.Error = &wire.ProfileError{Code: public.Code, Message: public.Message, Data: public.Data} - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - f.Error = &wire.ProfileError{Code: "cancelled", Message: "Request cancelled"} case errors.Is(err, transports.ErrClosed): + // Before cancellation: a carrier that ended because its context did + // is closed, whatever its cause. f.Error = &wire.ProfileError{Code: "disconnected", Message: "Connection ended; outcome may be unknown"} + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + f.Error = &wire.ProfileError{Code: "cancelled", Message: "Request cancelled"} default: f.Error = &wire.ProfileError{Code: "internal", Message: "Internal error"} } f.Result = nil // Preserve the local cancellation cause, but otherwise observe exactly // the normalized public error selected for this response. - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + if f.Error.Code != "cancelled" { err = &PublicError{Code: f.Error.Code, Message: f.Error.Message, Data: f.Error.Data} } } diff --git a/core/go/pair.go b/core/go/pair.go index ad62603..837eda0 100644 --- a/core/go/pair.go +++ b/core/go/pair.go @@ -150,7 +150,7 @@ func (w *localEnd) Send(path []string, message wire.Message) error { message.Frame.Meta = maps.Clone(message.Frame.Meta) name, err := profile.EncodePath(path) if err != nil { - return Unpublished(err) + return Unpublished(ErrInvalidPath) } if err := profile.Validate(name, message.Frame, w.pair.options.MaxFrameBytes); err != nil { return Unpublished(err) diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index 3951716..4f91b82 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -134,6 +134,13 @@ None changes a `bitwire/1` frame. ## Kept as v0.6.0 behaved +- A peer that refuses a malformed frame closes with 4011 and the decoder's + error as the reason, as v0.6.0's Go peer does, with the same decoding type + names. One form differs: a non-object `error` member names + `profile.PublicError` where v0.6.0 named `runtime.PublicError`. Reasons are + diagnostics rather than protocol; v0.6.0's Go and TypeScript peers send + different ones, and the bitwire/1 tables judge only the code. + - A request refused at the root's pending bound is answered through its return capability, so its caller gets `busy` without proof that nothing was published; v0.6.0's removed `Peer.Call` refused it synchronously. Whether a diff --git a/engine/go/peer.go b/engine/go/peer.go index 4abc418..fd1f52c 100644 --- a/engine/go/peer.go +++ b/engine/go/peer.go @@ -179,13 +179,15 @@ func NewPeer(ctx context.Context, conn transports.Conn, role Role, options Optio p.prefix, p.remotePrefix = "s:", "c:" } p.root = &rootWire{peer: p, wake: make(chan struct{}, 1), incoming: map[returnKey]*routedCall{}} + // The root runs before Prepare, so what Prepare attaches or sends through + // it is released and answered even when Prepare fails. + go p.root.run() if o.Prepare != nil { if err := o.Prepare(p); err != nil { p.abandon(err) return nil, err } } - go p.root.run() go p.readLoop() go p.writeLoop() go p.eventLoop() @@ -333,7 +335,7 @@ func (p *Peer) beginCall(ctx context.Context, method string, params json.RawMess p.publish.Unlock() cancel() p.fail(errors.New("bitruntime: request serials exhausted")) - return nil, core.Unpublished(&core.PublicError{Code: "identifier_exhausted", Message: "Create a new peer before issuing further calls"}) + return nil, core.Unpublished(core.Ended(&core.PublicError{Code: "identifier_exhausted", Message: "Create a new peer before issuing further calls"})) } p.next++ id := p.prefix + strconv.FormatUint(p.next, 10) @@ -375,14 +377,14 @@ func (p *Peer) beginCall(ctx context.Context, method string, params json.RawMess return &admittedCall{await: func(result any) error { defer finish() cancelRemote, err := request.Await(ctx, reply, p.done, p.Err, result) - if cancelRemote { + if cancelRemote && p.ctx.Err() != nil { // The call's context derives from the peer's, which ends with the - // peer: a call cut off by the peer ending is disconnected, not - // withdrawn, and owes the far side no cancellation. - if ended := p.Err(); ended != nil { - complete(false) - return ended - } + // peer, including when the context the peer was made with ends: a + // call cut off that way is disconnected, not withdrawn, and owes + // the far side no cancellation. + <-p.done + complete(false) + return p.Err() } complete(cancelRemote) return err diff --git a/engine/go/review_test.go b/engine/go/review_test.go new file mode 100644 index 0000000..6305767 --- /dev/null +++ b/engine/go/review_test.go @@ -0,0 +1,101 @@ +package engine_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + core "github.com/Bitspark/bitruntime/core/go" + dispatch "github.com/Bitspark/bitruntime/dispatch/go" + engine "github.com/Bitspark/bitruntime/engine/go" + transports "github.com/Bitspark/bitruntime/transports/go" + wire "github.com/Bitspark/bitwire/wire/go" +) + +// TestAFailedPrepareReleasesWhatItAttached: a receiver Prepare attached to the +// root is told the peer ended even when Prepare fails, as in v0.6.0, where +// selecting the root inside Prepare started it. +func TestAFailedPrepareReleasesWhatItAttached(t *testing.T) { + a, b := transports.Pipe(1 << 20) + defer a.Abort() + closed := make(chan struct{}) + refusal := errors.New("prepare failed") + _, err := engine.NewPeer(context.Background(), b, engine.ServerRole, engine.Options{Prepare: func(p *engine.Peer) error { + if _, err := p.Wire().Receive(wire.Receiver{Closed: func(wire.Code, string) { close(closed) }}); err != nil { + return err + } + return refusal + }}) + if !errors.Is(err, refusal) { + t.Fatalf("NewPeer returned %v", err) + } + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("the receiver Prepare attached was never told the peer ended") + } +} + +// TestACallCutOffByThePeersContextIsDisconnected: a peer ends when the context +// it was made with ends. A call its root had handed to the peer is then +// disconnected, not withdrawn. +func TestACallCutOffByThePeersContextIsDisconnected(t *testing.T) { + for range 20 { + a, b := transports.Pipe(1 << 20) + started := make(chan struct{}) + server, err := engine.NewPeer(context.Background(), b, engine.ServerRole, engine.Options{Prepare: func(p *engine.Peer) error { + d, err := dispatch.NewDispatcher(p.Wire()) + if err != nil { + return err + } + _, err = dispatch.Handle(d, []string{"wait"}, func(ctx context.Context, _ json.RawMessage) (any, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }) + return err + }}) + if err != nil { + t.Fatal(err) + } + made, end := context.WithCancel(context.Background()) + client, err := engine.NewPeer(made, a, engine.ClientRole, engine.Options{}) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- dispatch.Call(context.Background(), client.Wire(), []string{"wait"}, nil, nil) }() + <-started + end() + var public *core.PublicError + if err := <-done; !errors.As(err, &public) || public.Code != "disconnected" { + t.Fatalf("the call cut off by the peer's context returned %v", err) + } + _ = server.Close() + } +} + +// TestAnInvalidPathIsErrInvalidPath: every addressed carrier classifies a path +// segment outside Unicode-scalar text the same way. +func TestAnInvalidPathIsErrInvalidPath(t *testing.T) { + left, right, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + defer left.Close(transports.CodeNormal, "") + a, b := transports.Pipe(1 << 20) + peer, err := engine.NewPeer(context.Background(), a, engine.ClientRole, engine.Options{}) + if err != nil { + t.Fatal(err) + } + defer peer.Close() + defer b.Abort() + event := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileEvent, Data: json.RawMessage("1")}} + for _, access := range []wire.AddressedWire{left, right, peer.Wire()} { + if err := access.Send([]string{"\xff"}, event); !errors.Is(err, core.ErrInvalidPath) { + t.Fatalf("%T: %v", access, err) + } + } +} diff --git a/engine/go/wire.go b/engine/go/wire.go index 3809472..84100b1 100644 --- a/engine/go/wire.go +++ b/engine/go/wire.go @@ -62,7 +62,7 @@ func (w *rootWire) Send(path []string, message wire.Message) error { message.Frame.Meta = maps.Clone(message.Frame.Meta) name, err := profile.EncodePath(path) if err != nil { - return core.Unpublished(err) + return core.Unpublished(core.ErrInvalidPath) } if name == "" && (message.Frame.Kind == wire.ProfileRequest || message.Frame.Kind == wire.ProfileEvent) { return core.Unpublished(errors.New("bitruntime: a root operation needs a nonempty path")) diff --git a/engine/websocket/go/websocket.go b/engine/websocket/go/websocket.go index 46767d4..59b834d 100644 --- a/engine/websocket/go/websocket.go +++ b/engine/websocket/go/websocket.go @@ -98,7 +98,7 @@ func Accept(w http.ResponseWriter, r *http.Request, options ServerOptions) (*eng func refuseConnection(conn transports.Conn, timeout time.Duration) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - _ = conn.Close(ctx, transports.CodePolicyViolation, "the connection was refused before the protocol began") + _ = conn.Close(ctx, transports.CodePolicyViolation, "the connection was refused before the profile began") } // NewHandler serves bitwire/1 at an HTTP endpoint: each request that passes diff --git a/internal/profile/go/frame.go b/internal/profile/go/frame.go index c1801cc..852c35f 100644 --- a/internal/profile/go/frame.go +++ b/internal/profile/go/frame.go @@ -105,9 +105,45 @@ func ValidPath(path []string) bool { return true } +// frame and PublicError are what Decode reads an envelope into. Their names +// are v0.6.0's, because a decoder's error names the type it failed to fill, +// and a peer sends that error as the reason it refuses the frame. +type frame struct { + Version int `json:"version"` + Kind string `json:"kind"` + ID string `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *PublicError `json:"error,omitempty"` + Event string `json:"event,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Traceparent string `json:"traceparent,omitempty"` + Tracestate string `json:"tracestate,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +// PublicError is a decoded envelope's public error. +type PublicError struct { + Code string `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + // Decode admits one envelope exactly as the profile allows it. func Decode(data []byte) (Frame, error) { - var f Frame + decoded, err := decode(data) + f := Frame{Version: decoded.Version, Kind: decoded.Kind, ID: decoded.ID, Method: decoded.Method, + Params: decoded.Params, Result: decoded.Result, Event: decoded.Event, Data: decoded.Data, + Traceparent: decoded.Traceparent, Tracestate: decoded.Tracestate, Meta: decoded.Meta} + if decoded.Error != nil { + f.Error = &wire.ProfileError{Code: decoded.Error.Code, Message: decoded.Error.Message, Data: decoded.Error.Data} + } + return f, err +} + +func decode(data []byte) (frame, error) { + var f frame if err := RawUnicode(data); err != nil { return f, err } diff --git a/transports/go/pipe.go b/transports/go/pipe.go index 4c9db1e..199482a 100644 --- a/transports/go/pipe.go +++ b/transports/go/pipe.go @@ -92,7 +92,7 @@ func (e *pipeEnd) Receive(ctx context.Context) (Frame, error) { // The receiver refuses with 1009, as a WebSocket closes on its read // limit, so the sender observes why rather than an abort. e.end(&CloseError{Code: CodeTooLarge, Reason: "frame exceeds the receive limit"}) - return Frame{}, fmt.Errorf("transport frame of %d bytes exceeds the receive limit of %d", len(frame.Data), e.limit) + return Frame{}, fmt.Errorf("%w: transport frame of %d bytes exceeds the receive limit of %d", ErrClosed, len(frame.Data), e.limit) } return frame, nil } diff --git a/transports/go/transporttest/conformance.go b/transports/go/transporttest/conformance.go index 1311eb9..0b30389 100644 --- a/transports/go/transporttest/conformance.go +++ b/transports/go/transporttest/conformance.go @@ -151,6 +151,8 @@ func Run(t *testing.T, connect Connect) { }() if frame, err := b.Receive(ctx); err == nil { t.Fatalf("a frame of %d bytes was delivered over a limit of 1024", len(frame.Data)) + } else if !errors.Is(err, transports.ErrClosed) { + t.Fatalf("refusing a frame over the limit is not a closed carrier: %v", err) } if _, err := b.Receive(ctx); err == nil { t.Fatal("the connection received again after refusing a frame over the limit") diff --git a/transports/websocket/go/websocket.go b/transports/websocket/go/websocket.go index 3097dd5..35171bd 100644 --- a/transports/websocket/go/websocket.go +++ b/transports/websocket/go/websocket.go @@ -73,6 +73,12 @@ func (c *connection) Send(ctx context.Context, frame transports.Frame) error { return err } if err := c.conn.Write(ctx, t, frame.Data); err != nil { + if ctx.Err() != nil && errors.Is(err, ctx.Err()) { + // The library closes a connection whose write outlived its + // context, so the carrier is closed as well as late. + c.end() + return fmt.Errorf("%w: %w", transports.ErrClosed, ctx.Err()) + } return c.translate(ctx, err) } return nil @@ -91,14 +97,14 @@ func (c *connection) Receive(ctx context.Context) (transports.Frame, error) { // 1009 if it read it first, or the dropped transport otherwise. c.end() _ = c.conn.CloseNow() - return transports.Frame{}, err + return transports.Frame{}, fmt.Errorf("%w: %w", transports.ErrClosed, err) } return transports.Frame{}, c.translate(ctx, err) } kind, err := kindOf(t) if err != nil { _ = c.Abort() - return transports.Frame{}, err + return transports.Frame{}, fmt.Errorf("%w: %w", transports.ErrClosed, err) } return transports.Frame{Kind: kind, Data: data}, nil } From 888c8e59748c9218d95e4c5731b1d3584d0e87eb Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:42:13 +0200 Subject: [PATCH 36/39] ci: format every Go file and run the interoperability matrix Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 23 ++++++++++++++++++++++- .github/workflows/release.yml | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d27739..769c75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: cache: npm cache-dependency-path: package-lock.json - name: Go formatting - run: test -z "$(gofmt -l core/go)" + run: test -z "$(gofmt -l $(git ls-files '*.go'))" - run: go vet ./... - run: go test -race -count=1 ./... - run: npm ci --ignore-scripts @@ -33,3 +33,24 @@ jobs: - run: npm run build - run: npm test - run: node scripts/package-smoke.mjs + interop: + # bitruntime and Nightseam v0.6.0, Go and TypeScript, in every pairing over + # real WebSockets, and bitruntime's bytes against Nightseam's. + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + conformance/interop/nightseam/ts/package-lock.json + - run: npm ci --ignore-scripts + - run: npm run build + - run: node scripts/interop.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c20abc4..f539ef1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: - run: go test -race -count=1 ./... - 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/go-smoke.mjs "$GITHUB_REF_NAME" - name: Pack release run: | From d0a38eac3caec7331cea040e72fa29b4f266fa97 Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:42:49 +0200 Subject: [PATCH 37/39] docs: write the v0.2.0 release notes and the release unit's coordinates Co-Authored-By: Claude Opus 5.5 (1M context) --- RELEASING.md | 33 ++++++++++++++---------- docs/RELEASE.md | 68 +++++++++++++++++++++++++++++++------------------ 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index bd8fb14..164fb66 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,27 +1,32 @@ -# Releasing the structural core +# Releasing -The first release contains only the complete structural core. It does not -deliver transports, an invocation runtime, dispatch, an RPC engine, or the -Nightseam consumer migration. +A release is one root tag that versions the Go module and the TypeScript +package together. Each release states the Bitwire contract version it +implements, the protocol revisions it speaks and the conformance evidence it +passed (charter ยง2); `docs/RELEASE.md` is the release's notes. Coordinates: -- Go module `github.com/Bitspark/bitruntime`, package `core/go`. -- TypeScript package `@bitspark/bitruntime-core`, source and manifest `core/ts`, - for v0.1.0. From 0.2.0 the TypeScript implementation is one package, - `@bitspark/bitruntime`, whose manifest and lockfile are at the repository - root; its sources stay under `/ts/src` and it exports the - subpaths `@bitspark/bitruntime/core`, `/transports`, `/engine` and - `/dispatch`. -- The root `v0.1.0` tag versions this initial module pair together. Further - runtime components require an explicit module/versioning decision. +- Go module `github.com/Bitspark/bitruntime`, distributed by its root `vX.Y.Z` + tags, with the packages `core/go`, `transports/go`, `transports/websocket/go`, + `engine/go`, `engine/websocket/go` and `dispatch/go`. +- TypeScript package `@bitspark/bitruntime`, whose manifest and lockfile are at + the repository root. Its sources stay under `/ts/src`, and it + exports the subpaths `/core`, `/transports`, `/engine` and `/dispatch`. + v0.1.0 shipped the structural core alone as `@bitspark/bitruntime-core` from + `core/ts`. +- A later component records its module boundary here before it is released. Both implementations use the public Bitwire 0.3.0 contract. No dependency on a private Deixis checkout, local replacement, or Nightseam may enter the release. +The Nightseam v0.6.0 interoperability programs are test-only: their Go module is +nested under `conformance/interop` and their npm package is never packed. Before tagging, land a reviewed green PR on main. The CI workflow checks Go formatting/vet/race tests, TypeScript checking/build/tests, the independent -Bitwire structural oracle, and a fresh installed TypeScript tarball consumer. +Bitwire structural oracle, a fresh installed TypeScript tarball consumer, and +interoperability with Nightseam v0.6.0 peers in every Go/TypeScript pairing, +byte for byte. Confirm the actual Bitwire dependency release is publicly installable and run the same checks locally where supported. Validate a fresh Go consumer against the pushed commit, then against the final tag. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index f85dc97..e76bdf1 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,31 +1,49 @@ -# bitruntime v0.1.0 +# bitruntime v0.2.0 -The first release implements the shared structural core over the public -Bitwire 0.3.0 contract in Go and TypeScript: +The runtime path that hand-written adapters use, in Go and TypeScript, over +the public **Bitwire 0.3.0** contract, speaking the protocol revision +**`bitwire/1`**: -- Full generic Deixis tree construction, own values, complete exact-byte-keyed - children, partial selection, decomposition and reconstruction. -- `WireTree = DeixisNode` and derived sending through the selected - node's own addressless `Wire.send(message)`. -- Explicit addressed access over a complete tree for the existing UTF-8 - string-path carrier surface, with missing-path refusal and no lifetime - ownership transfer. -- Independent Bitwire structural observations plus native edge-case tests. +- **core:** the structural core of v0.1.0 (trees, selection, decomposition, + derived sending); the addressed operators `At`/`at`, `Mount`/`mount` and + `Forward`/`forward`; the local pair; the public invocation lifecycle. +- **transports:** the frame transport seam, the in-memory pipe and WebSocket, + with one closed classification and a sendable/observe-only split of close + codes. +- **engine:** the `bitwire/1` peer over any transport and WebSocket connection + setup (`Accept`, `NewHandler`, `Dial`; `Peer.connect`, `Peer.attach`). +- **dispatch:** the dispatcher and the `Call`, `Emit`, `Handle` and `Register` + helpers (`call`, `emit`, `handle`, `register`, `onEvent`). -This release does not include carriers, protocol engines, dispatch, live -references, tunnels or a completed bitsystem3/Nightseam migration. An opaque -`AddressedWire` cannot be converted into a full tree without a complete -declaration. Data reading and storage remain Bitstore's responsibility. +It is ported from Nightseam v0.6.0 (`5cc9723`), with provenance in `NOTICE` and +every change in [the port record](port-from-nightseam.md). Nightseam's recorded +defects in this path are fixed rather than ported: queued refusals are answered +when a pair closes (nightseam#722), a pair's pending slot is free before its +caller holds the answer (nightseam#658), ended carriers answer `disconnected`, +observe-only close codes are never sent, a forwarder fails only a refused +message, and a closing peer's code reaches the far side. -Go is available through the `v0.1.0` module tag. The TypeScript package is -published as a GitHub release tarball with a SHA-256 checksum; it is not yet -published to an npm registry. +**Evidence** -## Unreleased: one TypeScript package +- `bitwire/1` is unchanged: bitruntime interoperates with Nightseam v0.6.0 in + every pairing of Go and TypeScript over real WebSockets, and sends the same + bytes (`node scripts/interop.mjs`). The envelope tables of v0.6.0 are held by + a running peer of each role. +- Bitwire's independent cases, run from Bitwire's own test-only module against + this release: lifecycle, composition groups, declared composites (reference + realization; production gaps recorded) and trees, over the local pair and + WebSockets in both directions. +- The v0.6.0 Go and TypeScript test suites, ported with their expectations. -The next TypeScript version, 0.2.0, is one package, `@bitspark/bitruntime`, -built from the repository root. It replaces `@bitspark/bitruntime-core` and -exports four subpaths: `@bitspark/bitruntime/core` (the structural core, the -addressed operators, the local pair and the invocation lifecycle), -`/transports`, `/engine` (the `bitwire/1` peer) and `/dispatch`. It is not -released yet. +**Coordinates.** Go: module `github.com/Bitspark/bitruntime` at the `v0.2.0` +tag, packages `core/go`, `transports/go`, `transports/websocket/go`, +`engine/go`, `engine/websocket/go` and `dispatch/go`. TypeScript: the package +`@bitspark/bitruntime` 0.2.0 with the subpaths `/core`, `/transports`, +`/engine` and `/dispatch`, published as this release's tarball with a SHA-256 +checksum; it replaces `@bitspark/bitruntime-core` and is not published to an +npm registry. + +Not in this release: live references, tunnels, the framed byte stream +`bitwire-stream/1`, observation and tracing hooks, authentication integration, +and received-context evidence as a contract field, which awaits a later +Bitwire revision. From 1ce0f5044366f1d45704295e8d9e9c0de03e16ad Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 12:53:18 +0200 Subject: [PATCH 38/39] docs: spell every Bitspark project as its repository is named Apply the organization's naming rule (Bitspark/.github NAMING.md): bitwire, nightseam, bitstore, deixis and the other projects are written exactly as their repositories are named in prose. Code spans, identifiers and protocol names (bitwire/1) are unchanged. Checked with the organization's naming.mjs. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 18 ++++----- CHANGELOG.md | 12 +++--- CHARTER.md | 36 +++++++++--------- LAYOUT.md | 2 +- README.md | 32 ++++++++-------- RELEASING.md | 14 +++---- conformance/interop/README.md | 20 +++++----- core/go/README.md | 10 ++--- core/go/testdata/README.md | 6 +-- core/ts/README.md | 10 ++--- docs/RELEASE.md | 12 +++--- docs/bitsystem3-migration-kickoff.md | 56 ++++++++++++++-------------- docs/port-from-nightseam.md | 14 +++---- docs/wire-under-bitwire.md | 22 +++++------ vectors/bitwire-1/README.md | 6 +-- 15 files changed, 135 insertions(+), 135 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53375b2..cf1f014 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,28 +1,28 @@ # Working here as an agent -Read the [charter](CHARTER.md), and in Bitwire read +Read the [charter](CHARTER.md), and in bitwire read [decision 0007](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0007-using-bitwire-never-requires-nightseam.md), [decision 0010](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0010-bitwire-holds-the-contract-and-bitruntime-implements-it.md), [decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md) and the [carrier specification](https://github.com/Bitspark/bitwire/blob/main/docs/wire/carriers.md), before changing this tree. -- Implement the Bitwire contract; never redefine it. If the contract or a - specification looks wrong, raise it in Bitwire, don't work around it here. +- Implement the bitwire contract; never redefine it. If the contract or a + specification looks wrong, raise it in bitwire, don't work around it here. - Use `Wire.send(message)` and full `WireTree = DeixisNode`, symmetric - with Bitstore's `Data.read()` / `DataTree = DeixisNode`. The old + with bitstore's `Data.read()` / `DataTree = DeixisNode`. The old path-taking access is explicitly `AddressedWire`. Never disguise an opaque router as a full tree or implement the superseded `End` naming proposal. -- Depend on Bitwire and, in their own modules, on transport libraries. Never - depend on Nightseam, bittype or Bitlink. -- When porting from Nightseam: +- Depend on bitwire and, in their own modules, on transport libraries. Never + depend on nightseam, bittype or bitlink. +- When porting from nightseam: - port from an identified commit; - keep the released v0.6.0 apart from its unreleased commits; - record the provenance in `NOTICE`; - add no aliases or re-exports. -- Test against Bitwire's independent cases and vectors. Never make an +- Test against bitwire's independent cases and vectors. Never make an expectation match what the code happens to do. -- Treat Nightseam's recorded defects (nightseam#720โ€“#724) as acceptance criteria +- Treat nightseam's recorded defects (nightseam#720โ€“#724) as acceptance criteria for the ported code. - After the initial bootstrap, use a branch or worktree and a pull request. Squash a green change onto `main`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c47b87..3173dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Implement the path hand-written adapters use, ported from Nightseam v0.6.0 +- Implement the path hand-written adapters use, ported from nightseam v0.6.0 (`5cc9723`) with provenance in `NOTICE`: the transport seam, in-memory pipe and WebSocket (`transports`); `At`, `Mount`, `Forward`, the local pair and the invocation lifecycle (`core`); the dispatcher and the `Call`, `Emit`, @@ -10,13 +10,13 @@ engine with WebSocket connection setup (`engine`). The engine sends, accepts and refuses exactly what v0.6.0 does and presents the protocol only through its root Endpoint. -- Fix rather than port Nightseam's recorded defects in this path: a closing +- Fix rather than port nightseam's recorded defects in this path: a closing pair answers its queued refusals (nightseam#722), a pair response frees its call's slot before the caller holds it (nightseam#658), every ended carrier classifies as one closed error that forwarding answers as `disconnected`, observe-only close codes are never sent, and forwarding fails only a refused message. See [the port record](docs/port-from-nightseam.md). -- Hold the engine to Nightseam v0.6.0 peers over real WebSockets in both roles +- Hold the engine to nightseam v0.6.0 peers over real WebSockets in both roles and both languages, and to its bytes (`scripts/interop.mjs`). ## 0.1.0 (26 September 2026) @@ -24,7 +24,7 @@ - Implement the first Go/TypeScript structural core: full generic tree construction/selection/decomposition, derived sending, and an explicit addressed facade with exact UTF-8 path conversion. Validate with independent - Bitwire structural observations, native edge cases and package consumers. + bitwire structural observations, native edge cases and package consumers. Establish source-tag and GitHub tarball delivery for this bounded core; carriers and the wider runtime/consumer migration remain pending. @@ -36,11 +36,11 @@ - Name the old opaque addressed access `AddressedWire` and keep the Endpoint/return-capability boundary explicit. Update the charter, agent - instructions and migration kickoff to follow Bitwire decision 0012 without + instructions and migration kickoff to follow bitwire decision 0012 without claiming the runtime or consumer networking migration is implemented. - Document the family component-first layout with two-letter language directories, command paths and explicit adoption notes for existing source. Add the interactive kickoff for the first runtime and bitsystem3 migration. -- Charter the repository (Bitwire decision 0010): what it owns, what it promises and how that is versioned, what independent evidence checks it, and which change its separation makes easier. +- Charter the repository (bitwire decision 0010): what it owns, what it promises and how that is versioned, what independent evidence checks it, and which change its separation makes easier. diff --git a/CHARTER.md b/CHARTER.md index 116f0cf..61ebc4a 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -1,28 +1,28 @@ # Charter -[Bitwire decision 0010](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0010-bitwire-holds-the-contract-and-bitruntime-implements-it.md) +[bitwire decision 0010](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0010-bitwire-holds-the-contract-and-bitruntime-implements-it.md) requires every new repository to open with answers to four questions. A repository owns an independently useful compatibility commitment. A module owns a coherent semantic decision. ## 1. What decisions does it own? -How the Bitwire contract is implemented in Go and TypeScript, including the +How the bitwire contract is implemented in Go and TypeScript, including the maintainer's `Wire` / `WireTree` naming decision: - module layout and package coordinates; - concurrency, buffering and backpressure strategy, and resource bounds within the carrier contract; - the engine's public hooks for context, observation and tracing. These are - designed together with Bitwire's received-context contract change. -- which transports ship, following Bitwire decision 0009: + designed together with bitwire's received-context contract change. +- which transports ship, following bitwire decision 0009: - the in-memory pipe; - WebSocket; - `bitwire-stream/1` over stdio, TCP and Unix sockets. - the live-reference mechanism and tunnels. They depend on stated capabilities (an Endpoint plus lifetime and scope), not on a concrete peer. - concrete full `WireTree` construction, partial selection, decomposition and - reconstruction, using Deixis's generic byte-keyed structural contract; + reconstruction, using deixis's generic byte-keyed structural contract; - derived sending through `select(tree, path).own().send(message)` and explicit adapters to the separate `AddressedWire` carrier access contract. An opaque router does not become a full tree merely by being wrapped or renamed. @@ -30,15 +30,15 @@ maintainer's `Wire` / `WireTree` naming decision: It does **not** own: - the contract, the protocol, the carrier contract or the conformance expectations, - which are Bitwire's; + which are bitwire's; - declaration semantics or identity, which are bittype's; -- adapters or the identity check, which are Bitlink's; +- adapters or the identity check, which are bitlink's; - validation, which is bitschema's; - authority, which stays with its consumers. The naming across the two families is `WireTree = DeixisNode` and `DataTree = DeixisNode`. `Wire.send(message)` is addressless; -`Data.read()` reads bytes. Bitstore owns `Data` and `DataTree`, and Deixis owns +`Data.read()` reads bytes. bitstore owns `Data` and `DataTree`, and deixis owns the common structure and laws. Materialized `DeixisNode` values remain the storage codec's snapshots. This charter does not move storage implementation into bitruntime. @@ -53,7 +53,7 @@ capabilities remain separate from the native primitive rename. Each release states: -- which Bitwire contract version it implements; +- which bitwire contract version it implements; - which protocol revisions it implements (`bitwire/1`, โ€ฆ); - which conformance suite revision it passes. @@ -69,7 +69,7 @@ dispatch reads, which the TypeScript package keeps private to itself. Later components (live references, tunnels, telemetry, authentication integration) record their module boundaries before joining this unit or publishing separately. Before 1.0 there is no compatibility promise. There are no aliases -or re-exports of Nightseam. +or re-exports of nightseam. The initial release process publishes Go through its source tag and TypeScript as a GitHub release tarball with checksums. Registry publication is separately @@ -78,16 +78,16 @@ configured and cannot be inferred from the presence of a tarball. See ## 3. What independently written evidence checks the promise? -- Bitwire's conformance cases, run against released bitruntime from a test-only - module in Bitwire. -- The initial core runs its actual implementations against Bitwire's independent +- bitwire's conformance cases, run against released bitruntime from a test-only + module in bitwire. +- The initial core runs its actual implementations against bitwire's independent structural oracle as well as native edge cases. These observations do not - stand in for the carrier/runtime suites, which Bitwire runs from its own + stand in for the carrier/runtime suites, which bitwire runs from its own test-only module. -- Nightseam v0.6.0's `bitwire/1` tables, vendored byte for byte in +- nightseam v0.6.0's `bitwire/1` tables, vendored byte for byte in `vectors/bitwire-1`, and the byte-level transcripts of `scripts/interop.mjs`. - The portable byte vectors for `bitwire-stream/1`. -- Interoperability runs against Nightseam v0.6.0 peers, until the last consumer +- Interoperability runs against nightseam v0.6.0 peers, until the last consumer moves. - Deliberately unlawful implementations, which check that the cases reject violations. @@ -115,11 +115,11 @@ more: | live | Scopes, bindings, owners and release for live references | | tunnel | Many channels over one connection | | telemetry (optional) | Observation and tracing adapters | -| auth-integration (optional) | Wire-level authentication integration above Archon | +| auth-integration (optional) | Wire-level authentication integration above archon | ## The first milestone -bitsystem3 is the first consumer to move off Nightseam. It needs the path its +bitsystem3 is the first consumer to move off nightseam. It needs the path its hand-written adapters use: carriers, dispatch, helpers, selection and connection setup. diff --git a/LAYOUT.md b/LAYOUT.md index 3548789..601437f 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -16,7 +16,7 @@ directory. Do not organize implementations as repository-root `go/` or `ts/`, Use exactly two lowercase letters: `go`, `ts`, `py`, `rs`, `hs`, `cc`, `jv`, `sw`; other assigned codes include `rb`, `kt`, `cs` and `sh`. -Bitwire already uses `hs` for Haskell. Service SDKs and their source manifests +bitwire already uses `hs` for Haskell. Service SDKs and their source manifests also follow their own language registry; register a code there before using it. Source, native tests and language-specific package metadata belong with the diff --git a/README.md b/README.md index 02d2393..1b983f7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # bitruntime The Go and TypeScript implementation of the -[Bitwire](https://github.com/Bitspark/bitwire) contract. +[bitwire](https://github.com/Bitspark/bitwire) contract. **Status: the runtime path hand-written adapters use is implemented** (the next release after the structural core 0.1.0): @@ -14,28 +14,28 @@ release after the structural core 0.1.0): - **dispatch:** the dispatcher and the `Call`, `Emit`, `Handle` and `Register` helpers. -The runtime is ported from Nightseam v0.6.0 with its provenance in `NOTICE`, and -fixes Nightseam's recorded defects in this path; see +The runtime is ported from nightseam v0.6.0 with its provenance in `NOTICE`, and +fixes nightseam's recorded defects in this path; see [the port record](docs/port-from-nightseam.md). The engine interoperates with -Nightseam v0.6.0 peers in both roles and both languages and sends the same +nightseam v0.6.0 peers in both roles and both languages and sends the same bytes (`node scripts/interop.mjs`). Live references, tunnels, the framed byte stream, telemetry and authentication integration remain planned under -[Bitwire decision 0010](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0010-bitwire-holds-the-contract-and-bitruntime-implements-it.md). +[bitwire decision 0010](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0010-bitwire-holds-the-contract-and-bitruntime-implements-it.md). ## Where it sits ```text -bitruntime โ†’ Bitwire (the contract, the protocol and carrier specifications, conformance) +bitruntime โ†’ bitwire (the contract, the protocol and carrier specifications, conformance) ``` -- **Bitwire** specifies addressless `Wire`, structural `WireTree`, and the +- **bitwire** specifies addressless `Wire`, structural `WireTree`, and the separate `AddressedWire` carrier access contract. bitruntime implements them. -- **Bitwire's conformance cases** judge bitruntime as an external implementation, +- **bitwire's conformance cases** judge bitruntime as an external implementation, written from the specification and never recorded from this code. -- **What bitruntime depends on.** Bitwire, and in separate modules, the libraries a +- **What bitruntime depends on.** bitwire, and in separate modules, the libraries a transport needs. - **What it does not depend on.** The contract language (bittype), the adapters - (Bitlink), or Nightseam. + (bitlink), or nightseam. ## The primitive and tree contract @@ -49,7 +49,7 @@ type WireTree = DeixisNode; type DataTree = DeixisNode; ``` -Both trees have the same full Deixis structure: an own primitive, complete +Both trees have the same full deixis structure: an own primitive, complete children keyed by exact bytes, partial path selection, decomposition and reconstruction. For a present path: @@ -58,7 +58,7 @@ send(tree, path, message) = select(tree, path).own().send(message) read(tree, path) = select(tree, path).own().read() ``` -`Wire` belongs to Bitwire; `Data` belongs to Bitstore. A `Data` is a reading +`Wire` belongs to bitwire; `Data` belongs to bitstore. A `Data` is a reading capability. A materialized `DeixisNode` remains the codec snapshot, not the definition of `DataTree`. @@ -92,12 +92,12 @@ enter only through the WebSocket packages. TypeScript uses one package, `@bitspark/bitruntime`, built at the repository root with the subpaths `./core`, `./transports`, `./engine` and `./dispatch`, so the received context its components share stays private to the package. (v0.1.0 shipped the -structural core alone as `@bitspark/bitruntime-core`.) Both depend on the public Bitwire 0.3.0 contract. Releases +structural core alone as `@bitspark/bitruntime-core`.) Both depend on the public bitwire 0.3.0 contract. Releases publish a root Go tag and a TypeScript tarball with checksums on GitHub; npm registry publication is not configured. Read [RELEASING.md](RELEASING.md). -The generic core uses Bitwire's native node declarations. TypeScript accepts -Bitstore's matching structural node type directly; Go requires an explicit +The generic core uses bitwire's native node declarations. TypeScript accepts +bitstore's matching structural node type directly; Go requires an explicit adapter between the two packages' recursive node types. The shared semantic contract does not imply direct Go assignability. @@ -108,7 +108,7 @@ contract does not imply direct Go assignability. - [Working here as an agent](AGENTS.md). - [Repository layout](LAYOUT.md) and the [interactive kickoff for the bitsystem3 migration](docs/bitsystem3-migration-kickoff.md). -- [Bitwire's carrier specification](https://github.com/Bitspark/bitwire/blob/main/docs/wire/carriers.md) +- [bitwire's carrier specification](https://github.com/Bitspark/bitwire/blob/main/docs/wire/carriers.md) and [the contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md). ## Source layout diff --git a/RELEASING.md b/RELEASING.md index 164fb66..1086c67 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,7 @@ # Releasing A release is one root tag that versions the Go module and the TypeScript -package together. Each release states the Bitwire contract version it +package together. Each release states the bitwire contract version it implements, the protocol revisions it speaks and the conformance evidence it passed (charter ยง2); `docs/RELEASE.md` is the release's notes. @@ -17,17 +17,17 @@ Coordinates: `core/ts`. - A later component records its module boundary here before it is released. -Both implementations use the public Bitwire 0.3.0 contract. No dependency on a -private Deixis checkout, local replacement, or Nightseam may enter the release. -The Nightseam v0.6.0 interoperability programs are test-only: their Go module is +Both implementations use the public bitwire 0.3.0 contract. No dependency on a +private deixis checkout, local replacement, or nightseam may enter the release. +The nightseam v0.6.0 interoperability programs are test-only: their Go module is nested under `conformance/interop` and their npm package is never packed. Before tagging, land a reviewed green PR on main. The CI workflow checks Go formatting/vet/race tests, TypeScript checking/build/tests, the independent -Bitwire structural oracle, a fresh installed TypeScript tarball consumer, and -interoperability with Nightseam v0.6.0 peers in every Go/TypeScript pairing, +bitwire structural oracle, a fresh installed TypeScript tarball consumer, and +interoperability with nightseam v0.6.0 peers in every Go/TypeScript pairing, byte for byte. -Confirm the actual Bitwire dependency release is publicly installable and run +Confirm the actual bitwire dependency release is publicly installable and run the same checks locally where supported. Validate a fresh Go consumer against the pushed commit, then against the final tag. diff --git a/conformance/interop/README.md b/conformance/interop/README.md index e9e9ffa..4032147 100644 --- a/conformance/interop/README.md +++ b/conformance/interop/README.md @@ -1,22 +1,22 @@ -# Interoperability with Nightseam v0.6.0 +# Interoperability with nightseam v0.6.0 -Test-only evidence that bitruntime speaks `bitwire/1` as Nightseam v0.6.0 does. -Bitwire decision 0008 defines `bitwire/1` as that release's behavior, so a -bitruntime peer and a Nightseam v0.6.0 peer must serve each other in both roles, +Test-only evidence that bitruntime speaks `bitwire/1` as nightseam v0.6.0 does. +bitwire decision 0008 defines `bitwire/1` as that release's behavior, so a +bitruntime peer and a nightseam v0.6.0 peer must serve each other in both roles, in both languages, and send the same bytes for the same exchange. -Nightseam v0.6.0 compiles against Bitwire 0.2.0, whose `Wire` is path-taking, -and bitruntime against Bitwire 0.3.0, so the two cannot share one Go binary. Each +nightseam v0.6.0 compiles against bitwire 0.2.0, whose `Wire` is path-taking, +and bitruntime against bitwire 0.3.0, so the two cannot share one Go binary. Each implementation is its own program, and they meet only over real WebSockets. -The Nightseam programs are isolated in their own test-only modules and are never +The nightseam programs are isolated in their own test-only modules and are never a dependency of a published package. | Program | Implementation | | --- | --- | | `bitruntime/go` | this repository's Go runtime | | `bitruntime/ts` | this repository's TypeScript runtime | -| `nightseam/go` | Nightseam v0.6.0 Go (`github.com/Bitspark/nightseam v0.6.0`) | -| `nightseam/ts` | Nightseam v0.6.0 TypeScript (`@nightseam/runtime` and `@nightseam/duplex` 0.6.0) | +| `nightseam/go` | nightseam v0.6.0 Go (`github.com/Bitspark/nightseam v0.6.0`) | +| `nightseam/ts` | nightseam v0.6.0 TypeScript (`@nightseam/runtime` and `@nightseam/duplex` 0.6.0) | ## The scenario @@ -66,6 +66,6 @@ client against it, and compares the observations with this table. `recorder/go` is a raw WebSocket client that sends fixed envelopes to a server and records every frame the server sends back, answering the server's reverse request itself. The runner records each server and requires the bitruntime -transcripts to equal the Nightseam transcripts of the same language byte for +transcripts to equal the nightseam transcripts of the same language byte for byte, after masking only the random span identifiers a peer mints for its own requests. diff --git a/core/go/README.md b/core/go/README.md index f3085ec..5b1d058 100644 --- a/core/go/README.md +++ b/core/go/README.md @@ -1,7 +1,7 @@ # Go structural core Package `github.com/Bitspark/bitruntime/core/go` implements the full structural -contract declared by Bitwire 0.3.0. It uses Go 1.26, matching that dependency. +contract declared by bitwire 0.3.0. It uses Go 1.26, matching that dependency. ```go import ( @@ -70,13 +70,13 @@ The runtime does not claim it can prove termination of arbitrary foreign `Children` implementations. Locally constructed nodes already enforce their structure and need no repeated traversal. -Bitstore's Go declarations have the same semantics but define their own +bitstore's Go declarations have the same semantics but define their own recursive `DeixisNode[T]` and `Child[T]` types. They are not directly assignable -to Bitwire's Go interfaces: `At`, `Children`, and `Decompose` name different +to bitwire's Go interfaces: `At`, `Children`, and `Decompose` name different return types. Interoperation requires an explicit adapter preserving own capability identity, complete children, exact keys, and selection/reconstruction laws. The TypeScript presentations are structurally assignable; that does not -create Go type aliases or add a Bitstore dependency to this package. +create Go type aliases or add a bitstore dependency to this package. ## Addressed access @@ -106,5 +106,5 @@ The tests cover exact binary and empty keys, defensive copies, child and own identity, decomposition/reconstruction, partial selection, shared nodes, independent value implementations, identifiable cycles, deep foreign trees, derived sending, unchanged refusals, and Unicode-safe addressed access. -`TestBitwireStructuralOracle` runs production operations against Bitwire's +`TestBitwireStructuralOracle` runs production operations against bitwire's independent [observations](testdata/README.md). diff --git a/core/go/testdata/README.md b/core/go/testdata/README.md index 2d67f91..9e35318 100644 --- a/core/go/testdata/README.md +++ b/core/go/testdata/README.md @@ -1,8 +1,8 @@ # Structural oracle provenance -`tree-observations.json` is copied unchanged from Bitwire's independent +`tree-observations.json` is copied unchanged from bitwire's independent [`conformance/trees/expected.json`](https://github.com/Bitspark/bitwire/blob/v0.3.0/conformance/trees/expected.json). `TestBitwireStructuralOracle` makes the observations against this runtime's -production `Compose`, `Select`, and `Send`, rather than the Bitwire test-only -interpreter. Expected observations must change in Bitwire first, with the +production `Compose`, `Select`, and `Send`, rather than the bitwire test-only +interpreter. Expected observations must change in bitwire first, with the corresponding contract decision; they are never regenerated from this runtime. diff --git a/core/ts/README.md b/core/ts/README.md index 0547f1e..8d1188b 100644 --- a/core/ts/README.md +++ b/core/ts/README.md @@ -1,12 +1,12 @@ # @bitspark/bitruntime/core -The TypeScript structural runtime for Bitwire 0.3.0, the `core` subpath of the +The TypeScript structural runtime for bitwire 0.3.0, the `core` subpath of the `@bitspark/bitruntime` package (v0.1.0 shipped it as `@bitspark/bitruntime-core`). The same subpath also exports the addressed operators (`at`, `mount`, `forward`), the local `pair`, the invocation lifecycle, `PublicError`, `respond` and trace propagation, which docs/port-from-nightseam.md describes. It depends only on the -public Bitwire contract and implements the common finite, acyclic, byte-keyed -Deixis structure. It does not implement carriers, RPC, or receive/close ownership. +public bitwire contract and implements the common finite, acyclic, byte-keyed +deixis structure. It does not implement carriers, RPC, or receive/close ownership. ```ts import type { Message, Wire } from '@bitspark/bitwire'; @@ -41,7 +41,7 @@ Derived sending is exactly `select(tree, path).own().send(message)`. Missing selection throws `MissingPathError` without calling a primitive or falling back to an ancestor. Existing primitive refusals propagate unchanged. Message and return-capability identity are preserved. A supplied primitive implements -Bitwire's admission and asynchronous dispatch rules; this structural operator +bitwire's admission and asynchronous dispatch rules; this structural operator does not create a dispatch queue or await an application result. `asAddressed(tree)` grants the separate `AddressedWire` access interface. @@ -53,6 +53,6 @@ profile. The adapter exposes no receiver or lifecycle access. There is no invers conversion from an opaque addressed router to a complete tree. Run `npm ci`, `npm run check`, and `npm test` at the repository root. The tests include an observation -driver against Bitwire's independently maintained full-tree oracle from +driver against bitwire's independently maintained full-tree oracle from `conformance/trees/expected.json` in contract 0.3.0, plus construction, identity, cycle, byte-key, deep traversal, refusal, and Unicode bridge checks. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index e76bdf1..55521e3 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,7 +1,7 @@ # bitruntime v0.2.0 The runtime path that hand-written adapters use, in Go and TypeScript, over -the public **Bitwire 0.3.0** contract, speaking the protocol revision +the public **bitwire 0.3.0** contract, speaking the protocol revision **`bitwire/1`**: - **core:** the structural core of v0.1.0 (trees, selection, decomposition, @@ -15,8 +15,8 @@ the public **Bitwire 0.3.0** contract, speaking the protocol revision - **dispatch:** the dispatcher and the `Call`, `Emit`, `Handle` and `Register` helpers (`call`, `emit`, `handle`, `register`, `onEvent`). -It is ported from Nightseam v0.6.0 (`5cc9723`), with provenance in `NOTICE` and -every change in [the port record](port-from-nightseam.md). Nightseam's recorded +It is ported from nightseam v0.6.0 (`5cc9723`), with provenance in `NOTICE` and +every change in [the port record](port-from-nightseam.md). nightseam's recorded defects in this path are fixed rather than ported: queued refusals are answered when a pair closes (nightseam#722), a pair's pending slot is free before its caller holds the answer (nightseam#658), ended carriers answer `disconnected`, @@ -25,11 +25,11 @@ message, and a closing peer's code reaches the far side. **Evidence** -- `bitwire/1` is unchanged: bitruntime interoperates with Nightseam v0.6.0 in +- `bitwire/1` is unchanged: bitruntime interoperates with nightseam v0.6.0 in every pairing of Go and TypeScript over real WebSockets, and sends the same bytes (`node scripts/interop.mjs`). The envelope tables of v0.6.0 are held by a running peer of each role. -- Bitwire's independent cases, run from Bitwire's own test-only module against +- bitwire's independent cases, run from bitwire's own test-only module against this release: lifecycle, composition groups, declared composites (reference realization; production gaps recorded) and trees, over the local pair and WebSockets in both directions. @@ -46,4 +46,4 @@ npm registry. Not in this release: live references, tunnels, the framed byte stream `bitwire-stream/1`, observation and tracing hooks, authentication integration, and received-context evidence as a contract field, which awaits a later -Bitwire revision. +bitwire revision. diff --git a/docs/bitsystem3-migration-kickoff.md b/docs/bitsystem3-migration-kickoff.md index 08a8b23..f315a82 100644 --- a/docs/bitsystem3-migration-kickoff.md +++ b/docs/bitsystem3-migration-kickoff.md @@ -6,7 +6,7 @@ from the bitruntime checkout. Read this entire document, then execute the work. ## Outcome Implement and land the first usable Go/TypeScript bitruntime milestone and move -bitsystem3 completely off Nightseam. Carry the work through design resolution, +bitsystem3 completely off nightseam. Carry the work through design resolution, implementation, independent validation, release where needed, consumer adoption, green pull requests and verified integration on the affected repositories' main branches. The main delivery issues are @@ -22,8 +22,8 @@ routine implementation details, tests, branches, commits, PRs or delivery steps. Follow each repository's actual review and protection rules. The bounded milestone is the hand-written adapter path used by bitsystem3. -Complete the supporting Bitwire contract/conformance work it needs. Later -generated consumers, live/tunnel migrations and the rest of Nightseam's +Complete the supporting bitwire contract/conformance work it needs. Later +generated consumers, live/tunnel migrations and the rest of nightseam's retirement remain separately tracked; do not silently expand this session to all of them. @@ -39,7 +39,7 @@ Inspect current local and remote state, uncommitted work, active branches, open PRs, issue comments and dependency versions. Read each repository's AGENTS.md, contribution/release guidance and local LAYOUT.md before touching it. -Read this repository's CHARTER.md and, in Bitwire: +Read this repository's CHARTER.md and, in bitwire: - decisions 0006, 0007, 0008, 0009, 0010 and especially [0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md); - the current Wire/WireTree/AddressedWire, profile, composition and carrier @@ -50,25 +50,25 @@ Read this repository's CHARTER.md and, in Bitwire: Also read the coordination issues [bitruntime #2](https://github.com/Bitspark/bitruntime/issues/2), -[Bitwire #47](https://github.com/Bitspark/bitwire/issues/47), -[Nightseam #725](https://github.com/Bitspark/nightseam/issues/725) and +[bitwire #47](https://github.com/Bitspark/bitwire/issues/47), +[nightseam #725](https://github.com/Bitspark/nightseam/issues/725) and [bitsystem3 #8](https://github.com/Bitspark/bitsystem3/issues/8). Review bitsystem3's actual adapters, CLI, browser client, docs/bitwire.md and the applied decisions at the end of research-docs/0001-carrier-stack-home.md. -Earlier research advice and Bitverse's historical architecture are evidence, not +Earlier research advice and bitverse's historical architecture are evidence, not authority overriding decision 0010 and current charters. -The architecture is decided: **Bitwire specifies and independently checks; -bitruntime implements.** Bitwire must not gain a production dependency on -bitruntime or Nightseam. bittype owns the new wire-independent language; -bitschema owns validation; Bitlink owns adapters and their generation. +The architecture is decided: **bitwire specifies and independently checks; +bitruntime implements.** bitwire must not gain a production dependency on +bitruntime or nightseam. bittype owns the new wire-independent language; +bitschema owns validation; bitlink owns adapters and their generation. The primitive/tree API and naming are settled; remaining runtime and lifecycle choices must be resolved against that contract. Read [Data / DataTree and Wire / WireTree](wire-under-bitwire.md). The final maintainer decision on 2026-09-26 supersedes the earlier `End` proposal and draft -[Bitwire PR #49](https://github.com/Bitspark/bitwire/pull/49): +[bitwire PR #49](https://github.com/Bitspark/bitwire/pull/49): ```ts interface Data { read(): Promise; } @@ -78,7 +78,7 @@ type DataTree = DeixisNode; type WireTree = DeixisNode; ``` -Both are full Deixis structures: own value, complete exact-byte-keyed children, +Both are full deixis structures: own value, complete exact-byte-keyed children, partial `at(path)`, decomposition and reconstruction. For a present path: ```text @@ -86,11 +86,11 @@ read(tree, path) = select(tree, path).own().read() send(tree, path, message) = select(tree, path).own().send(message) ``` -Bitstore owns Data/DataTree. A materialized `DeixisNode` remains the +bitstore owns Data/DataTree. A materialized `DeixisNode` remains the codec snapshot, not the public DataTree capability type. Storage work remains with its workstreams and does not enlarge this networking milestone. -Bitwire owns `Wire` and `WireTree`; bitruntime implements them. The old +bitwire owns `Wire` and `WireTree`; bitruntime implements them. The old path-taking interface is explicitly `AddressedWire`, never an alias for `WireTree`. `Endpoint` extends `AddressedWire` and `ReturnAddress` retains that access for immutable `bitwire/1` profile behavior. An opaque addressed router @@ -114,20 +114,20 @@ them with a documented version boundary: cancellation, invocation lifetime, path selection and endpoint ownership. - Exact-byte-key adaptation, explicitly admitted remote structures, partial selection behavior and retained-parts authority. Reconcile - [Deixis #49](https://github.com/Bitspark/deixis/issues/49), + [deixis #49](https://github.com/Bitspark/deixis/issues/49), [deixis-svc #1](https://github.com/Bitspark/deixis-svc/issues/1), [bitwire-svc #9](https://github.com/Bitspark/bitwire-svc/issues/9) and [bitstore-svc #13](https://github.com/Bitspark/bitstore-svc/issues/13). DataTree and WireTree must use the same full structural contract; a generic relay does not acquire application interpretation. It must not advertise an opaque router as a fully inspectable tree. - Record any shared Deixis library dependency and reconcile it with the charters. + Record any shared deixis library dependency and reconcile it with the charters. - Public lifecycle facilities: admission, capture, cancellation, actual body completion, control drain and retirement are distinct. A timeout does not retire executing work. Do not require concrete-peer access or a shared private ledger to prove cross-endpoint behavior. - Unforgeable received-context evidence and the engine's context/observation - hooks, designed together with the relevant Bitwire contract revision. + hooks, designed together with the relevant bitwire contract revision. - Carrier close/error classification, sendable versus observation-only close codes, buffering/backpressure bounds and exact package/module coordinates. @@ -154,7 +154,7 @@ bitruntime/ docs/ ``` -Shared vectors and specifications may remain language-neutral. Bitwire owns +Shared vectors and specifications may remain language-neutral. bitwire owns independent expected behavior; implementation tests here do not replace it. Optional later modules follow the same rule: live, tunnel, telemetry and auth-integration each have go/ and ts/ implementations when delivered. @@ -176,9 +176,9 @@ recorded exceptions. ## Implementation and evidence -Start from identified Nightseam source commits. The accepted v0.6.0 protocol +Start from identified nightseam source commits. The accepted v0.6.0 protocol baseline is commit 5cc9723a24646c40ed1861f892b2b23eb6d785d7; verify its tag -and provenance. Distinguish it from unreleased Nightseam improvements. Record +and provenance. Distinguish it from unreleased nightseam improvements. Record ported source and modifications in NOTICE. Add no compatibility aliases, re-exports, local replace directives or sibling-checkout build dependencies. @@ -187,7 +187,7 @@ explicit AddressedWire bridges, forwarding/local pairs, frame transports and WebSocket, the bitwire/1 peer and dial/accept setup, and dispatch/request/ response/event helpers in Go and TypeScript. Port only what the milestone needs and fix the recorded defects in that path, especially -[Nightseam #722](https://github.com/Bitspark/nightseam/issues/722): +[nightseam #722](https://github.com/Bitspark/nightseam/issues/722): queued refusals must not disappear when a pair closes. Review the lifecycle observations in #658 as well. Keep other #720โ€“#724 defects attached to their relevant successor modules; do not claim a tunnel or live @@ -202,11 +202,11 @@ Hold these distinctions throughout: - Contract changes update their native presentations and independent cases, including the other delivered languages where meaning changes; Go/TS runtime delivery alone is not eight-language runtime conformance. -- Independent expected observations come from the specification. Run Bitwire's +- Independent expected observations come from the specification. Run bitwire's cases against released bitruntime in a separate test-only module. Include unlawful implementations to show the cases reject real violations. - Exercise local pairs and WebSocket across Go/Go, TS/TS and both cross-language - directions, plus interoperability with actual Nightseam v0.6.0 peers. + directions, plus interoperability with actual nightseam v0.6.0 peers. - Verify path composition/remounting, retained child access, identity, local context/received evidence, reverse calls, cancellation and bounded overload. Where lifecycle acceptance requires two independent endpoint implementations @@ -220,14 +220,14 @@ Hold these distinctions throughout: Publish the smallest complete module set through the configured release process. Check module coordinates, version tags, provenance and fresh external installs; pin released dependencies in consumers. A workspace-only green build is not -release evidence. Keep any historical Nightseam interop dependency isolated to +release evidence. Keep any historical nightseam interop dependency isolated to test-only fixtures, outside published production dependencies. ## Move bitsystem3 and prove it remains the same live model Update all Go and TypeScript imports, manifests and lockfiles in one coherent consumer adoption. Cover the server, CLI, api/ts and browser conformance fixtures. -Audit go.mod/go.sum and package.json/package-lock.json for remaining Nightseam +Audit go.mod/go.sum and package.json/package-lock.json for remaining nightseam dependencies or alias shims. Preserve the space model: persistent ID, local facts, parent and name-to-child @@ -240,7 +240,7 @@ scope when selecting, mounting, remounting or retaining a child. Document what the migration actually enables; the common abstraction does not by itself provide distributed storage, cross-host transactions or authority propagation. -Keep the frontend/backend/PostgreSQL Docker setup and Logos DB persistence. +Keep the frontend/backend/PostgreSQL Docker setup and logos DB persistence. Planning, goals and inference remain outside this milestone. PostgreSQL keeps its normal database protocol. Preserve persist-before-acknowledge/publish and the handling of uncertain commits; never replay a mutation merely because a @@ -274,7 +274,7 @@ foundation as a side effect of this migration. Update the milestone and coordination issues with exact commits, dependency versions, checks and remaining scope. Close only issues whose acceptance is -demonstrated. Do not close the wider Nightseam-retirement or A0/A1 epics just +demonstrated. Do not close the wider nightseam-retirement or A0/A1 epics just because the bitsystem3 milestone passes. Finish with the landed commits/PRs, published module versions, conformance diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index 4f91b82..b510bad 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -1,20 +1,20 @@ -# Ported from Nightseam v0.6.0 +# Ported from nightseam v0.6.0 -The runtime path hand-written adapters use is ported from Nightseam +The runtime path hand-written adapters use is ported from nightseam **v0.6.0**, commit `5cc9723a24646c40ed1861f892b2b23eb6d785d7` (tag `v0.6.0`). None of the 41 unreleased commits after it is ported: the six that touch the runtime rename types, fix a test's own race, or add the declared-composition API -that Bitwire decision 0012 supersedes. The verbatim import is its own commit, so +that bitwire decision 0012 supersedes. The verbatim import is its own commit, so every adaptation is visible as a diff; `NOTICE` records the provenance. -`bitwire/1` is the behavior of that release (Bitwire decision 0008). The engine +`bitwire/1` is the behavior of that release (bitwire decision 0008). The engine sends, accepts and refuses the same frames, with the same close codes, bounds and serial rules. The envelope vectors in `vectors/bitwire-1/` are that release's tables, byte for byte. ## Where each piece went -| Nightseam v0.6.0 | bitruntime (Go) | TypeScript | +| nightseam v0.6.0 | bitruntime (Go) | TypeScript | | --- | --- | --- | | `duplex/go` `Conn`, `Frame`, codes, `Pipe` | `transports/go` | `transports/ts` | | `duplex/go/ws` | `transports/websocket/go` | `transports/ts` (`webSocketConnection`) | @@ -115,7 +115,7 @@ None changes a `bitwire/1` frame. Endpoint presents the protocol (research R20); a request whose method is not a canonical path encoding is answered `method_not_found`, as a v0.6.0 peer without that handler answers. Observer hooks and family labels are removed - until the engine's observation hooks are designed with Bitwire's + until the engine's observation hooks are designed with bitwire's received-context revision (charter ยง1). - **TypeScript.** The one closed classification is `PublicError` with code `disconnected`, keeping what ended the carrier as its `cause`: a send on a @@ -145,7 +145,7 @@ None changes a `bitwire/1` frame. capability, so its caller gets `busy` without proof that nothing was published; v0.6.0's removed `Peer.Call` refused it synchronously. Whether a synchronous refusal must prove non-publication is research decision 4, still - open in Bitwire. + open in bitwire. - A local pair and a peer's root still end their carrier when a bounded queue overflows. The send whose overflow ended it reports `core.ErrBackpressure` with the closed classification; later sends report the carrier closed. diff --git a/docs/wire-under-bitwire.md b/docs/wire-under-bitwire.md index d5247dc..806c9f9 100644 --- a/docs/wire-under-bitwire.md +++ b/docs/wire-under-bitwire.md @@ -1,7 +1,7 @@ # Data / DataTree and Wire / WireTree **Status: maintainer-selected contract, 2026-09-26; structural core implemented, -carrier/runtime migration pending.** [Bitwire decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md) +carrier/runtime migration pending.** [bitwire decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md) records the names and full structural obligation: ```ts @@ -15,14 +15,14 @@ type WireTree = DeixisNode; The earlier `End` primitive, preservation of addressed `Wire`, `ByteSource` naming, and `Bitdata = Deixis[Bytes]` as the public access model are superseded. They remain history in earlier discussions, including draft -[Bitwire PR #49](https://github.com/Bitspark/bitwire/pull/49). New code must use -the names above. [Bitwire #42](https://github.com/Bitspark/bitwire/issues/42) +[bitwire PR #49](https://github.com/Bitspark/bitwire/pull/49). New code must use +the names above. [bitwire #42](https://github.com/Bitspark/bitwire/issues/42) tracks interaction delivery; the shared structure is coordinated through -[Deixis #49](https://github.com/Bitspark/deixis/issues/49). +[deixis #49](https://github.com/Bitspark/deixis/issues/49). ## One complete structural contract -Deixis defines `Node[T] = T ร— FiniteMap[Bytes, Node[T]]`. Both tree types have +deixis defines `Node[T] = T ร— FiniteMap[Bytes, Node[T]]`. Both tree types have the same obligations, irrespective of whether an own primitive reads or sends: - an own value at every node, including nodes which also have children; @@ -70,7 +70,7 @@ public, released, versioned and consistent with the affected charters. TypeScript's structural types let the generic core consume either family's matching node interface directly. Go's recursive return types retain package -identity: Bitstore's `DeixisNode[Data]` needs an explicit adapter to Bitwire's +identity: bitstore's `DeixisNode[Data]` needs an explicit adapter to bitwire's `DeixisNode[Data]` before using this runtime's generic operators. This release does not claim direct Go assignment or introduce a storage-to-interaction production dependency. The adapter must preserve the common structural laws. @@ -145,10 +145,10 @@ reject adapters that collapse them. ## Ownership and protocol boundaries -Bitwire owns `Wire`, `WireTree`, `AddressedWire`, their laws, native -presentations and independent interaction cases. Bitstore owns `Data`, -`DataTree` and persistence APIs. Deixis owns the generic structure, laws and -codec. bitruntime implements the Bitwire contract; no new primitive repository +bitwire owns `Wire`, `WireTree`, `AddressedWire`, their laws, native +presentations and independent interaction cases. bitstore owns `Data`, +`DataTree` and persistence APIs. deixis owns the generic structure, laws and +codec. bitruntime implements the bitwire contract; no new primitive repository is needed. Existing `wire/go/` and `wire/ts/` presentations can hold the new types without inventing a module per type. @@ -187,7 +187,7 @@ does not prove complete remote-tree access. The Go/TypeScript core now provides composition, selection, complete parts, derived sending and a local AddressedWire facade. It is independently checked -against Bitwire's structural oracle. This is not a networking release. +against bitwire's structural oracle. This is not a networking release. The [kickoff](bitsystem3-migration-kickoff.md) still requires remaining runtime implementation, independent carrier conformance, remote bridges, admission and lifecycle evidence, interoperable released packages, and the complete diff --git a/vectors/bitwire-1/README.md b/vectors/bitwire-1/README.md index ae59a34..067b7cb 100644 --- a/vectors/bitwire-1/README.md +++ b/vectors/bitwire-1/README.md @@ -1,8 +1,8 @@ # bitwire/1 envelope vectors -Byte-identical copies of Nightseam v0.6.0's profile tables, taken from +Byte-identical copies of nightseam v0.6.0's profile tables, taken from `conformance/tables/` at `5cc9723a24646c40ed1861f892b2b23eb6d785d7` (tag -`v0.6.0`). Bitwire decision 0008 defines `bitwire/1` as that release's +`v0.6.0`). bitwire decision 0008 defines `bitwire/1` as that release's behavior, so these rows state what a `bitwire/1` peer accepts and refuses. | File | Holds | @@ -12,7 +12,7 @@ behavior, so these rows state what a `bitwire/1` peer accepts and refuses. | `unicode.json` | JSON texts whose strings are, or are not, Unicode scalar values | The Go and TypeScript native tests read these files. They are implementation -evidence, not Bitwire's independent conformance: Bitwire publishes the +evidence, not bitwire's independent conformance: bitwire publishes the normative tables and manifest of `bitwire/1` under its issue #39, and its cases judge released bitruntime from a test-only module. When that publication exists, these copies are replaced by it rather than edited. From ea43a68a4e72f20c0fbd2630b67f4e28b293daec Mon Sep 17 00:00:00 2001 From: Julian Matschinske Date: Sat, 26 Sep 2026 13:08:50 +0200 Subject: [PATCH 39/39] core, engine, transports: fix what the independent review of the TypeScript port found - A close code the browser WebSocket API refuses (anything but 1000 and 3000-4999) threw inside the peer's close and left the socket open; the connection now closes without the code instead. - A connected peer whose connection is closing refused sends with not_connected, and an emit racing the close ended the peer and lost the far side's close code; it now reports disconnected and leaves the close to arrive. - A reply a pair admitted but its caller's return capability refused, such as one over a smaller frame limit before a forwarder, stranded the caller until its deadline, in Go and TypeScript; the caller now gets the bounded internal error. - A handler could reach the endpoint behind its dispatcher through a TypeScript-private field; it is now #private. Each has a regression test that fails without its fix. The Go lifecycle test waiting on onRetired now waits for the callback, which runs after Retired reports true outside the lock (a race inherited from v0.6.0's test). Co-Authored-By: Claude Opus 5.5 (1M context) --- core/go/forward_test.go | 39 +++++++++++++ core/go/invocation_test.go | 18 +++++- core/go/pair.go | 15 ++++- core/ts/src/pair.ts | 22 +++++++- dispatch/ts/src/dispatcher.ts | 10 ++-- docs/port-from-nightseam.md | 12 +++- engine/ts/src/peer.ts | 17 +++--- engine/ts/src/wire.ts | 7 ++- engine/ts/test/review.test.ts | 103 ++++++++++++++++++++++++++++++++++ transports/ts/src/index.ts | 10 +++- 10 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 engine/ts/test/review.test.ts diff --git a/core/go/forward_test.go b/core/go/forward_test.go index 1b5d4b8..e6009f1 100644 --- a/core/go/forward_test.go +++ b/core/go/forward_test.go @@ -8,8 +8,10 @@ import ( "encoding/json" "errors" "reflect" + "strings" "sync" "testing" + "time" core "github.com/Bitspark/bitruntime/core/go" dispatch "github.com/Bitspark/bitruntime/dispatch/go" @@ -203,3 +205,40 @@ func TestForwardWireCarriesUnknownPathsAndReverseCallsAcrossPeers(t *testing.T) } } } + +// TestAReplyRefusedFurtherBackReachesTheCaller: caller โ†’ pair A (small frames) +// โ†’ Forward โ†’ pair B โ†’ handler. B admits the reply, and A's return capability +// refuses it for its size. The caller gets the bounded internal error at once, +// not its deadline. +func TestAReplyRefusedFurtherBackReachesTheCaller(t *testing.T) { + caller, forwardA, err := core.NewPair(core.PairOptions{MaxFrameBytes: 600, RequestTimeout: 10 * time.Second}) + if err != nil { + t.Fatal(err) + } + defer caller.Close(transports.CodeNormal, "") + forwardB, handler, err := core.NewPair(core.PairOptions{}) + if err != nil { + t.Fatal(err) + } + defer forwardB.Close(transports.CodeNormal, "") + stop, err := core.Forward(forwardA, forwardB) + if err != nil { + t.Fatal(err) + } + defer stop() + d, err := dispatch.NewDispatcher(handler) + if err != nil { + t.Fatal(err) + } + if _, err := dispatch.Handle(d, []string{"big"}, func(context.Context, json.RawMessage) (any, error) { + return strings.Repeat("x", 2000), nil + }); err != nil { + t.Fatal(err) + } + started := time.Now() + err = dispatch.Call(context.Background(), caller, []string{"big"}, nil, nil, dispatch.CallOptions{Timeout: 10 * time.Second}) + var public *core.PublicError + if !errors.As(err, &public) || public.Code != "internal" || time.Since(started) > 5*time.Second { + t.Fatalf("the caller got %v after %v", err, time.Since(started)) + } +} diff --git a/core/go/invocation_test.go b/core/go/invocation_test.go index 2841caf..d51ca56 100644 --- a/core/go/invocation_test.go +++ b/core/go/invocation_test.go @@ -213,8 +213,8 @@ func TestIndependentEndpointParticipatesThroughThePublicVocabulary(t *testing.T) t.Fatal("no outcome") } waitRetired(t, invocation) - if endpoint.retirements.Load() != 1 { - t.Fatalf("retirements: %d", endpoint.retirements.Load()) + if got := waitRetirements(endpoint, 1); got != 1 { + t.Fatalf("retirements: %d", got) } } @@ -405,7 +405,7 @@ func TestSequentialCompletionsBeyondCapacityRetainNothing(t *testing.T) { continue } } - if got := endpoint.retirements.Load(); got != 32 { + if got := waitRetirements(endpoint, 32); got != 32 { t.Fatalf("retirements after 32 sequential completions: %d", got) } } @@ -493,6 +493,18 @@ func (b *bareReturn) Send(path []string, message wire.Message) error { return nil } +// waitRetirements waits for the onRetired callbacks, which run after an +// invocation reports Retired, outside its lock, and returns their count. +func waitRetirements(endpoint *invocationEndpoint, want int64) int64 { + for range 500 { + if got := endpoint.retirements.Load(); got >= want { + return got + } + time.Sleep(2 * time.Millisecond) + } + return endpoint.retirements.Load() +} + func waitRetired(t *testing.T, invocation *core.Invocation) { t.Helper() for range 500 { diff --git a/core/go/pair.go b/core/go/pair.go index 837eda0..ee0989e 100644 --- a/core/go/pair.go +++ b/core/go/pair.go @@ -547,5 +547,18 @@ func (r *localReturn) Send(path []string, message wire.Message) (err error) { err = errors.New("bitruntime: wire return failed") } }() - return WithoutUnpublishedProof(r.call.key.address.Wire.Send(path, message)) + if err := r.call.key.address.Wire.Send(path, message); err != nil { + // The caller's own return capability can refuse what this pair + // admitted, such as a reply over a smaller frame limit further back. + // The call is already answered here, so the caller gets the bounded + // internal error directly rather than waiting for its deadline. + fallback := wire.Message{Frame: wire.ProfileFrame{Version: 1, Kind: wire.ProfileResponse, ID: message.Frame.ID, + Error: &wire.ProfileError{Code: "internal", Message: "Response could not be encoded"}, + Traceparent: message.Frame.Traceparent, Tracestate: message.Frame.Tracestate}} + if r.call.key.address.Wire.Send(nil, fallback) == nil { + return &PublicError{Code: "internal", Message: "Response could not be encoded"} + } + return WithoutUnpublishedProof(err) + } + return nil } diff --git a/core/ts/src/pair.ts b/core/ts/src/pair.ts index e0fe49d..40a9f86 100644 --- a/core/ts/src/pair.ts +++ b/core/ts/src/pair.ts @@ -1,4 +1,4 @@ -import type { AddressedWire, Endpoint, Message, Path, Receiver, ReturnAddress } from '@bitspark/bitwire'; +import type { AddressedWire, Endpoint, Message, Path, ProfileFrame, Receiver, ReturnAddress } from '@bitspark/bitwire'; import { PublicError, ReceiverExistsError } from './error.ts'; import { Invocation, defaultInvocationLimits } from './invocation.ts'; import { @@ -13,7 +13,7 @@ import { import { ended, profileFrame, publicError } from './internal/frame.ts'; import { LIMIT_DEFAULTS, limits } from './internal/limits.ts'; import { encodePath } from './internal/path.ts'; -import { traceOf } from './internal/trace.ts'; +import { traceMembers, traceOf } from './internal/trace.ts'; import { respond } from './respond.ts'; import { defaultPropagator, type Propagator } from './trace.ts'; @@ -343,6 +343,24 @@ export function pair(options: PairOptions = {}): [Endpoint, Endpoint] { try { message.return!.wire.send([], { frame: checked }); } catch (error) { + // The caller's own return capability can refuse what this pair + // admitted, such as a reply over a smaller frame limit further + // back. The call is already answered here, so the caller gets + // the bounded internal error directly rather than waiting for + // its deadline. + try { + message.return!.wire.send([], { + frame: { + version: 1, + kind: 'response', + id: frame.id, + error: { code: 'internal', message: 'Response could not be encoded' }, + ...traceMembers(traceOf(checked)), + } as ProfileFrame, + }); + } catch { + /* The caller cannot take even the bounded answer. */ + } throw error instanceof PublicError ? publicError(error) : error; } }, diff --git a/dispatch/ts/src/dispatcher.ts b/dispatch/ts/src/dispatcher.ts index 45c100f..73d7793 100644 --- a/dispatch/ts/src/dispatcher.ts +++ b/dispatch/ts/src/dispatcher.ts @@ -38,11 +38,13 @@ export class Dispatcher implements Registry { private readonly prefixes = new Map(); private ended = false; private detach: (() => void) | undefined; - private readonly root: Endpoint; + // Truly private: a handler holds this dispatcher as its context.wire, and + // must not reach the carrier behind it. + readonly #root: Endpoint; private readonly ownEndpoint: boolean; constructor(root: Endpoint, options: DispatcherOptions = {}) { - this.root = root; + this.#root = root; this.ownEndpoint = options.ownEndpoint ?? false; const detach = root.receive({ message: (path, message) => this.deliver(path, message), @@ -56,7 +58,7 @@ export class Dispatcher implements Registry { } send(path: Path, message: Message): void { if (this.ended) throw disconnected(); - this.root.send(path, message); + this.#root.send(path, message); } /** Routes exactly path to receiver. A path has one registration. */ register(path: Path, receiver: Receiver): () => void { @@ -164,7 +166,7 @@ export class Dispatcher implements Registry { /* Each owner receives its end. */ } } - if (this.ownEndpoint) this.root.close(code, reason); + if (this.ownEndpoint) this.#root.close(code, reason); } } /** Attaches a dispatcher to an endpoint, borrowed unless the options transfer its closure. */ diff --git a/docs/port-from-nightseam.md b/docs/port-from-nightseam.md index b510bad..c898ab9 100644 --- a/docs/port-from-nightseam.md +++ b/docs/port-from-nightseam.md @@ -98,6 +98,10 @@ None changes a `bitwire/1` frame. and the engine fails its connection when even that does not fit, as v0.6.0's raw response path did. Through v0.6.0's root the remote caller waited for its deadline. +- **A reply refused further back still reaches its caller.** When a pair has + admitted a reply that its caller's return capability then refuses, such as + one over a smaller frame limit before a forwarder, the caller gets the + bounded `internal` error instead of waiting for its deadline. - **Observe-only close codes (R27).** `transports.Sendable` separates codes that may be sent from 1005, 1006 and 1015. Transports refuse the latter with `ErrUnsendableCode`; a peer asked to close with one aborts instead. @@ -130,7 +134,13 @@ None changes a `bitwire/1` frame. a request's frame arrived with rather than what a propagator placed on the handler's context, and local structured frames omit an empty trace member, as the peer's own frames always did; v0.6.0's root refused its own response - to a request that carried a `tracestate` alone. + to a request that carried a `tracestate` alone. A WebSocket connection whose + platform refuses a close code (a browser accepts only 1000 and 3000โ€“4999) + closes without one rather than staying open. A connected peer whose + connection is closing reports `disconnected`, and an emit that races the + close no longer ends the peer before the far side's close code arrives. A + dispatcher keeps its endpoint in a `#private` field, so a handler's context + cannot reach it. ## Kept as v0.6.0 behaved diff --git a/engine/ts/src/peer.ts b/engine/ts/src/peer.ts index 7a39e8e..44207fb 100644 --- a/engine/ts/src/peer.ts +++ b/engine/ts/src/peer.ts @@ -325,8 +325,7 @@ export class Peer { } catch (error) { return Promise.reject(new UnpublishedError(error)); } - if (!this.isOpen()) - return Promise.reject(new UnpublishedError(new PublicError('not_connected', 'Peer is not connected.'))); + if (!this.isOpen()) return Promise.reject(new UnpublishedError(this.notOpen())); if (options.signal?.aborted) return Promise.reject(new UnpublishedError(new PublicError('cancelled', 'Call was cancelled before sending.'))); if (this.pending.size >= this.limits.maxPendingRequests) { @@ -410,6 +409,12 @@ export class Peer { return this.state === 'connected' && this.connection?.state === 'open'; } + /** Why a send is refused while the peer is not open: a connected peer whose + * connection is closing has ended (R26); one that never connected has not. */ + private notOpen(): PublicError { + return this.state === 'connected' ? ended() : new PublicError('not_connected', 'Peer is not connected.'); + } + private takePending(id: string): Pending | undefined { const pending = this.pending.get(id); if (!pending) return; @@ -432,7 +437,7 @@ export class Peer { const ticket = envelope.kind === 'request' ? this.nextTicket++ : undefined; if (ticket !== undefined) this.publishing.push(ticket); try { - if (!this.isOpen()) throw new PublicError('not_connected', 'Peer is not connected.'); + if (!this.isOpen()) throw this.notOpen(); let text: string; try { text = JSON.stringify(envelope, (_key, value: unknown) => { @@ -459,8 +464,7 @@ export class Peer { const deadline = Date.now() + this.limits.writeTimeoutMs; for (;;) { if (abandoned?.aborted) return; - if (!this.isOpen() || this.connection !== connection) - throw new PublicError('not_connected', 'Peer is not connected.'); + if (!this.isOpen() || this.connection !== connection) throw this.notOpen(); // The queue is the one ordering gate: room alone is not enough, the // sender must also be the one whose turn it is. That is what keeps // publication in the order senders reserved, which the request serial @@ -485,8 +489,7 @@ export class Peer { }); if (!room) { if (abandoned?.aborted) return; - if (!this.isOpen() || this.connection !== connection) - throw new PublicError('not_connected', 'Peer is not connected.'); + if (!this.isOpen() || this.connection !== connection) throw this.notOpen(); endOnRefusal = true; throw new PublicError('busy', 'Output consumer is stalled; queue limit reached.'); } diff --git a/engine/ts/src/wire.ts b/engine/ts/src/wire.ts index 8f2865c..0dd87cd 100644 --- a/engine/ts/src/wire.ts +++ b/engine/ts/src/wire.ts @@ -170,7 +170,12 @@ export function rootWire(peer: Peer, options: RootOptions): Endpoint { // Invoke admission now, in wire order; only completion is asynchronous. void options .emit(name, frame.data, { meta: frame.meta ? { ...frame.meta } : undefined }, outgoingTrace(frame)) - .catch((error: unknown) => options.fail(publicError(error))); + .catch((error: unknown) => { + // A peer whose connection is closing is ending already; its close + // brings the remote's code and reason, which failing here would lose. + const refused = publicError(error); + if (refused.code !== 'disconnected') options.fail(refused); + }); continue; } if (frame.kind !== 'request') continue; diff --git a/engine/ts/test/review.test.ts b/engine/ts/test/review.test.ts new file mode 100644 index 0000000..190d6c2 --- /dev/null +++ b/engine/ts/test/review.test.ts @@ -0,0 +1,103 @@ +// Regressions for what the independent review of the TypeScript port found. +// Each held for Nightseam v0.6.0 as well; each is fixed here. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Endpoint } from '@bitspark/bitwire'; +import { forward, pair, PublicError } from '../../../core/ts/src/index.ts'; +import { call, createDispatcher, emit, handle } from '../../../dispatch/ts/src/index.ts'; +import { pipe, webSocketConnection, type FrameConnection, type WebSocketLike } from '../../../transports/ts/src/index.ts'; +import { Peer } from '../src/index.ts'; + +test('a close code the WebSocket API refuses still closes the socket', () => { + const closes: unknown[][] = []; + const socket: WebSocketLike = { + readyState: 1, + bufferedAmount: 0, + send() {}, + // The browser API accepts only 1000 and 3000โ€“4999. + close(...args: unknown[]) { + const [code] = args as [number | undefined]; + closes.push(args); + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException('refused', 'InvalidAccessError'); + } + }, + addEventListener() {}, + removeEventListener() {}, + }; + webSocketConnection(socket).close(1002, 'wire event rejected'); + assert.deepEqual(closes, [[1002, 'wire event rejected'], []]); +}); + +test('a connected peer whose connection is closing reports disconnected, and a racing emit keeps the remote close', async () => { + const [a, b] = pipe(); + let state: FrameConnection['state'] = 'open'; + const closing: FrameConnection = { + get state() { + return state === 'open' ? a.state : state; + }, + get buffered() { + return a.buffered; + }, + send: (frame) => a.send(frame), + close: (code, reason) => a.close(code, reason), + listen: (handlers) => a.listen(handlers), + }; + const client = new Peer(); + const server = new Peer({ role: 'server' }); + await Promise.all([client.attach(closing), server.attach(b)]); + const ended = new Promise((resolve) => client.onClose(resolve)); + // The socket has received the far side's close frame but not yet fired + // its close event. + state = 'closing'; + await assert.rejects(call(client.wire(), ['anything']), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'disconnected'); + return true; + }); + emit(client.wire(), ['event'], 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(client.status, 'connected', 'a racing emit failed the peer before its close arrived'); + state = 'open'; + server.close(); + const error = await ended; + assert.equal(error.code, 'disconnected'); +}); + +test('a reply refused further back reaches the caller as the bounded internal error', async () => { + // caller โ†’ pair A (small frames) โ†’ forward โ†’ pair B โ†’ handler. B admits the + // reply; A's return capability refuses it for its size. + const [callerSide, forwardA] = pair({ maxFrameBytes: 600, requestTimeoutMs: 10_000 }); + const [forwardB, handlerSide] = pair(); + const stop = forward(forwardA, forwardB); + handle(createDispatcher(handlerSide), ['big'], () => 'x'.repeat(2000)); + const started = Date.now(); + try { + await assert.rejects(call(callerSide, ['big'], null), (error: unknown) => { + assert.ok(error instanceof PublicError); + assert.equal(error.code, 'internal'); + return true; + }); + assert.ok(Date.now() - started < 5_000, 'the caller waited for its deadline'); + } finally { + stop(); + callerSide.close(); + forwardB.close(); + } +}); + +test("a handler's context does not reach the carrier behind its dispatcher", async () => { + const [a, b] = pair(); + let reached: unknown = 'unset'; + handle(createDispatcher(b), ['probe'], (_params, context) => { + const registry = context.wire as unknown as Record; + reached = registry.root ?? Object.values(registry).find((value) => value === b); + return null; + }); + await call(a, ['probe'], null); + assert.equal(reached, undefined); + a.close(); +}); + +// Keep the Endpoint import meaningful to the type checker. +export type { Endpoint }; diff --git a/transports/ts/src/index.ts b/transports/ts/src/index.ts index b65f688..ed4a6c9 100644 --- a/transports/ts/src/index.ts +++ b/transports/ts/src/index.ts @@ -187,7 +187,15 @@ export function webSocketConnection(socket: WebSocketLike): FrameConnection { }, close(code = CODE_NORMAL, reason = '') { refuseUnsendable(code); - socket.close(code, reason); + try { + socket.close(code, reason); + } catch { + // The browser WebSocket API accepts only 1000 and 3000โ€“4999 and a + // reason of at most 123 bytes. A code or reason it refuses must not + // leave the connection open: close without them, and the far side + // observes no status (1005). + socket.close(); + } }, listen(handlers) { listeners.add(handlers);