From d10e9b5704d1dec26bc135b71cd1606851f09074 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:17:43 +0000 Subject: [PATCH 001/140] fix(stream): preserve pipe channels and tcp auth metadata Co-Authored-By: Virgil --- adapter/tcp/tcp.go | 5 +- adapter/tcp/tcp_test.go | 154 ++++++++++++++++++++++++++++++++++++++++ hub.go | 48 ++++++++++++- hub_test.go | 126 ++++++++++++++++++++++++++++++++ stream.go | 8 +++ 5 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 adapter/tcp/tcp_test.go create mode 100644 hub_test.go diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index fb0d097..0b52170 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -160,14 +160,17 @@ func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub return } + result := stream.AuthResult{Valid: true} if auth := a.config.ConnAuthenticator; auth != nil { - result := auth.AuthenticateConn(handshake) + result = auth.AuthenticateConn(handshake) if !result.Valid { return } } peer := stream.NewPeer("tcp") + peer.UserID = result.UserID + peer.Claims = result.Claims _ = hub.AddPeer(peer) _ = hub.SubscribePeer(peer, "*") defer hub.RemovePeer(peer) diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go new file mode 100644 index 0000000..a27c7ff --- /dev/null +++ b/adapter/tcp/tcp_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tcp + +import ( + "context" + "net" + "testing" + "time" + + "dappco.re/go/stream" +) + +func TestTCP_Listen_Good(t *testing.T) { + hub := stream.NewHubWithConfig(stream.HubConfig{ + OnConnect: func(peer *stream.Peer) { + if peer.UserID != "user-42" { + t.Errorf("peer.UserID = %q, want %q", peer.UserID, "user-42") + } + if peer.Claims["role"] != "admin" { + t.Errorf("peer.Claims[role] = %v, want %q", peer.Claims["role"], "admin") + } + }, + }) + + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + if string(handshake) != "hello" { + return stream.AuthResult{Valid: false} + } + return stream.AuthResult{ + Valid: true, + UserID: "user-42", + Claims: map[string]any{"role": "admin"}, + } + }), + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + if _, err := connection.Write(encodeFrame("", []byte("hello"))); err != nil { + t.Fatalf("Write() error = %v", err) + } + + waitForPeerCount(t, hub, 1) +} + +func TestTCP_Listen_Bad(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + return stream.AuthResult{Valid: false} + }), + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + if _, err := connection.Write(encodeFrame("", []byte("nope"))); err != nil { + t.Fatalf("Write() error = %v", err) + } + + waitForPeerCount(t, hub, 0) +} + +func TestTCP_Listen_Ugly(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + HandshakeTimeout: 50 * time.Millisecond, + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + time.Sleep(120 * time.Millisecond) + waitForPeerCount(t, hub, 0) +} + +func waitForListenerAddress(t *testing.T, adapter *Adapter) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + adapter.mu.Lock() + listener := adapter.listener + adapter.mu.Unlock() + if listener != nil { + return listener.Addr().String() + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for listener") + return "" +} + +func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.PeerCount() == expected { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), expected) +} diff --git a/hub.go b/hub.go index 70098ab..a2e06a0 100644 --- a/hub.go +++ b/hub.go @@ -26,6 +26,7 @@ type Hub struct { unregister chan *Peer channels map[string]map[*Peer]bool handlers map[string]map[uint64]func([]byte) + publishers map[uint64]func(string, []byte) nextID uint64 config HubConfig done chan struct{} @@ -65,6 +66,7 @@ func NewHubWithConfig(config HubConfig) *Hub { unregister: make(chan *Peer, 256), channels: map[string]map[*Peer]bool{}, handlers: map[string]map[uint64]func([]byte){}, + publishers: map[uint64]func(string, []byte){}, config: config, done: make(chan struct{}), } @@ -125,11 +127,12 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { } handlers := cloneHandlers(h.handlers[channel]) wildcardHandlers := cloneHandlers(h.handlers["*"]) + publishers := clonePublishHandlers(h.publishers) h.mu.RUnlock() if !running { return ErrHubNotRunning } - if len(peers) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 { + if len(peers) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 && len(publishers) == 0 { return nil } for peer := range peers { @@ -140,6 +143,7 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { } h.invokeHandlers(handlers, frame) h.invokeHandlers(wildcardHandlers, frame) + h.invokePublishHandlers(publishers, channel, frame) return nil } @@ -484,6 +488,37 @@ func (h *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { } } +func (h *Hub) subscribePublished(handler func(string, []byte)) func() { + if h == nil || handler == nil { + return func() {} + } + h.mu.Lock() + if h.publishers == nil { + h.publishers = map[uint64]func(string, []byte){} + } + h.nextID++ + id := h.nextID + h.publishers[id] = handler + h.mu.Unlock() + + return func() { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.publishers, id) + } +} + +func (h *Hub) invokePublishHandlers(handlers []func(string, []byte), channel string, frame []byte) { + for _, handler := range handlers { + func(fn func(string, []byte)) { + defer func() { + _ = recover() + }() + fn(channel, frame) + }(handler) + } +} + func cloneHandlers(handlers map[uint64]func([]byte)) []func([]byte) { if len(handlers) == 0 { return nil @@ -494,3 +529,14 @@ func cloneHandlers(handlers map[uint64]func([]byte)) []func([]byte) { } return cloned } + +func clonePublishHandlers(handlers map[uint64]func(string, []byte)) []func(string, []byte) { + if len(handlers) == 0 { + return nil + } + cloned := make([]func(string, []byte), 0, len(handlers)) + for _, handler := range handlers { + cloned = append(cloned, handler) + } + return cloned +} diff --git a/hub_test.go b/hub_test.go new file mode 100644 index 0000000..650f435 --- /dev/null +++ b/hub_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream + +import ( + "context" + "testing" + "time" +) + +func TestHub_Pipe_Good(t *testing.T) { + sourceHub := NewHub() + destinationHub := NewHub() + + sourceContext, sourceCancel := context.WithCancel(context.Background()) + defer sourceCancel() + destinationContext, destinationCancel := context.WithCancel(context.Background()) + defer destinationCancel() + + go sourceHub.Run(sourceContext) + go destinationHub.Run(destinationContext) + waitForRunningHub(t, sourceHub) + waitForRunningHub(t, destinationHub) + + stop := Pipe(sourceHub, destinationHub) + defer stop() + + received := make(chan []byte, 1) + unsubscribe := destinationHub.Subscribe("hashrate", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := sourceHub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for forwarded frame") + } +} + +func TestHub_Pipe_Bad(t *testing.T) { + sourceHub := NewHub() + destinationHub := NewHub() + + sourceContext, sourceCancel := context.WithCancel(context.Background()) + defer sourceCancel() + destinationContext, destinationCancel := context.WithCancel(context.Background()) + defer destinationCancel() + + go sourceHub.Run(sourceContext) + go destinationHub.Run(destinationContext) + waitForRunningHub(t, sourceHub) + waitForRunningHub(t, destinationHub) + + stop := Pipe(sourceHub, destinationHub) + received := make(chan []byte, 1) + unsubscribe := destinationHub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + stop() + + if err := sourceHub.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + t.Fatalf("received unexpected frame after stop: %q", string(frame)) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHub_Pipe_Ugly(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + stop := Pipe(hub, hub) + defer stop() + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("agent", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := hub.Publish("agent", []byte("event")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "event" { + t.Fatalf("received frame = %q, want %q", string(frame), "event") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for local frame") + } +} + +func waitForRunningHub(t *testing.T, hub *Hub) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + hub.mu.RLock() + running := hub.running + hub.mu.RUnlock() + if running { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for hub to start") +} diff --git a/stream.go b/stream.go index c95c95b..d2585ea 100644 --- a/stream.go +++ b/stream.go @@ -202,6 +202,14 @@ func Pipe(src Stream, dst Stream) func() { if src == nil || dst == nil || src == dst { return func() {} } + type publishSubscriber interface { + subscribePublished(handler func(string, []byte)) func() + } + if publisher, ok := src.(publishSubscriber); ok { + return publisher.subscribePublished(func(channel string, frame []byte) { + _ = dst.Publish(channel, frame) + }) + } stop := src.Subscribe("*", func(frame []byte) { _ = dst.Broadcast(frame) }) From 0c17b058fa89ff979b94d6b1295df01e91a23b01 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:26:55 +0000 Subject: [PATCH 002/140] fix(stream): add transport heartbeats and deadlines Co-Authored-By: Virgil --- adapter/tcp/tcp.go | 9 +- adapter/ws/ws.go | 47 ++++++++-- adapter/ws/ws_test.go | 199 ++++++++++++++++++++++++++++++++++++++++++ hub.go | 23 +++-- hub_config.go | 14 +++ 5 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 adapter/ws/ws_test.go diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 0b52170..7b3fab1 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -175,7 +175,7 @@ func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub _ = hub.SubscribePeer(peer, "*") defer hub.RemovePeer(peer) - go a.writePump(ctx, conn, peer) + go a.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { channel, frame, err := readFrame(conn, 0) @@ -192,7 +192,7 @@ func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub func (a *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer, hub *stream.Hub) { defer conn.Close() - go a.writePump(ctx, conn, peer) + go a.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { channel, frame, err := readFrame(conn, 0) if err != nil { @@ -207,7 +207,7 @@ func (a *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer } } -func (a *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Peer) { +func (a *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Peer, writeTimeout time.Duration) { for { select { case <-ctx.Done(): @@ -216,6 +216,9 @@ func (a *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Pee if !ok { return } + if writeTimeout > 0 { + _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + } if _, err := conn.Write(frame); err != nil { return } diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index da3d4ea..29df609 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -123,13 +123,15 @@ func (a *Adapter) Handler() http.HandlerFunc { defer a.hub.RemovePeer(peer) defer conn.Close() - go func() { - for frame := range peer.SendQueue() { - if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil { - return - } - } - }() + hubConfig := a.hub.Config() + if hubConfig.PongTimeout > 0 { + _ = conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) + }) + } + + go a.writePump(conn, peer, hubConfig.WriteTimeout, hubConfig.HeartbeatInterval) conn.SetReadLimit(1 << 20) for { @@ -162,3 +164,34 @@ func (a *Adapter) Handler() http.HandlerFunc { peer.Close() } } + +func (a *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, writeTimeout, heartbeatInterval time.Duration) { + var ticker *time.Ticker + var heartbeat <-chan time.Time + if heartbeatInterval > 0 { + ticker = time.NewTicker(heartbeatInterval) + defer ticker.Stop() + heartbeat = ticker.C + } + for { + select { + case <-heartbeat: + if writeTimeout > 0 { + _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + } + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case frame, ok := <-peer.SendQueue(): + if !ok { + return + } + if writeTimeout > 0 { + _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + } + if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil { + return + } + } + } +} diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go new file mode 100644 index 0000000..4c05f39 --- /dev/null +++ b/adapter/ws/ws_test.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gorilla/websocket" + + "dappco.re/go/stream" +) + +func TestAdapter_Handler_Good(t *testing.T) { + hub := stream.NewHubWithConfig(stream.HubConfig{ + HeartbeatInterval: 20 * time.Millisecond, + PongTimeout: 100 * time.Millisecond, + WriteTimeout: 100 * time.Millisecond, + }) + + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + receivedPing := make(chan struct{}, 1) + receivedFrame := make(chan []byte, 1) + conn.SetPingHandler(func(appData string) error { + select { + case receivedPing <- struct{}{}: + default: + } + return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second)) + }) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + messageType, payload, err := conn.ReadMessage() + if err != nil { + return + } + if messageType == websocket.TextMessage { + receivedFrame <- append([]byte(nil), payload...) + } + } + }() + + if err := conn.WriteJSON(stream.Message{ + Type: stream.TypeSubscribe, + Channel: "hashrate", + }); err != nil { + t.Fatalf("WriteJSON() error = %v", err) + } + + waitForChannelSubscriberCount(t, hub, "hashrate", 1) + + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-receivedFrame: + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for published frame") + } + + select { + case <-receivedPing: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for heartbeat ping") + } + + _ = conn.Close() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for client reader to exit") + } +} + +func TestAdapter_Handler_Bad(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Authenticator: stream.NewAPIKeyAuth(map[string]string{"valid-key": "user-1"}), + }) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + _, resp, err := websocket.DefaultDialer.Dial(websocketURL(server.URL), nil) + if err == nil { + t.Fatal("Dial() error = nil, want auth failure") + } + if resp == nil { + t.Fatal("Dial() response = nil, want 401 response") + } + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("StatusCode = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } +} + +func TestAdapter_Handler_Ugly(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + if err := conn.WriteJSON(stream.Message{ + Type: stream.TypeSubscribe, + Channel: "block", + }); err != nil { + t.Fatalf("WriteJSON() error = %v", err) + } + + if err := conn.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + waitForPeerCount(t, hub, 0) +} + +func dialWebSocket(t *testing.T, serverURL string, header http.Header) *websocket.Conn { + t.Helper() + conn, resp, err := websocket.DefaultDialer.Dial(websocketURL(serverURL), header) + if err != nil { + if resp != nil { + t.Fatalf("Dial() error = %v, status = %s", err, resp.Status) + } + t.Fatalf("Dial() error = %v", err) + } + return conn +} + +func websocketURL(serverURL string) string { + parsed, err := url.Parse(serverURL) + if err != nil { + return serverURL + } + switch parsed.Scheme { + case "http": + parsed.Scheme = "ws" + case "https": + parsed.Scheme = "wss" + } + return parsed.String() +} + +func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.PeerCount() == expected { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), expected) +} + +func waitForChannelSubscriberCount(t *testing.T, hub *stream.Hub, channel string, expected int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.ChannelSubscriberCount(channel) == expected { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("ChannelSubscriberCount(%q) = %d, want %d", channel, hub.ChannelSubscriberCount(channel), expected) +} diff --git a/hub.go b/hub.go index a2e06a0..9eecdcd 100644 --- a/hub.go +++ b/hub.go @@ -50,15 +50,7 @@ func NewHub() *Hub { // OnConnect: func(p *stream.Peer) { log.Println("connected", p.ID) }, // }) func NewHubWithConfig(config HubConfig) *Hub { - if config.HeartbeatInterval == 0 { - config.HeartbeatInterval = DefaultHubConfig().HeartbeatInterval - } - if config.PongTimeout == 0 { - config.PongTimeout = DefaultHubConfig().PongTimeout - } - if config.WriteTimeout == 0 { - config.WriteTimeout = DefaultHubConfig().WriteTimeout - } + config = normalizeHubConfig(config) return &Hub{ peers: map[*Peer]bool{}, broadcast: make(chan []byte, 256), @@ -72,6 +64,19 @@ func NewHubWithConfig(config HubConfig) *Hub { } } +// Config returns a normalised copy of the hub configuration. +// +// cfg := hub.Config() +func (h *Hub) Config() HubConfig { + if h == nil { + return DefaultHubConfig() + } + h.mu.RLock() + config := h.config + h.mu.RUnlock() + return normalizeHubConfig(config) +} + // Run starts the hub's select loop. Call in a goroutine. Exits when ctx is cancelled. // // go hub.Run(ctx) diff --git a/hub_config.go b/hub_config.go index b2a5be5..d6786ea 100644 --- a/hub_config.go +++ b/hub_config.go @@ -53,3 +53,17 @@ func DefaultHubConfig() HubConfig { WriteTimeout: 10 * time.Second, } } + +func normalizeHubConfig(config HubConfig) HubConfig { + defaults := DefaultHubConfig() + if config.HeartbeatInterval == 0 { + config.HeartbeatInterval = defaults.HeartbeatInterval + } + if config.PongTimeout == 0 { + config.PongTimeout = defaults.PongTimeout + } + if config.WriteTimeout == 0 { + config.WriteTimeout = defaults.WriteTimeout + } + return config +} From 3643665afb91ff151e640b0339e13cc8eade640c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:30:22 +0000 Subject: [PATCH 003/140] refactor(stream): align adapters with AX conventions Co-Authored-By: Virgil --- adapter/sse/sse.go | 15 +++++++-------- adapter/tcp/reconnect.go | 5 ++--- adapter/tcp/tcp.go | 10 +++------- adapter/ws/reconnect.go | 5 ++--- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 8f69811..bcea103 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -6,11 +6,11 @@ package sse import ( - "fmt" + "io" "net/http" + "strconv" "time" - "dappco.re/go/core" "dappco.re/go/stream" ) @@ -96,7 +96,7 @@ func (a *Adapter) serve(w http.ResponseWriter, r *http.Request, channels []strin _ = a.hub.SubscribePeer(peer, channel) } - _, _ = fmt.Fprintf(w, "retry: %d\n\n", a.config.RetryMs) + _, _ = io.WriteString(w, "retry: "+strconv.Itoa(a.config.RetryMs)+"\n\n") flusher.Flush() ticker := time.NewTicker(a.config.HeartbeatInterval) @@ -111,14 +111,13 @@ func (a *Adapter) serve(w http.ResponseWriter, r *http.Request, channels []strin if !ok { return } - _, _ = fmt.Fprintf(w, "data: %s\n\n", frame) + _, _ = io.WriteString(w, "data: ") + _, _ = w.Write(frame) + _, _ = io.WriteString(w, "\n\n") flusher.Flush() case <-ticker.C: - _, _ = fmt.Fprint(w, ": ping\n\n") + _, _ = io.WriteString(w, ": ping\n\n") flusher.Flush() } } } - -var _ time.Duration -var _ = core.E diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 9a6813c..5771c0c 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -5,7 +5,6 @@ package tcp import ( "context" "crypto/tls" - "errors" "net" "sync" "time" @@ -52,7 +51,7 @@ func NewReconnectingTCP(config ReconnectConfig) *ReconnectingTCP { // Connect starts the connection loop. Blocks until ctx is cancelled. func (rc *ReconnectingTCP) Connect(ctx context.Context) error { if rc == nil { - return errors.New("nil reconnecting tcp") + return core.E("stream.tcp", "nil reconnecting tcp", nil) } if ctx == nil { ctx = context.Background() @@ -112,7 +111,7 @@ func (rc *ReconnectingTCP) Connect(ctx context.Context) error { // Send transmits frame on channel through the TCP connection. func (rc *ReconnectingTCP) Send(channel string, frame []byte) error { if rc == nil { - return errors.New("nil reconnecting tcp") + return core.E("stream.tcp", "nil reconnecting tcp", nil) } rc.mu.RLock() conn := rc.conn diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 7b3fab1..1205297 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -9,7 +9,6 @@ import ( "context" "crypto/tls" "encoding/binary" - "errors" "io" "net" "sync" @@ -55,7 +54,7 @@ func (a *Adapter) Mount(hub *stream.Hub) { // Listen starts the TCP accept loop. Blocks until ctx cancelled. func (a *Adapter) Listen(ctx context.Context) error { if a == nil { - return errors.New("nil adapter") + return core.E("stream.tcp", "nil adapter", nil) } if a.hub == nil { return core.E("stream.tcp", "stream hub not mounted", nil) @@ -93,7 +92,7 @@ func (a *Adapter) Listen(ctx context.Context) error { // Dial connects to a remote TCP stream endpoint. Returns a Peer that can send/receive. func (a *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer, error) { if a == nil { - return nil, errors.New("nil adapter") + return nil, core.E("stream.tcp", "nil adapter", nil) } if hub == nil { hub = a.hub @@ -270,8 +269,5 @@ func isClosedNetworkError(err error) bool { if err == nil { return false } - if errors.Is(err, net.ErrClosed) { - return true - } - return false + return err == net.ErrClosed } diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index e2c3cb2..9da5b8a 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -4,7 +4,6 @@ package ws import ( "context" - "errors" "net/http" "sync" "time" @@ -57,7 +56,7 @@ func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { // Connect starts the connection loop. Blocks until ctx is cancelled. func (rc *ReconnectingClient) Connect(ctx context.Context) error { if rc == nil { - return errors.New("nil reconnecting client") + return core.E("stream.ws", "nil reconnecting client", nil) } if ctx == nil { ctx = context.Background() @@ -140,7 +139,7 @@ func (rc *ReconnectingClient) Connect(ctx context.Context) error { // Send marshals and sends a message through the WebSocket connection. func (rc *ReconnectingClient) Send(msg stream.Message) error { if rc == nil { - return errors.New("nil reconnecting client") + return core.E("stream.ws", "nil reconnecting client", nil) } if msg.Timestamp.IsZero() { msg.Timestamp = time.Now().UTC() From 5931bae86be3f01ac93b5a2dc0c5e60e4acfdfd3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:33:38 +0000 Subject: [PATCH 004/140] fix(stream): tighten hub and tcp lifecycle Co-Authored-By: Virgil --- adapter/redis/redis.go | 7 +++---- adapter/tcp/tcp.go | 11 ++++++++++- adapter/zmq/zmq.go | 7 +++---- hub.go | 20 ++++++++++++++++---- hub_config.go | 3 +++ 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index f10586b..efb5cb2 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -30,7 +30,7 @@ type Bridge struct { config Config sourceID string - mu sync.Mutex + mu sync.RWMutex running bool stopCh chan struct{} } @@ -69,7 +69,6 @@ func (b *Bridge) Start(ctx context.Context) error { b.mu.Lock() if b.running { b.mu.Unlock() - <-ctx.Done() return nil } b.running = true @@ -150,8 +149,8 @@ func (b *Bridge) registryKey() string { } func (b *Bridge) isRunning() bool { - b.mu.Lock() - defer b.mu.Unlock() + b.mu.RLock() + defer b.mu.RUnlock() return b.running } diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 1205297..abdfc17 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -67,7 +67,14 @@ func (a *Adapter) Listen(ctx context.Context) error { if err != nil { return err } - defer listener.Close() + defer func() { + _ = listener.Close() + a.mu.Lock() + if a.listener == listener { + a.listener = nil + } + a.mu.Unlock() + }() go func() { <-ctx.Done() @@ -228,6 +235,8 @@ func (a *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Pee func readFrame(conn net.Conn, timeout time.Duration) (string, []byte, error) { if timeout > 0 { _ = conn.SetReadDeadline(time.Now().Add(timeout)) + } else { + _ = conn.SetReadDeadline(time.Time{}) } var length uint32 if err := binary.Read(conn, binary.BigEndian, &length); err != nil { diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 0840acd..684983a 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -45,7 +45,7 @@ type Adapter struct { config Config source string - mu sync.Mutex + mu sync.RWMutex running bool stopCh chan struct{} } @@ -82,7 +82,6 @@ func (a *Adapter) Start(ctx context.Context) error { a.mu.Lock() if a.running { a.mu.Unlock() - <-ctx.Done() return nil } a.running = true @@ -154,8 +153,8 @@ func (a *Adapter) sourceID() string { } func (a *Adapter) isRunning() bool { - a.mu.Lock() - defer a.mu.Unlock() + a.mu.RLock() + defer a.mu.RUnlock() return a.running } diff --git a/hub.go b/hub.go index 9eecdcd..c9a7489 100644 --- a/hub.go +++ b/hub.go @@ -90,7 +90,6 @@ func (h *Hub) Run(ctx context.Context) { h.mu.Lock() if h.running { h.mu.Unlock() - <-ctx.Done() return } h.running = true @@ -133,17 +132,19 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { handlers := cloneHandlers(h.handlers[channel]) wildcardHandlers := cloneHandlers(h.handlers["*"]) publishers := clonePublishHandlers(h.publishers) + peersToSend := clonePeers(peers) + wildcardPeersToSend := clonePeers(wildcardPeers) h.mu.RUnlock() if !running { return ErrHubNotRunning } - if len(peers) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 && len(publishers) == 0 { + if len(peersToSend) == 0 && len(wildcardPeersToSend) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 && len(publishers) == 0 { return nil } - for peer := range peers { + for _, peer := range peersToSend { h.sendToPeer(peer, channel, frame) } - for peer := range wildcardPeers { + for _, peer := range wildcardPeersToSend { h.sendToPeer(peer, channel, frame) } h.invokeHandlers(handlers, frame) @@ -545,3 +546,14 @@ func clonePublishHandlers(handlers map[uint64]func(string, []byte)) []func(strin } return cloned } + +func clonePeers(peers map[*Peer]bool) []*Peer { + if len(peers) == 0 { + return nil + } + cloned := make([]*Peer, 0, len(peers)) + for peer := range peers { + cloned = append(cloned, peer) + } + return cloned +} diff --git a/hub_config.go b/hub_config.go index d6786ea..16436d6 100644 --- a/hub_config.go +++ b/hub_config.go @@ -62,6 +62,9 @@ func normalizeHubConfig(config HubConfig) HubConfig { if config.PongTimeout == 0 { config.PongTimeout = defaults.PongTimeout } + if config.PongTimeout <= config.HeartbeatInterval { + config.PongTimeout = config.HeartbeatInterval * 2 + } if config.WriteTimeout == 0 { config.WriteTimeout = defaults.WriteTimeout } From 77b6c511683c7343435fdde4e04651f16430216d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:38:44 +0000 Subject: [PATCH 005/140] fix(stream): close remaining RFC runtime gaps Co-Authored-By: Virgil --- adapter/redis/redis.go | 3 + adapter/redis/redis_test.go | 117 ++++++++++++++++++++++++++++++++++++ adapter/sse/sse_test.go | 116 +++++++++++++++++++++++++++++++++++ adapter/tcp/reconnect.go | 2 + adapter/tcp/tcp.go | 3 + adapter/ws/reconnect.go | 2 + adapter/zmq/zmq.go | 3 + hub.go | 32 ++++++---- hub_test.go | 62 +++++++++++++++++++ 9 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 adapter/redis/redis_test.go create mode 100644 adapter/sse/sse_test.go diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index efb5cb2..8ae62f3 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -66,6 +66,9 @@ func (b *Bridge) Start(ctx context.Context) error { if b == nil { return core.E("stream.redis", "nil bridge", nil) } + if ctx == nil { + ctx = context.Background() + } b.mu.Lock() if b.running { b.mu.Unlock() diff --git a/adapter/redis/redis_test.go b/adapter/redis/redis_test.go new file mode 100644 index 0000000..e83cec2 --- /dev/null +++ b/adapter/redis/redis_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package redis + +import ( + "context" + "testing" + "time" + + "dappco.re/go/stream" +) + +func TestBridge_Publish_Good(t *testing.T) { + hub1 := stream.NewHub() + hub2 := stream.NewHub() + + hub1Context, hub1Cancel := context.WithCancel(context.Background()) + defer hub1Cancel() + hub2Context, hub2Cancel := context.WithCancel(context.Background()) + defer hub2Cancel() + + go hub1.Run(hub1Context) + go hub2.Run(hub2Context) + + bridge1, err := NewBridge(hub1, Config{Addr: "redis:6379", Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge(hub1) error = %v", err) + } + bridge2, err := NewBridge(hub2, Config{Addr: "redis:6379", Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge(hub2) error = %v", err) + } + + bridgeContext, bridgeCancel := context.WithCancel(context.Background()) + defer bridgeCancel() + go func() { _ = bridge1.Start(bridgeContext) }() + go func() { _ = bridge2.Start(bridgeContext) }() + + received := make(chan []byte, 1) + unsubscribe := hub2.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + waitForBridgeRunning(t, bridge1) + waitForBridgeRunning(t, bridge2) + + if err := bridge1.PublishToChannel("block", []byte("template")); err != nil { + t.Fatalf("PublishToChannel() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for bridged frame") + } +} + +func TestBridge_Publish_Bad(t *testing.T) { + hub := stream.NewHub() + bridge, err := NewBridge(hub, Config{Addr: "redis:6379", Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge() error = %v", err) + } + + if err := bridge.PublishToChannel("block", []byte("template")); err == nil { + t.Fatal("PublishToChannel() error = nil, want bridge not started error") + } +} + +func TestBridge_Publish_Ugly(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + bridge, err := NewBridge(hub, Config{Addr: "redis:6379", Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge() error = %v", err) + } + + bridgeContext, bridgeCancel := context.WithCancel(context.Background()) + defer bridgeCancel() + go func() { _ = bridge.Start(bridgeContext) }() + waitForBridgeRunning(t, bridge) + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := bridge.PublishToChannel("block", []byte("template")); err != nil { + t.Fatalf("PublishToChannel() error = %v", err) + } + + select { + case frame := <-received: + t.Fatalf("received unexpected self-echo frame = %q", string(frame)) + case <-time.After(200 * time.Millisecond): + } +} + +func waitForBridgeRunning(t *testing.T, bridge *Bridge) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if bridge.isRunning() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for bridge to start") +} diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go new file mode 100644 index 0000000..3cb529c --- /dev/null +++ b/adapter/sse/sse_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package sse + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "dappco.re/go/stream" +) + +func TestAdapter_Handler_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{HeartbeatInterval: 20 * time.Millisecond}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=hashrate") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + waitForPeerCount(t, hub, 1) + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + reader := bufio.NewReader(response.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("ReadString() error = %v", err) + } + if strings.TrimSpace(line) == "data: 123456" { + return + } + } +} + +func TestAdapter_Handler_Bad(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Authenticator: stream.NewAPIKeyAuth(map[string]string{"valid-key": "user-1"}), + }) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=hashrate") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("StatusCode = %d, want %d", response.StatusCode, http.StatusUnauthorized) + } +} + +func TestAdapter_Handler_Ugly(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{HeartbeatInterval: 20 * time.Millisecond}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + requestContext, requestCancel := context.WithCancel(context.Background()) + request, err := http.NewRequestWithContext(requestContext, http.MethodGet, server.URL+"?channel=hashrate", nil) + if err != nil { + t.Fatalf("NewRequestWithContext() error = %v", err) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatalf("Do() error = %v", err) + } + + waitForPeerCount(t, hub, 1) + requestCancel() + _ = response.Body.Close() + + waitForPeerCount(t, hub, 0) +} + +func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.PeerCount() == expected { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), expected) +} diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 5771c0c..69ee110 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -78,6 +78,8 @@ func (rc *ReconnectingTCP) Connect(ctx context.Context) error { } rc.setConn(conn) + backoff = rc.config.InitialBackoff + attempt = 0 if rc.config.OnConnect != nil { rc.config.OnConnect() } diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index abdfc17..4d0842e 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -56,6 +56,9 @@ func (a *Adapter) Listen(ctx context.Context) error { if a == nil { return core.E("stream.tcp", "nil adapter", nil) } + if ctx == nil { + ctx = context.Background() + } if a.hub == nil { return core.E("stream.tcp", "stream hub not mounted", nil) } diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 9da5b8a..8f1c94d 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -98,6 +98,8 @@ func (rc *ReconnectingClient) Connect(ctx context.Context) error { rc.conn = conn rc.state = stream.StateConnected rc.mu.Unlock() + backoff = rc.config.InitialBackoff + attempt = 0 if rc.config.OnConnect != nil { rc.config.OnConnect() } diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 684983a..6ae981d 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -72,6 +72,9 @@ func (a *Adapter) Start(ctx context.Context) error { if a == nil { return core.E("stream.zmq", "nil adapter", nil) } + if ctx == nil { + ctx = context.Background() + } if a.config.Endpoint == "" { return core.E("stream.zmq", "empty endpoint", nil) } diff --git a/hub.go b/hub.go index c9a7489..f12377f 100644 --- a/hub.go +++ b/hub.go @@ -5,6 +5,7 @@ package stream import ( "context" "iter" + "sort" "sync" "dappco.re/go/core" @@ -124,29 +125,20 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { } h.mu.RLock() running := h.running - peers := h.channels[channel] - wildcardPeers := h.channels["*"] - if channel == "*" { - wildcardPeers = nil - } handlers := cloneHandlers(h.handlers[channel]) wildcardHandlers := cloneHandlers(h.handlers["*"]) publishers := clonePublishHandlers(h.publishers) - peersToSend := clonePeers(peers) - wildcardPeersToSend := clonePeers(wildcardPeers) + peersToSend := h.collectChannelPeersLocked(channel) h.mu.RUnlock() if !running { return ErrHubNotRunning } - if len(peersToSend) == 0 && len(wildcardPeersToSend) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 && len(publishers) == 0 { + if len(peersToSend) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 && len(publishers) == 0 { return nil } for _, peer := range peersToSend { h.sendToPeer(peer, channel, frame) } - for _, peer := range wildcardPeersToSend { - h.sendToPeer(peer, channel, frame) - } h.invokeHandlers(handlers, frame) h.invokeHandlers(wildcardHandlers, frame) h.invokePublishHandlers(publishers, channel, frame) @@ -389,6 +381,7 @@ func (h *Hub) AllChannels() iter.Seq[string] { channels = append(channels, channel) } h.mu.RUnlock() + sort.Strings(channels) return func(yield func(string) bool) { for _, channel := range channels { if !yield(channel) { @@ -525,6 +518,23 @@ func (h *Hub) invokePublishHandlers(handlers []func(string, []byte), channel str } } +func (h *Hub) collectChannelPeersLocked(channel string) []*Peer { + combined := map[*Peer]struct{}{} + for peer := range h.channels[channel] { + combined[peer] = struct{}{} + } + if channel != "*" { + for peer := range h.channels["*"] { + combined[peer] = struct{}{} + } + } + peers := make([]*Peer, 0, len(combined)) + for peer := range combined { + peers = append(peers, peer) + } + return peers +} + func cloneHandlers(handlers map[uint64]func([]byte)) []func([]byte) { if len(handlers) == 0 { return nil diff --git a/hub_test.go b/hub_test.go index 650f435..86cafb9 100644 --- a/hub_test.go +++ b/hub_test.go @@ -110,6 +110,68 @@ func TestHub_Pipe_Ugly(t *testing.T) { } } +func TestHub_Publish_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer(channel) error = %v", err) + } + if err := hub.SubscribePeer(peer, "*"); err != nil { + t.Fatalf("SubscribePeer(wildcard) error = %v", err) + } + + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for published frame") + } + + select { + case frame := <-peer.SendQueue(): + t.Fatalf("received duplicate frame = %q", string(frame)) + case <-time.After(200 * time.Millisecond): + } +} + +func TestHub_Publish_Bad(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v, want nil", err) + } +} + +func TestHub_Publish_Ugly(t *testing.T) { + hub := NewHub() + + if err := hub.Publish("hashrate", []byte("123456")); err != ErrHubNotRunning { + t.Fatalf("Publish() error = %v, want %v", err, ErrHubNotRunning) + } +} + func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) From a68c3e5bca309510484c3452782a3ce4b1843b08 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:44:46 +0000 Subject: [PATCH 006/140] feat(stream): implement hub broker loop Co-Authored-By: Virgil --- hub.go | 169 +++++++++++++++++++++++++++++++++++----------------- hub_test.go | 97 ++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 54 deletions(-) diff --git a/hub.go b/hub.go index f12377f..c084d7d 100644 --- a/hub.go +++ b/hub.go @@ -96,23 +96,36 @@ func (h *Hub) Run(ctx context.Context) { h.running = true h.mu.Unlock() - <-ctx.Done() + defer func() { + h.mu.Lock() + peers := make([]*Peer, 0, len(h.peers)) + for peer := range h.peers { + peers = append(peers, peer) + } + h.running = false + h.mu.Unlock() - h.mu.Lock() - peers := make([]*Peer, 0, len(h.peers)) - for peer := range h.peers { - peers = append(peers, peer) - } - h.running = false - h.mu.Unlock() + for _, peer := range peers { + h.removePeer(peer) + } - for _, peer := range peers { - h.RemovePeer(peer) + h.doneOnce.Do(func() { + close(h.done) + }) + }() + + for { + select { + case <-ctx.Done(): + return + case peer := <-h.register: + h.addPeer(peer) + case peer := <-h.unregister: + h.removePeer(peer) + case frame := <-h.broadcast: + h.broadcastToPeers(frame) + } } - - h.doneOnce.Do(func() { - close(h.done) - }) } // SendToChannel delivers frame to all peers subscribed to channel. @@ -250,19 +263,16 @@ func (h *Hub) Broadcast(frame []byte) error { } h.mu.RLock() running := h.running - peers := make([]*Peer, 0, len(h.peers)) - for peer := range h.peers { - peers = append(peers, peer) - } - handlers := cloneHandlers(h.handlers["*"]) h.mu.RUnlock() if !running { return ErrHubNotRunning } - for _, peer := range peers { - h.sendBroadcastToPeer(peer, frame) + select { + case h.broadcast <- append([]byte(nil), frame...): + return nil + default: + h.broadcastToPeers(frame) } - h.invokeHandlers(handlers, frame) return nil } @@ -407,20 +417,17 @@ func (h *Hub) AddPeer(peer *Peer) error { if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} } - h.mu.Lock() - if h.peers == nil { - h.peers = map[*Peer]bool{} - } - if h.peers[peer] { - h.mu.Unlock() - return nil - } - h.peers[peer] = true - onConnect := h.config.OnConnect - h.mu.Unlock() - if onConnect != nil { - onConnect(peer) + h.mu.RLock() + running := h.running + h.mu.RUnlock() + if running { + select { + case h.register <- peer: + return nil + default: + } } + h.addPeer(peer) return nil } @@ -431,27 +438,17 @@ func (h *Hub) RemovePeer(peer *Peer) { if h == nil || peer == nil { return } - h.mu.Lock() - if !h.peers[peer] { - h.mu.Unlock() - return - } - delete(h.peers, peer) - for channel, peers := range h.channels { - delete(peers, peer) - if len(peers) == 0 { - delete(h.channels, channel) + h.mu.RLock() + running := h.running + h.mu.RUnlock() + if running { + select { + case h.unregister <- peer: + return + default: } } - peer.mu.Lock() - peer.subscriptions = map[string]bool{} - peer.mu.Unlock() - onDisconnect := h.config.OnDisconnect - h.mu.Unlock() - peer.Close() - if onDisconnect != nil { - onDisconnect(peer) - } + h.removePeer(peer) } func (h *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { @@ -487,6 +484,70 @@ func (h *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { } } +func (h *Hub) addPeer(peer *Peer) { + if h == nil || peer == nil { + return + } + h.mu.Lock() + if h.peers == nil { + h.peers = map[*Peer]bool{} + } + if h.peers[peer] { + h.mu.Unlock() + return + } + h.peers[peer] = true + onConnect := h.config.OnConnect + h.mu.Unlock() + if onConnect != nil { + onConnect(peer) + } +} + +func (h *Hub) removePeer(peer *Peer) { + if h == nil || peer == nil { + return + } + h.mu.Lock() + if !h.peers[peer] { + h.mu.Unlock() + return + } + delete(h.peers, peer) + for channel, peers := range h.channels { + delete(peers, peer) + if len(peers) == 0 { + delete(h.channels, channel) + } + } + peer.mu.Lock() + peer.subscriptions = map[string]bool{} + peer.mu.Unlock() + onDisconnect := h.config.OnDisconnect + h.mu.Unlock() + peer.Close() + if onDisconnect != nil { + onDisconnect(peer) + } +} + +func (h *Hub) broadcastToPeers(frame []byte) { + if h == nil { + return + } + h.mu.RLock() + peers := make([]*Peer, 0, len(h.peers)) + for peer := range h.peers { + peers = append(peers, peer) + } + handlers := cloneHandlers(h.handlers["*"]) + h.mu.RUnlock() + for _, peer := range peers { + h.sendBroadcastToPeer(peer, frame) + } + h.invokeHandlers(handlers, frame) +} + func (h *Hub) subscribePublished(handler func(string, []byte)) func() { if h == nil || handler == nil { return func() {} diff --git a/hub_test.go b/hub_test.go index 86cafb9..012b767 100644 --- a/hub_test.go +++ b/hub_test.go @@ -123,6 +123,7 @@ func TestHub_Publish_Good(t *testing.T) { t.Fatalf("AddPeer() error = %v", err) } defer hub.RemovePeer(peer) + waitForPeerCount(t, hub, 1) if err := hub.SubscribePeer(peer, "hashrate"); err != nil { t.Fatalf("SubscribePeer(channel) error = %v", err) @@ -172,6 +173,90 @@ func TestHub_Publish_Ugly(t *testing.T) { } } +func TestHub_Broadcast_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + waitForPeerCount(t, hub, 1) + + if err := hub.Broadcast([]byte("123456")); err != nil { + t.Fatalf("Broadcast() error = %v", err) + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for broadcast frame") + } +} + +func TestHub_Broadcast_Bad(t *testing.T) { + hub := NewHub() + + if err := hub.Broadcast([]byte("123456")); err != ErrHubNotRunning { + t.Fatalf("Broadcast() error = %v, want %v", err, ErrHubNotRunning) + } +} + +func TestHub_Broadcast_Ugly(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + waitForPeerCount(t, hub, 1) + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("*", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := hub.Broadcast([]byte("event")); err != nil { + t.Fatalf("Broadcast() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "event" { + t.Fatalf("received handler frame = %q, want %q", string(frame), "event") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for broadcast handler") + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "event" { + t.Fatalf("received peer frame = %q, want %q", string(frame), "event") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for broadcast peer") + } + + hubCancel() + waitForPeerCount(t, hub, 0) +} + func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) @@ -186,3 +271,15 @@ func waitForRunningHub(t *testing.T, hub *Hub) { } t.Fatal("timed out waiting for hub to start") } + +func waitForPeerCount(t *testing.T, hub *Hub, expected int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.PeerCount() == expected { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), expected) +} From 3f55d1fd521bfd9f551295d472f73a461d423a58 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:48:10 +0000 Subject: [PATCH 007/140] fix(stream): tighten hub and tcp edge cases Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 2 +- adapter/tcp/tcp.go | 12 +++++++----- adapter/tcp/tcp_test.go | 32 ++++++++++++++++++++++++++++++++ hub.go | 3 +++ hub_test.go | 31 +++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 69ee110..9ce1b57 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -165,7 +165,7 @@ func (rc *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) error { return ctx.Err() default: } - channel, frame, err := readFrame(conn, 0) + channel, frame, err := readFrame(conn, 0, MaxFrameSize) if err != nil { return err } diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 4d0842e..defbc2c 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -21,6 +21,8 @@ import ( // MaxFrameSize is the maximum allowed frame size in bytes. const MaxFrameSize = 65535 +const maxHandshakeFrameSize = 4 << 10 + // Config configures the TCP adapter. type Config struct { Addr string @@ -164,7 +166,7 @@ func (a *Adapter) dial(ctx context.Context) (net.Conn, error) { func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { defer conn.Close() - _, handshake, err := readFrame(conn, a.config.HandshakeTimeout) + _, handshake, err := readFrame(conn, a.config.HandshakeTimeout, maxHandshakeFrameSize) if err != nil { return } @@ -187,7 +189,7 @@ func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub go a.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { - channel, frame, err := readFrame(conn, 0) + channel, frame, err := readFrame(conn, 0, MaxFrameSize) if err != nil { return } @@ -203,7 +205,7 @@ func (a *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer defer conn.Close() go a.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { - channel, frame, err := readFrame(conn, 0) + channel, frame, err := readFrame(conn, 0, MaxFrameSize) if err != nil { hub.RemovePeer(peer) return @@ -235,7 +237,7 @@ func (a *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Pee } } -func readFrame(conn net.Conn, timeout time.Duration) (string, []byte, error) { +func readFrame(conn net.Conn, timeout time.Duration, maxFrameSize int) (string, []byte, error) { if timeout > 0 { _ = conn.SetReadDeadline(time.Now().Add(timeout)) } else { @@ -248,7 +250,7 @@ func readFrame(conn net.Conn, timeout time.Duration) (string, []byte, error) { } return "", nil, err } - if length > MaxFrameSize { + if maxFrameSize > 0 && length > uint32(maxFrameSize) { return "", nil, core.E("stream.tcp", "frame too large", nil) } payload := make([]byte, length) diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index a27c7ff..1d03cd8 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -125,6 +125,38 @@ func TestTCP_Listen_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } +func TestTCP_Listen_HandshakeTooLarge_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + tooLargeHandshake := make([]byte, maxHandshakeFrameSize+1) + if _, err := connection.Write(encodeFrame("", tooLargeHandshake)); err != nil { + t.Fatalf("Write() error = %v", err) + } + + waitForPeerCount(t, hub, 0) +} + func waitForListenerAddress(t *testing.T, adapter *Adapter) string { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/hub.go b/hub.go index c084d7d..b2365d3 100644 --- a/hub.go +++ b/hub.go @@ -140,6 +140,9 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { running := h.running handlers := cloneHandlers(h.handlers[channel]) wildcardHandlers := cloneHandlers(h.handlers["*"]) + if channel == "*" { + wildcardHandlers = nil + } publishers := clonePublishHandlers(h.publishers) peersToSend := h.collectChannelPeersLocked(channel) h.mu.RUnlock() diff --git a/hub_test.go b/hub_test.go index 012b767..6ab7f42 100644 --- a/hub_test.go +++ b/hub_test.go @@ -257,6 +257,37 @@ func TestHub_Broadcast_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } +func TestHub_SendToChannel_Wildcard_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + count := 0 + unsubscribe := hub.Subscribe("*", func(frame []byte) { + if string(frame) == "event" { + count++ + } + }) + defer unsubscribe() + + if err := hub.Publish("*", []byte("event")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if count == 1 { + return + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("wildcard handler count = %d, want 1", count) +} + func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) From 55055292aeb1ffd798c08fd4cb634bceb6753553 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:55:35 +0000 Subject: [PATCH 008/140] feat(stream): implement redis and zmq transports Co-Authored-By: Virgil --- adapter/redis/redis.go | 209 ++++++++++++++++------------- adapter/redis/redis_test.go | 81 +++++++++--- adapter/zmq/zmq.go | 257 ++++++++++++++++++++++-------------- adapter/zmq/zmq_test.go | 173 ++++++++++++++++++++++++ go.mod | 17 ++- go.sum | 20 +++ 6 files changed, 545 insertions(+), 212 deletions(-) create mode 100644 adapter/zmq/zmq_test.go diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 8ae62f3..4413ce6 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -8,8 +8,10 @@ package redis import ( "context" "crypto/tls" - "strconv" "sync" + "time" + + "github.com/redis/go-redis/v9" "dappco.re/go/core" "dappco.re/go/stream" @@ -30,18 +32,17 @@ type Bridge struct { config Config sourceID string - mu sync.RWMutex - running bool - stopCh chan struct{} + mu sync.RWMutex + cancel context.CancelFunc + pubsub *redis.PubSub + client *redis.Client } -type bridgeRegistry struct { - mu sync.RWMutex - bridges map[string]map[*Bridge]struct{} +type envelope struct { + SourceID string `json:"s"` + Frame []byte `json:"f"` } -var registry = bridgeRegistry{bridges: map[string]map[*Bridge]struct{}{}} - // NewBridge creates and validates the Redis connection. Does not start listening. func NewBridge(hub *stream.Hub, cfg Config) (*Bridge, error) { if hub == nil { @@ -53,11 +54,19 @@ func NewBridge(hub *stream.Hub, cfg Config) (*Bridge, error) { if cfg.Prefix == "" { cfg.Prefix = "stream" } + client := newRedisClient(cfg) + defer client.Close() + + pingContext, pingCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pingCancel() + if err := client.Ping(pingContext).Err(); err != nil { + return nil, core.E("stream.redis", "redis ping failed", err) + } + return &Bridge{ hub: hub, config: cfg, sourceID: stream.NewPeer("redis").ID, - stopCh: make(chan struct{}), }, nil } @@ -69,28 +78,52 @@ func (b *Bridge) Start(ctx context.Context) error { if ctx == nil { ctx = context.Background() } + + runContext, runCancel := context.WithCancel(ctx) + client := newRedisClient(b.config) + pubsub := client.PSubscribe(runContext, b.broadcastChannel(), b.channelPattern()) + b.mu.Lock() - if b.running { - b.mu.Unlock() - return nil - } - b.running = true - stopCh := b.stopCh - key := b.registryKey() + b.cancel = runCancel + b.client = client + b.pubsub = pubsub b.mu.Unlock() - registry.add(key, b) - defer registry.remove(key, b) + defer func() { + b.mu.Lock() + b.cancel = nil + b.client = nil + b.pubsub = nil + b.mu.Unlock() + runCancel() + _ = pubsub.Close() + _ = client.Close() + }() + + for { + message, err := pubsub.ReceiveMessage(runContext) + if err != nil { + if runContext.Err() != nil { + return nil + } + return err + } - select { - case <-ctx.Done(): - case <-stopCh: - } + var decoded envelope + if !core.JSONUnmarshal([]byte(message.Payload), &decoded).OK { + continue + } + if decoded.SourceID == b.sourceID { + continue + } - b.mu.Lock() - b.running = false - b.mu.Unlock() - return nil + channel := b.channelFromRedis(message.Channel) + if channel == "" { + _ = b.hub.Broadcast(decoded.Frame) + continue + } + _ = b.hub.Publish(channel, decoded.Frame) + } } // Stop cleanly shuts down the bridge. Closes the pub/sub subscription and Redis client. @@ -98,14 +131,22 @@ func (b *Bridge) Stop() error { if b == nil { return nil } - b.mu.Lock() - if !b.running { - b.mu.Unlock() - return nil + + b.mu.RLock() + cancel := b.cancel + pubsub := b.pubsub + client := b.client + b.mu.RUnlock() + + if cancel != nil { + cancel() + } + if pubsub != nil { + _ = pubsub.Close() + } + if client != nil { + return client.Close() } - close(b.stopCh) - b.stopCh = make(chan struct{}) - b.mu.Unlock() return nil } @@ -114,14 +155,11 @@ func (b *Bridge) PublishToChannel(channel string, frame []byte) error { if b == nil { return core.E("stream.redis", "nil bridge", nil) } - if !b.isRunning() { - return core.E("stream.redis", "bridge not started", nil) + if channel == "" { + return core.E("stream.redis", "empty channel", nil) } - registry.publish(b.registryKey(), channel, envelope{ - SourceID: b.sourceID, - Frame: append([]byte(nil), frame...), - }) - return nil + + return b.publish(b.channelKey(channel), frame) } // PublishBroadcast publishes frame as a broadcast via Redis. @@ -129,14 +167,8 @@ func (b *Bridge) PublishBroadcast(frame []byte) error { if b == nil { return core.E("stream.redis", "nil bridge", nil) } - if !b.isRunning() { - return core.E("stream.redis", "bridge not started", nil) - } - registry.publish(b.registryKey(), "", envelope{ - SourceID: b.sourceID, - Frame: append([]byte(nil), frame...), - }) - return nil + + return b.publish(b.broadcastChannel(), frame) } // SourceID returns the random instance identifier. @@ -147,58 +179,51 @@ func (b *Bridge) SourceID() string { return b.sourceID } -func (b *Bridge) registryKey() string { - return b.config.Addr + "|" + strconv.Itoa(b.config.DB) + "|" + b.config.Prefix -} +func (b *Bridge) publish(channel string, frame []byte) error { + client := newRedisClient(b.config) + defer client.Close() -func (b *Bridge) isRunning() bool { - b.mu.RLock() - defer b.mu.RUnlock() - return b.running + payload := envelope{ + SourceID: b.sourceID, + Frame: append([]byte(nil), frame...), + } + encoded := core.JSONMarshal(payload) + if !encoded.OK { + if err, ok := encoded.Value.(error); ok { + return err + } + return core.E("stream.redis", "failed to marshal envelope", nil) + } + + publishContext, publishCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer publishCancel() + return client.Publish(publishContext, channel, encoded.Value).Err() } -type envelope struct { - SourceID string `json:"s"` - Frame []byte `json:"f"` +func (b *Bridge) broadcastChannel() string { + return b.config.Prefix + ":broadcast" } -func (r *bridgeRegistry) add(key string, bridge *Bridge) { - r.mu.Lock() - defer r.mu.Unlock() - if r.bridges[key] == nil { - r.bridges[key] = map[*Bridge]struct{}{} - } - r.bridges[key][bridge] = struct{}{} +func (b *Bridge) channelKey(channel string) string { + return b.config.Prefix + ":channel:" + channel } -func (r *bridgeRegistry) remove(key string, bridge *Bridge) { - r.mu.Lock() - defer r.mu.Unlock() - if bridges := r.bridges[key]; bridges != nil { - delete(bridges, bridge) - if len(bridges) == 0 { - delete(r.bridges, key) - } - } +func (b *Bridge) channelPattern() string { + return b.config.Prefix + ":channel:*" } -func (r *bridgeRegistry) publish(key, channel string, message envelope) { - r.mu.RLock() - bridges := r.bridges[key] - targets := make([]*Bridge, 0, len(bridges)) - for bridge := range bridges { - targets = append(targets, bridge) +func (b *Bridge) channelFromRedis(channel string) string { + if channel == b.broadcastChannel() { + return "" } - r.mu.RUnlock() + return core.TrimPrefix(channel, b.config.Prefix+":channel:") +} - for _, bridge := range targets { - if bridge == nil || bridge.sourceID == message.SourceID { - continue - } - if channel == "" { - _ = bridge.hub.Broadcast(message.Frame) - continue - } - _ = bridge.hub.Publish(channel, message.Frame) - } +func newRedisClient(cfg Config) *redis.Client { + return redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, + TLSConfig: cfg.TLSConfig, + }) } diff --git a/adapter/redis/redis_test.go b/adapter/redis/redis_test.go index e83cec2..ff58d50 100644 --- a/adapter/redis/redis_test.go +++ b/adapter/redis/redis_test.go @@ -7,10 +7,14 @@ import ( "testing" "time" + "github.com/alicebob/miniredis/v2" + "dappco.re/go/stream" ) func TestBridge_Publish_Good(t *testing.T) { + redisServer := miniredis.RunT(t) + hub1 := stream.NewHub() hub2 := stream.NewHub() @@ -22,11 +26,11 @@ func TestBridge_Publish_Good(t *testing.T) { go hub1.Run(hub1Context) go hub2.Run(hub2Context) - bridge1, err := NewBridge(hub1, Config{Addr: "redis:6379", Prefix: "pool"}) + bridge1, err := NewBridge(hub1, Config{Addr: redisServer.Addr(), Prefix: "pool"}) if err != nil { t.Fatalf("NewBridge(hub1) error = %v", err) } - bridge2, err := NewBridge(hub2, Config{Addr: "redis:6379", Prefix: "pool"}) + bridge2, err := NewBridge(hub2, Config{Addr: redisServer.Addr(), Prefix: "pool"}) if err != nil { t.Fatalf("NewBridge(hub2) error = %v", err) } @@ -35,6 +39,7 @@ func TestBridge_Publish_Good(t *testing.T) { defer bridgeCancel() go func() { _ = bridge1.Start(bridgeContext) }() go func() { _ = bridge2.Start(bridgeContext) }() + time.Sleep(100 * time.Millisecond) received := make(chan []byte, 1) unsubscribe := hub2.Subscribe("block", func(frame []byte) { @@ -42,9 +47,6 @@ func TestBridge_Publish_Good(t *testing.T) { }) defer unsubscribe() - waitForBridgeRunning(t, bridge1) - waitForBridgeRunning(t, bridge2) - if err := bridge1.PublishToChannel("block", []byte("template")); err != nil { t.Fatalf("PublishToChannel() error = %v", err) } @@ -61,23 +63,21 @@ func TestBridge_Publish_Good(t *testing.T) { func TestBridge_Publish_Bad(t *testing.T) { hub := stream.NewHub() - bridge, err := NewBridge(hub, Config{Addr: "redis:6379", Prefix: "pool"}) - if err != nil { - t.Fatalf("NewBridge() error = %v", err) - } - - if err := bridge.PublishToChannel("block", []byte("template")); err == nil { - t.Fatal("PublishToChannel() error = nil, want bridge not started error") + _, err := NewBridge(hub, Config{}) + if err == nil { + t.Fatal("NewBridge() error = nil, want empty address error") } } func TestBridge_Publish_Ugly(t *testing.T) { + redisServer := miniredis.RunT(t) + hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() go hub.Run(hubContext) - bridge, err := NewBridge(hub, Config{Addr: "redis:6379", Prefix: "pool"}) + bridge, err := NewBridge(hub, Config{Addr: redisServer.Addr(), Prefix: "pool"}) if err != nil { t.Fatalf("NewBridge() error = %v", err) } @@ -85,7 +85,7 @@ func TestBridge_Publish_Ugly(t *testing.T) { bridgeContext, bridgeCancel := context.WithCancel(context.Background()) defer bridgeCancel() go func() { _ = bridge.Start(bridgeContext) }() - waitForBridgeRunning(t, bridge) + time.Sleep(100 * time.Millisecond) received := make(chan []byte, 1) unsubscribe := hub.Subscribe("block", func(frame []byte) { @@ -104,14 +104,51 @@ func TestBridge_Publish_Ugly(t *testing.T) { } } -func waitForBridgeRunning(t *testing.T, bridge *Bridge) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if bridge.isRunning() { - return +func TestBridge_PublishBroadcast_Good(t *testing.T) { + redisServer := miniredis.RunT(t) + + hub1 := stream.NewHub() + hub2 := stream.NewHub() + + hub1Context, hub1Cancel := context.WithCancel(context.Background()) + defer hub1Cancel() + hub2Context, hub2Cancel := context.WithCancel(context.Background()) + defer hub2Cancel() + + go hub1.Run(hub1Context) + go hub2.Run(hub2Context) + + bridge1, err := NewBridge(hub1, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge(hub1) error = %v", err) + } + bridge2, err := NewBridge(hub2, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge(hub2) error = %v", err) + } + + bridgeContext, bridgeCancel := context.WithCancel(context.Background()) + defer bridgeCancel() + go func() { _ = bridge1.Start(bridgeContext) }() + go func() { _ = bridge2.Start(bridgeContext) }() + time.Sleep(100 * time.Millisecond) + + peer := stream.NewPeer("ws") + if err := hub2.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub2.RemovePeer(peer) + + if err := bridge1.PublishBroadcast([]byte("shutdown")); err != nil { + t.Fatalf("PublishBroadcast() error = %v", err) + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "shutdown" { + t.Fatalf("received frame = %q, want %q", string(frame), "shutdown") } - time.Sleep(10 * time.Millisecond) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for bridged broadcast") } - t.Fatal("timed out waiting for bridge to start") } diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 6ae981d..b7a3d63 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -6,9 +6,12 @@ package zmq import ( "context" - "strconv" + "net" + "net/url" "sync" + "github.com/go-zeromq/zmq4" + "dappco.re/go/core" "dappco.re/go/stream" ) @@ -43,23 +46,16 @@ type Config struct { type Adapter struct { hub *stream.Hub config Config - source string mu sync.RWMutex running bool - stopCh chan struct{} -} - -type zmqRegistry struct { - mu sync.RWMutex - adapters map[string]map[*Adapter]struct{} + socket zmq4.Socket + cancel context.CancelFunc } -var registry = zmqRegistry{adapters: map[string]map[*Adapter]struct{}{}} - // New creates a ZMQ adapter. Call Mount and Start before use. func New(config Config) *Adapter { - return &Adapter{config: config, source: stream.NewPeer("zmq").ID, stopCh: make(chan struct{})} + return &Adapter{config: config} } // Mount wires the adapter to a hub. @@ -81,29 +77,68 @@ func (a *Adapter) Start(ctx context.Context) error { if a.hub == nil { return core.E("stream.zmq", "stream hub not mounted", nil) } + if err := a.validateRole(); err != nil { + return err + } + + runContext, runCancel := context.WithCancel(ctx) + socket, err := a.newSocket(runContext) + if err != nil { + runCancel() + return err + } + if err := a.connectSocket(socket); err != nil { + _ = socket.Close() + runCancel() + return err + } a.mu.Lock() if a.running { a.mu.Unlock() + _ = socket.Close() + runCancel() return nil } a.running = true - stopCh := a.stopCh - key := a.registryKey() + a.socket = socket + a.cancel = runCancel a.mu.Unlock() - registry.add(key, a) - defer registry.remove(key, a) + defer func() { + a.mu.Lock() + a.running = false + a.socket = nil + a.cancel = nil + a.mu.Unlock() + runCancel() + _ = socket.Close() + }() - select { - case <-ctx.Done(): - case <-stopCh: + if !a.isReceiver() { + <-runContext.Done() + return nil } - a.mu.Lock() - a.running = false - a.mu.Unlock() - return nil + for { + message, err := socket.Recv() + if err != nil { + if runContext.Err() != nil { + return nil + } + return err + } + + channel, frame, ok := decodeMessage(message) + if !ok { + continue + } + if channel == "" { + _ = a.hub.Broadcast(frame) + continue + } + _ = a.hub.Publish(channel, frame) + } } // Publish sends frame with topic (channel name) via the ZMQ socket. @@ -111,18 +146,19 @@ func (a *Adapter) Publish(channel string, frame []byte) error { if a == nil { return core.E("stream.zmq", "nil adapter", nil) } - if a.config.Role != RolePublisher && a.config.Role != RolePusher { + if !a.isSender() { return core.E("stream.zmq", "publish not supported for this role", nil) } - if !a.isRunning() { + + a.mu.RLock() + socket := a.socket + running := a.running + a.mu.RUnlock() + if !running || socket == nil { return core.E("stream.zmq", "adapter not started", nil) } - registry.publish(a.registryKey(), message{ - SourceID: a.sourceID(), - Channel: channel, - Frame: append([]byte(nil), frame...), - }) - return nil + + return socket.Send(zmq4.NewMsg(encodeMessage(channel, frame))) } // Stop shuts down the adapter. @@ -130,92 +166,119 @@ func (a *Adapter) Stop() error { if a == nil { return nil } - a.mu.Lock() - if !a.running { - a.mu.Unlock() - return nil + + a.mu.RLock() + cancel := a.cancel + socket := a.socket + a.mu.RUnlock() + + if cancel != nil { + cancel() + } + if socket != nil { + return socket.Close() + } + return nil +} + +func (a *Adapter) validateRole() error { + switch a.config.Mode { + case ModePubSub: + if a.config.Role != RolePublisher && a.config.Role != RoleSubscriber { + return core.E("stream.zmq", "invalid pubsub role", nil) + } + case ModePushPull: + if a.config.Role != RolePusher && a.config.Role != RolePuller { + return core.E("stream.zmq", "invalid pushpull role", nil) + } + default: + return core.E("stream.zmq", "invalid mode", nil) } - close(a.stopCh) - a.stopCh = make(chan struct{}) - a.mu.Unlock() return nil } -type message struct { - SourceID string - Channel string - Frame []byte +func (a *Adapter) newSocket(ctx context.Context) (zmq4.Socket, error) { + switch a.config.Role { + case RolePublisher: + return zmq4.NewPub(ctx), nil + case RoleSubscriber: + socket := zmq4.NewSub(ctx) + topics := a.config.Topics + if len(topics) == 0 { + topics = []string{""} + } + for _, topic := range topics { + if err := socket.SetOption(zmq4.OptionSubscribe, topic); err != nil { + return nil, err + } + } + return socket, nil + case RolePusher: + return zmq4.NewPush(ctx), nil + case RolePuller: + return zmq4.NewPull(ctx), nil + default: + return nil, core.E("stream.zmq", "invalid role", nil) + } } -func (a *Adapter) registryKey() string { - return a.config.Endpoint + "|" + strconv.Itoa(int(a.config.Mode)) +func (a *Adapter) connectSocket(socket zmq4.Socket) error { + if a.shouldListen() { + return socket.Listen(listenEndpoint(a.config.Endpoint)) + } + return socket.Dial(a.config.Endpoint) } -func (a *Adapter) sourceID() string { - return a.source +func (a *Adapter) shouldListen() bool { + if a.config.Mode == ModePushPull { + return a.config.Role == RolePusher + } + return a.config.Role == RolePublisher } -func (a *Adapter) isRunning() bool { - a.mu.RLock() - defer a.mu.RUnlock() - return a.running +func (a *Adapter) isSender() bool { + return a.config.Role == RolePublisher || a.config.Role == RolePusher } -func (r *zmqRegistry) add(key string, adapter *Adapter) { - r.mu.Lock() - defer r.mu.Unlock() - if r.adapters[key] == nil { - r.adapters[key] = map[*Adapter]struct{}{} - } - r.adapters[key][adapter] = struct{}{} +func (a *Adapter) isReceiver() bool { + return a.config.Role == RoleSubscriber || a.config.Role == RolePuller } -func (r *zmqRegistry) remove(key string, adapter *Adapter) { - r.mu.Lock() - defer r.mu.Unlock() - if adapters := r.adapters[key]; adapters != nil { - delete(adapters, adapter) - if len(adapters) == 0 { - delete(r.adapters, key) +func decodeMessage(message zmq4.Msg) (string, []byte, bool) { + payload := message.Bytes() + for index, value := range payload { + if value != 0 { + continue } + channel := string(payload[:index]) + frame := append([]byte(nil), payload[index+1:]...) + return channel, frame, true } + return "", nil, false } -func (r *zmqRegistry) publish(key string, message message) { - r.mu.RLock() - adapters := r.adapters[key] - targets := make([]*Adapter, 0, len(adapters)) - for adapter := range adapters { - targets = append(targets, adapter) +func encodeMessage(channel string, frame []byte) []byte { + output := make([]byte, 0, len(channel)+1+len(frame)) + output = append(output, []byte(channel)...) + output = append(output, 0) + output = append(output, frame...) + return output +} + +func listenEndpoint(endpoint string) string { + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme != "tcp" { + return endpoint } - r.mu.RUnlock() - for _, adapter := range targets { - if adapter == nil || adapter.sourceID() == message.SourceID { - continue - } - if adapter.config.Role != RoleSubscriber && adapter.config.Role != RolePuller { - continue - } - if len(adapter.config.Topics) > 0 && message.Channel != "" { - allowed := false - for _, topic := range adapter.config.Topics { - if topic == message.Channel { - allowed = true - break - } - } - if !allowed { - continue - } - } - if adapter.hub == nil { - continue - } - if message.Channel == "" { - _ = adapter.hub.Broadcast(message.Frame) - continue - } - _ = adapter.hub.Publish(message.Channel, message.Frame) + host, port, err := net.SplitHostPort(parsed.Host) + if err != nil { + return endpoint + } + if host == "" || host == "*" { + return endpoint } + + parsed.Host = net.JoinHostPort("*", port) + return parsed.String() } diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go new file mode 100644 index 0000000..b598248 --- /dev/null +++ b/adapter/zmq/zmq_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package zmq + +import ( + "context" + "net" + "strconv" + "testing" + "time" + + "dappco.re/go/stream" +) + +func TestAdapter_Publish_Good(t *testing.T) { + publisherHub := stream.NewHub() + subscriberHub := stream.NewHub() + + publisherContext, publisherCancel := context.WithCancel(context.Background()) + defer publisherCancel() + subscriberContext, subscriberCancel := context.WithCancel(context.Background()) + defer subscriberCancel() + + go publisherHub.Run(publisherContext) + go subscriberHub.Run(subscriberContext) + + endpoint := randomTCPEndpoint(t) + publisher := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RolePublisher, + }) + publisher.Mount(publisherHub) + + subscriber := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RoleSubscriber, + Topics: []string{"block"}, + }) + subscriber.Mount(subscriberHub) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + go func() { _ = publisher.Start(runContext) }() + go func() { _ = subscriber.Start(runContext) }() + waitForAdapterRunning(t, publisher) + waitForAdapterRunning(t, subscriber) + + received := make(chan []byte, 1) + unsubscribe := subscriberHub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := publisher.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + return + case <-time.After(100 * time.Millisecond): + } + } + t.Fatal("timed out waiting for zmq frame") +} + +func TestAdapter_Publish_Bad(t *testing.T) { + hub := stream.NewHub() + adapter := New(Config{ + Mode: ModePubSub, + Endpoint: randomTCPEndpoint(t), + Role: RoleSubscriber, + }) + adapter.Mount(hub) + + if err := adapter.Publish("block", []byte("template")); err == nil { + t.Fatal("Publish() error = nil, want publish not supported error") + } +} + +func TestAdapter_Start_Ugly(t *testing.T) { + pusherHub := stream.NewHub() + pullerHub := stream.NewHub() + + pusherContext, pusherCancel := context.WithCancel(context.Background()) + defer pusherCancel() + pullerContext, pullerCancel := context.WithCancel(context.Background()) + defer pullerCancel() + + go pusherHub.Run(pusherContext) + go pullerHub.Run(pullerContext) + + endpoint := randomTCPEndpoint(t) + puller := New(Config{ + Mode: ModePushPull, + Endpoint: endpoint, + Role: RolePuller, + }) + puller.Mount(pullerHub) + + pusher := New(Config{ + Mode: ModePushPull, + Endpoint: endpoint, + Role: RolePusher, + }) + pusher.Mount(pusherHub) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + go func() { _ = puller.Start(runContext) }() + go func() { _ = pusher.Start(runContext) }() + waitForAdapterRunning(t, puller) + waitForAdapterRunning(t, pusher) + + received := make(chan []byte, 1) + unsubscribe := pullerHub.Subscribe("job", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := pusher.Publish("job", []byte("work")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + select { + case frame := <-received: + if string(frame) != "work" { + t.Fatalf("received frame = %q, want %q", string(frame), "work") + } + return + case <-time.After(100 * time.Millisecond): + } + } + t.Fatal("timed out waiting for push/pull frame") +} + +func randomTCPEndpoint(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + defer listener.Close() + + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("Addr() type = %T, want *net.TCPAddr", listener.Addr()) + } + return "tcp://127.0.0.1:" + strconv.Itoa(address.Port) +} + +func waitForAdapterRunning(t *testing.T, adapter *Adapter) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + adapter.mu.RLock() + running := adapter.running + adapter.mu.RUnlock() + if running { + time.Sleep(100 * time.Millisecond) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for adapter to start") +} diff --git a/go.mod b/go.mod index 9c05138..339fff1 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,19 @@ go 1.26.0 require dappco.re/go/core v0.8.0-alpha.1 -require github.com/gorilla/websocket v1.5.3 +require ( + github.com/alicebob/miniredis/v2 v2.37.0 + github.com/go-zeromq/zmq4 v0.17.0 + github.com/gorilla/websocket v1.5.3 + github.com/redis/go-redis/v9 v9.18.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-zeromq/goczmq/v4 v4.2.2 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/text v0.15.0 // indirect +) diff --git a/go.sum b/go.sum index 41034bd..9ffe7d8 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,32 @@ dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-zeromq/goczmq/v4 v4.2.2 h1:HAJN+i+3NW55ijMJJhk7oWxHKXgAuSBkoFfvr8bYj4U= +github.com/go-zeromq/goczmq/v4 v4.2.2/go.mod h1:Sm/lxrfxP/Oxqs0tnHD6WAhwkWrx+S+1MRrKzcxoaYE= +github.com/go-zeromq/zmq4 v0.17.0 h1:r12/XdqPeRbuaF4C3QZJeWCt7a5vpJbslDH1rTXF+Kc= +github.com/go-zeromq/zmq4 v0.17.0/go.mod h1:EQxjJD92qKnrsVMzAnx62giD6uJIPi1dMGZ781iCDtY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 14a6a6eaea1b9148b5c33d1918b91e99d3d43a81 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:02:54 +0000 Subject: [PATCH 009/140] fix(hub): dispatch callbacks through hub queue Co-Authored-By: Virgil --- hub.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/hub.go b/hub.go index b2365d3..c2a4304 100644 --- a/hub.go +++ b/hub.go @@ -23,6 +23,7 @@ import ( type Hub struct { peers map[*Peer]bool broadcast chan []byte + deliver chan delivery register chan *Peer unregister chan *Peer channels map[string]map[*Peer]bool @@ -55,6 +56,7 @@ func NewHubWithConfig(config HubConfig) *Hub { return &Hub{ peers: map[*Peer]bool{}, broadcast: make(chan []byte, 256), + deliver: make(chan delivery, 256), register: make(chan *Peer, 256), unregister: make(chan *Peer, 256), channels: map[string]map[*Peer]bool{}, @@ -124,6 +126,8 @@ func (h *Hub) Run(ctx context.Context) { h.removePeer(peer) case frame := <-h.broadcast: h.broadcastToPeers(frame) + case item := <-h.deliver: + h.processDelivery(item.channel, item.frame) } } } @@ -138,26 +142,21 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { } h.mu.RLock() running := h.running - handlers := cloneHandlers(h.handlers[channel]) - wildcardHandlers := cloneHandlers(h.handlers["*"]) - if channel == "*" { - wildcardHandlers = nil - } - publishers := clonePublishHandlers(h.publishers) peersToSend := h.collectChannelPeersLocked(channel) + hasHandlers := len(h.handlers[channel]) > 0 + hasWildcardHandlers := len(h.handlers["*"]) > 0 && channel != "*" + hasPublishers := len(h.publishers) > 0 h.mu.RUnlock() if !running { return ErrHubNotRunning } - if len(peersToSend) == 0 && len(handlers) == 0 && len(wildcardHandlers) == 0 && len(publishers) == 0 { + if len(peersToSend) == 0 && !hasHandlers && !hasWildcardHandlers && !hasPublishers { return nil } for _, peer := range peersToSend { h.sendToPeer(peer, channel, frame) } - h.invokeHandlers(handlers, frame) - h.invokeHandlers(wildcardHandlers, frame) - h.invokePublishHandlers(publishers, channel, frame) + h.enqueueDelivery(channel, frame) return nil } @@ -551,6 +550,43 @@ func (h *Hub) broadcastToPeers(frame []byte) { h.invokeHandlers(handlers, frame) } +type delivery struct { + channel string + frame []byte +} + +func (h *Hub) enqueueDelivery(channel string, frame []byte) { + if h == nil { + return + } + item := delivery{ + channel: channel, + frame: append([]byte(nil), frame...), + } + select { + case h.deliver <- item: + default: + h.processDelivery(item.channel, item.frame) + } +} + +func (h *Hub) processDelivery(channel string, frame []byte) { + if h == nil { + return + } + h.mu.RLock() + handlers := cloneHandlers(h.handlers[channel]) + wildcardHandlers := cloneHandlers(h.handlers["*"]) + publishers := clonePublishHandlers(h.publishers) + h.mu.RUnlock() + + h.invokeHandlers(handlers, frame) + if channel != "*" { + h.invokeHandlers(wildcardHandlers, frame) + } + h.invokePublishHandlers(publishers, channel, frame) +} + func (h *Hub) subscribePublished(handler func(string, []byte)) func() { if h == nil || handler == nil { return func() {} From 5f957f40057139dda3c10825d427acf190503f44 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:08:00 +0000 Subject: [PATCH 010/140] feat(zmq): add handshake auth for receivers Co-Authored-By: Virgil --- adapter/zmq/zmq.go | 58 +++++++++++++++ adapter/zmq/zmq_test.go | 157 ++++++++++++++++++++++++++++++++++++++++ go.sum | 8 ++ 3 files changed, 223 insertions(+) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index b7a3d63..ba90b6a 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -9,6 +9,7 @@ import ( "net" "net/url" "sync" + "time" "github.com/go-zeromq/zmq4" @@ -40,6 +41,14 @@ type Config struct { Endpoint string Role Role Topics []string + + // ConnAuthenticator validates the first received frame before normal dispatch. + // When nil, the adapter accepts the connection without handshake validation. + ConnAuthenticator stream.ConnAuthenticator + + // HandshakeTimeout limits how long the adapter waits for the first frame when + // ConnAuthenticator is configured. Defaults to 5 seconds. + HandshakeTimeout time.Duration } // Adapter is the ZMQ transport adapter. @@ -55,6 +64,9 @@ type Adapter struct { // New creates a ZMQ adapter. Call Mount and Start before use. func New(config Config) *Adapter { + if config.HandshakeTimeout == 0 { + config.HandshakeTimeout = 5 * time.Second + } return &Adapter{config: config} } @@ -120,6 +132,20 @@ func (a *Adapter) Start(ctx context.Context) error { return nil } + if a.config.ConnAuthenticator != nil { + handshake, err := a.recvWithTimeout(runContext, socket, a.config.HandshakeTimeout) + if err != nil { + if err == context.Canceled { + return nil + } + return err + } + result := a.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) + if !result.Valid { + return stream.ErrAuthRejected + } + } + for { message, err := socket.Recv() if err != nil { @@ -257,6 +283,38 @@ func decodeMessage(message zmq4.Msg) (string, []byte, bool) { return "", nil, false } +func (a *Adapter) recvWithTimeout(ctx context.Context, socket zmq4.Socket, timeout time.Duration) (zmq4.Msg, error) { + if timeout <= 0 { + msg, err := socket.Recv() + return msg, err + } + + type result struct { + message zmq4.Msg + err error + } + + receive := make(chan result, 1) + go func() { + msg, err := socket.Recv() + receive <- result{message: msg, err: err} + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-ctx.Done(): + _ = socket.Close() + return zmq4.Msg{}, ctx.Err() + case outcome := <-receive: + return outcome.message, outcome.err + case <-timer.C: + _ = socket.Close() + return zmq4.Msg{}, stream.ErrHandshakeTimeout + } +} + func encodeMessage(channel string, frame []byte) []byte { output := make([]byte, 0, len(channel)+1+len(frame)) output = append(output, []byte(channel)...) diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go index b598248..1fff8c8 100644 --- a/adapter/zmq/zmq_test.go +++ b/adapter/zmq/zmq_test.go @@ -141,6 +141,163 @@ func TestAdapter_Start_Ugly(t *testing.T) { t.Fatal("timed out waiting for push/pull frame") } +func TestAdapter_Start_Auth_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + endpoint := randomTCPEndpoint(t) + subscriber := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RoleSubscriber, + Topics: []string{"block"}, + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + if string(handshake) != "block\x00hello" { + return stream.AuthResult{Valid: false} + } + return stream.AuthResult{Valid: true} + }), + }) + subscriber.Mount(hub) + + publisher := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RolePublisher, + }) + publisher.Mount(stream.NewHub()) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + go func() { _ = subscriber.Start(runContext) }() + go func() { _ = publisher.Start(runContext) }() + waitForAdapterRunning(t, subscriber) + waitForAdapterRunning(t, publisher) + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := publisher.Publish("block", []byte("hello")); err != nil { + t.Fatalf("handshake Publish() error = %v", err) + } + if err := publisher.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + return + case <-time.After(100 * time.Millisecond): + } + } + + t.Fatal("timed out waiting for authenticated zmq frame") +} + +func TestAdapter_Start_Auth_Ugly(t *testing.T) { + endpoint := randomTCPEndpoint(t) + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + subscriber := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RoleSubscriber, + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + return stream.AuthResult{Valid: false} + }), + HandshakeTimeout: 500 * time.Millisecond, + }) + subscriber.Mount(hub) + + publisher := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RolePublisher, + }) + publisher.Mount(stream.NewHub()) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + errs := make(chan error, 1) + go func() { errs <- subscriber.Start(runContext) }() + go func() { _ = publisher.Start(runContext) }() + waitForAdapterRunning(t, subscriber) + waitForAdapterRunning(t, publisher) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := publisher.Publish("block", []byte("hello")); err != nil { + t.Fatalf("handshake Publish() error = %v", err) + } + + select { + case err := <-errs: + if err != stream.ErrAuthRejected { + t.Fatalf("Start() error = %v, want %v", err, stream.ErrAuthRejected) + } + return + case <-time.After(100 * time.Millisecond): + } + } + + t.Fatal("timed out waiting for auth rejection") +} + +func TestAdapter_Start_Auth_Timeout(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + subscriber := New(Config{ + Mode: ModePubSub, + Endpoint: randomTCPEndpoint(t), + Role: RoleSubscriber, + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + return stream.AuthResult{Valid: true} + }), + HandshakeTimeout: 500 * time.Millisecond, + }) + subscriber.Mount(hub) + + publisher := New(Config{ + Mode: ModePubSub, + Endpoint: subscriber.config.Endpoint, + Role: RolePublisher, + }) + publisher.Mount(stream.NewHub()) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + errs := make(chan error, 1) + go func() { errs <- subscriber.Start(runContext) }() + go func() { _ = publisher.Start(runContext) }() + waitForAdapterRunning(t, subscriber) + waitForAdapterRunning(t, publisher) + + select { + case err := <-errs: + if err != stream.ErrHandshakeTimeout { + t.Fatalf("Start() error = %v, want %v", err, stream.ErrHandshakeTimeout) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for handshake timeout") + } +} + func randomTCPEndpoint(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/go.sum b/go.sum index 9ffe7d8..844aa11 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -14,6 +18,8 @@ github.com/go-zeromq/zmq4 v0.17.0 h1:r12/XdqPeRbuaF4C3QZJeWCt7a5vpJbslDH1rTXF+Kc github.com/go-zeromq/zmq4 v0.17.0/go.mod h1:EQxjJD92qKnrsVMzAnx62giD6uJIPi1dMGZ781iCDtY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= @@ -22,6 +28,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= From c39ea6e6ec9f52051ac352ad9d00996dc9f55934 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:12:10 +0000 Subject: [PATCH 011/140] feat(adapter): support direct HTTP handlers --- adapter/sse/sse.go | 11 ++- adapter/sse/sse_test.go | 35 +++++++++ adapter/ws/ws.go | 161 +++++++++++++++++++++------------------- adapter/ws/ws_test.go | 40 ++++++++++ 4 files changed, 166 insertions(+), 81 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index bcea103..73245ac 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -43,11 +43,16 @@ func (a *Adapter) Mount(hub *stream.Hub) { a.hub = hub } +// ServeHTTP accepts an SSE connection and subscribes it using the channel query params. +// +// http.Handle("/stream/events", adapter) +func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.serve(w, r, r.URL.Query()["channel"]) +} + // Handler returns an http.HandlerFunc that accepts SSE connections. func (a *Adapter) Handler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - a.serve(w, r, r.URL.Query()["channel"]) - } + return a.ServeHTTP } // HandlerForChannel returns a handler that auto-subscribes all connections to channel. diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 3cb529c..cf60cec 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -103,6 +103,41 @@ func TestAdapter_Handler_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } +func TestAdapter_ServeHTTP_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{HeartbeatInterval: 20 * time.Millisecond}) + adapter.Mount(hub) + + server := httptest.NewServer(adapter) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=serve-http") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + waitForPeerCount(t, hub, 1) + if err := hub.Publish("serve-http", []byte("ok")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + reader := bufio.NewReader(response.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("ReadString() error = %v", err) + } + if strings.TrimSpace(line) == "data: ok" { + return + } + } +} + func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 29df609..07e2dd8 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -73,96 +73,101 @@ func (a *Adapter) Mount(hub *stream.Hub) { a.hub = hub } -// Handler returns an http.HandlerFunc for WebSocket connections. -// Compatible with net/http and gin (use gin.WrapF). -// -// http.Handle("/stream/ws", adapter.Handler()) +// ServeHTTP upgrades the request to WebSocket and binds the connection to the mounted hub. // -// // Gin: -// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) -func (a *Adapter) Handler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if a.hub == nil { - http.Error(w, "stream hub not mounted", http.StatusInternalServerError) +// http.Handle("/stream/ws", adapter) +func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if a.hub == nil { + http.Error(w, "stream hub not mounted", http.StatusInternalServerError) + return + } + + result := stream.AuthResult{Valid: true} + if a.config.Authenticator != nil { + result = a.config.Authenticator.Authenticate(r) + if !result.Valid { + if a.config.OnAuthFailure != nil { + a.config.OnAuthFailure(r, result) + } + http.Error(w, "unauthorised", http.StatusUnauthorized) return } + } - result := stream.AuthResult{Valid: true} - if a.config.Authenticator != nil { - result = a.config.Authenticator.Authenticate(r) - if !result.Valid { - if a.config.OnAuthFailure != nil { - a.config.OnAuthFailure(r, result) - } - http.Error(w, "unauthorised", http.StatusUnauthorized) - return + upgrader := websocket.Upgrader{ + ReadBufferSize: a.config.ReadBufferSize, + WriteBufferSize: a.config.WriteBufferSize, + CheckOrigin: func(r *http.Request) bool { + if a.config.CheckOrigin != nil { + return a.config.CheckOrigin(r) } - } + return true + }, + } - upgrader := websocket.Upgrader{ - ReadBufferSize: a.config.ReadBufferSize, - WriteBufferSize: a.config.WriteBufferSize, - CheckOrigin: func(r *http.Request) bool { - if a.config.CheckOrigin != nil { - return a.config.CheckOrigin(r) - } - return true - }, - } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - conn, err := upgrader.Upgrade(w, r, nil) + peer := stream.NewPeer("ws") + peer.UserID = result.UserID + peer.Claims = result.Claims + _ = a.hub.AddPeer(peer) + defer a.hub.RemovePeer(peer) + defer conn.Close() + + hubConfig := a.hub.Config() + if hubConfig.PongTimeout > 0 { + _ = conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) + }) + } + + go a.writePump(conn, peer, hubConfig.WriteTimeout, hubConfig.HeartbeatInterval) + + conn.SetReadLimit(1 << 20) + for { + messageType, payload, err := conn.ReadMessage() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + break } - - peer := stream.NewPeer("ws") - peer.UserID = result.UserID - peer.Claims = result.Claims - _ = a.hub.AddPeer(peer) - defer a.hub.RemovePeer(peer) - defer conn.Close() - - hubConfig := a.hub.Config() - if hubConfig.PongTimeout > 0 { - _ = conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) - conn.SetPongHandler(func(string) error { - return conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) - }) + if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage { + continue } - - go a.writePump(conn, peer, hubConfig.WriteTimeout, hubConfig.HeartbeatInterval) - - conn.SetReadLimit(1 << 20) - for { - messageType, payload, err := conn.ReadMessage() - if err != nil { - break - } - if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage { - continue - } - var message stream.Message - if !core.JSONUnmarshal(payload, &message).OK { - continue - } - switch message.Type { - case stream.TypeSubscribe: - _ = a.hub.SubscribePeer(peer, message.Channel) - case stream.TypeUnsubscribe: - a.hub.UnsubscribePeer(peer, message.Channel) - case stream.TypePing: - _ = peer.Send([]byte(core.JSONMarshalString(stream.Message{ - Type: stream.TypePong, - Channel: message.Channel, - ProcessID: message.ProcessID, - Timestamp: time.Now().UTC(), - }))) - } + var message stream.Message + if !core.JSONUnmarshal(payload, &message).OK { + continue + } + switch message.Type { + case stream.TypeSubscribe: + _ = a.hub.SubscribePeer(peer, message.Channel) + case stream.TypeUnsubscribe: + a.hub.UnsubscribePeer(peer, message.Channel) + case stream.TypePing: + _ = peer.Send([]byte(core.JSONMarshalString(stream.Message{ + Type: stream.TypePong, + Channel: message.Channel, + ProcessID: message.ProcessID, + Timestamp: time.Now().UTC(), + }))) } - - peer.Close() } + + peer.Close() +} + +// Handler returns an http.HandlerFunc for WebSocket connections. +// Compatible with net/http and gin (use gin.WrapF). +// +// http.Handle("/stream/ws", adapter.Handler()) +// +// // Gin: +// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) +func (a *Adapter) Handler() http.HandlerFunc { + return a.ServeHTTP } func (a *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, writeTimeout, heartbeatInterval time.Duration) { diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 4c05f39..70dc82d 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -148,6 +148,46 @@ func TestAdapter_Handler_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } +func TestAdapter_ServeHTTP_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(adapter) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + if err := conn.WriteJSON(stream.Message{ + Type: stream.TypeSubscribe, + Channel: "serve-http", + }); err != nil { + t.Fatalf("WriteJSON() error = %v", err) + } + + waitForChannelSubscriberCount(t, hub, "serve-http", 1) + + if err := hub.Publish("serve-http", []byte("ok")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage() error = %v", err) + } + if messageType != websocket.TextMessage { + t.Fatalf("messageType = %d, want %d", messageType, websocket.TextMessage) + } + if string(payload) != "ok" { + t.Fatalf("payload = %q, want %q", string(payload), "ok") + } +} + func dialWebSocket(t *testing.T, serverURL string, header http.Header) *websocket.Conn { t.Helper() conn, resp, err := websocket.DefaultDialer.Dial(websocketURL(serverURL), header) From 22c9c9b403cb50bf031eedec9ce9797a3dd56fa6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:23:23 +0000 Subject: [PATCH 012/140] feat(ws): add legacy compatibility aliases Co-Authored-By: Virgil --- adapter/ws/compat.go | 85 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 adapter/ws/compat.go diff --git a/adapter/ws/compat.go b/adapter/ws/compat.go new file mode 100644 index 0000000..dd9de6e --- /dev/null +++ b/adapter/ws/compat.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package ws preserves the legacy go-ws compatibility surface while the new +// transport-agnostic stream package does the actual work. +package ws + +import ( + "dappco.re/go/stream" + "dappco.re/go/stream/adapter/redis" +) + +// Hub preserves the legacy go-ws Hub type name. +type Hub = stream.Hub + +// HubConfig preserves the legacy go-ws HubConfig type name. +type HubConfig = stream.HubConfig + +// Peer preserves the transport-agnostic peer type under the legacy package. +type Peer = stream.Peer + +// Client preserves the legacy go-ws Client type name. +type Client = stream.Peer + +// Authenticator preserves the legacy go-ws Authenticator type name. +type Authenticator = stream.Authenticator + +// AuthenticatorFunc preserves the legacy go-ws AuthenticatorFunc helper. +type AuthenticatorFunc = stream.AuthenticatorFunc + +// AuthResult preserves the legacy go-ws AuthResult type name. +type AuthResult = stream.AuthResult + +// ConnAuthenticator preserves the legacy raw-connection authenticator name. +type ConnAuthenticator = stream.ConnAuthenticator + +// ConnAuthenticatorFunc preserves the legacy raw-connection helper name. +type ConnAuthenticatorFunc = stream.ConnAuthenticatorFunc + +// Message preserves the legacy go-ws WebSocket message envelope. +type Message = stream.Message + +// MessageType preserves the legacy go-ws message type name. +type MessageType = stream.MessageType + +const ( + // TypeProcessOutput preserves the legacy message type constant. + TypeProcessOutput = stream.TypeProcessOutput + // TypeProcessStatus preserves the legacy message type constant. + TypeProcessStatus = stream.TypeProcessStatus + // TypeEvent preserves the legacy message type constant. + TypeEvent = stream.TypeEvent + // TypeError preserves the legacy message type constant. + TypeError = stream.TypeError + // TypePing preserves the legacy message type constant. + TypePing = stream.TypePing + // TypePong preserves the legacy message type constant. + TypePong = stream.TypePong + // TypeSubscribe preserves the legacy message type constant. + TypeSubscribe = stream.TypeSubscribe + // TypeUnsubscribe preserves the legacy message type constant. + TypeUnsubscribe = stream.TypeUnsubscribe +) + +// RedisBridge preserves the legacy go-ws RedisBridge type name. +type RedisBridge = redis.Bridge + +// NewRedisBridge creates the legacy Redis bridge wrapper. +func NewRedisBridge(hub *stream.Hub, config redis.Config) (*RedisBridge, error) { + return redis.NewBridge(hub, config) +} + +// NewHub creates a legacy-compatible hub. +func NewHub() *Hub { + return stream.NewHub() +} + +// NewHubWithConfig creates a legacy-compatible hub with explicit configuration. +func NewHubWithConfig(config HubConfig) *Hub { + return stream.NewHubWithConfig(config) +} + +// DefaultHubConfig returns the default hub configuration for legacy callers. +func DefaultHubConfig() HubConfig { + return stream.DefaultHubConfig() +} From 26eddad4c9bbef2d68bcfb99f4a73a4b4a4268e9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:28:57 +0000 Subject: [PATCH 013/140] refactor(adapter): use semantic receiver names Co-Authored-By: Virgil --- adapter/redis/redis.go | 104 +++++++++++++++---------------- adapter/sse/sse.go | 34 +++++----- adapter/tcp/reconnect.go | 102 +++++++++++++++--------------- adapter/tcp/tcp.go | 76 +++++++++++------------ adapter/ws/reconnect.go | 130 +++++++++++++++++++-------------------- adapter/ws/ws.go | 42 ++++++------- 6 files changed, 244 insertions(+), 244 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 4413ce6..c7466bd 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -44,17 +44,17 @@ type envelope struct { } // NewBridge creates and validates the Redis connection. Does not start listening. -func NewBridge(hub *stream.Hub, cfg Config) (*Bridge, error) { +func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { if hub == nil { return nil, core.E("stream.redis", "nil hub", nil) } - if cfg.Addr == "" { + if config.Addr == "" { return nil, core.E("stream.redis", "empty address", nil) } - if cfg.Prefix == "" { - cfg.Prefix = "stream" + if config.Prefix == "" { + config.Prefix = "stream" } - client := newRedisClient(cfg) + client := newRedisClient(config) defer client.Close() pingContext, pingCancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -65,14 +65,14 @@ func NewBridge(hub *stream.Hub, cfg Config) (*Bridge, error) { return &Bridge{ hub: hub, - config: cfg, + config: config, sourceID: stream.NewPeer("redis").ID, }, nil } // Start begins the Redis pub/sub listener. Blocks in a goroutine until Stop() or ctx cancel. -func (b *Bridge) Start(ctx context.Context) error { - if b == nil { +func (bridge *Bridge) Start(ctx context.Context) error { + if bridge == nil { return core.E("stream.redis", "nil bridge", nil) } if ctx == nil { @@ -80,21 +80,21 @@ func (b *Bridge) Start(ctx context.Context) error { } runContext, runCancel := context.WithCancel(ctx) - client := newRedisClient(b.config) - pubsub := client.PSubscribe(runContext, b.broadcastChannel(), b.channelPattern()) + client := newRedisClient(bridge.config) + pubsub := client.PSubscribe(runContext, bridge.broadcastChannel(), bridge.channelPattern()) - b.mu.Lock() - b.cancel = runCancel - b.client = client - b.pubsub = pubsub - b.mu.Unlock() + bridge.mu.Lock() + bridge.cancel = runCancel + bridge.client = client + bridge.pubsub = pubsub + bridge.mu.Unlock() defer func() { - b.mu.Lock() - b.cancel = nil - b.client = nil - b.pubsub = nil - b.mu.Unlock() + bridge.mu.Lock() + bridge.cancel = nil + bridge.client = nil + bridge.pubsub = nil + bridge.mu.Unlock() runCancel() _ = pubsub.Close() _ = client.Close() @@ -113,30 +113,30 @@ func (b *Bridge) Start(ctx context.Context) error { if !core.JSONUnmarshal([]byte(message.Payload), &decoded).OK { continue } - if decoded.SourceID == b.sourceID { + if decoded.SourceID == bridge.sourceID { continue } - channel := b.channelFromRedis(message.Channel) + channel := bridge.channelFromRedis(message.Channel) if channel == "" { - _ = b.hub.Broadcast(decoded.Frame) + _ = bridge.hub.Broadcast(decoded.Frame) continue } - _ = b.hub.Publish(channel, decoded.Frame) + _ = bridge.hub.Publish(channel, decoded.Frame) } } // Stop cleanly shuts down the bridge. Closes the pub/sub subscription and Redis client. -func (b *Bridge) Stop() error { - if b == nil { +func (bridge *Bridge) Stop() error { + if bridge == nil { return nil } - b.mu.RLock() - cancel := b.cancel - pubsub := b.pubsub - client := b.client - b.mu.RUnlock() + bridge.mu.RLock() + cancel := bridge.cancel + pubsub := bridge.pubsub + client := bridge.client + bridge.mu.RUnlock() if cancel != nil { cancel() @@ -151,40 +151,40 @@ func (b *Bridge) Stop() error { } // PublishToChannel publishes frame to a specific hub channel via Redis. -func (b *Bridge) PublishToChannel(channel string, frame []byte) error { - if b == nil { +func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { + if bridge == nil { return core.E("stream.redis", "nil bridge", nil) } if channel == "" { return core.E("stream.redis", "empty channel", nil) } - return b.publish(b.channelKey(channel), frame) + return bridge.publish(bridge.channelKey(channel), frame) } // PublishBroadcast publishes frame as a broadcast via Redis. -func (b *Bridge) PublishBroadcast(frame []byte) error { - if b == nil { +func (bridge *Bridge) PublishBroadcast(frame []byte) error { + if bridge == nil { return core.E("stream.redis", "nil bridge", nil) } - return b.publish(b.broadcastChannel(), frame) + return bridge.publish(bridge.broadcastChannel(), frame) } // SourceID returns the random instance identifier. -func (b *Bridge) SourceID() string { - if b == nil { +func (bridge *Bridge) SourceID() string { + if bridge == nil { return "" } - return b.sourceID + return bridge.sourceID } -func (b *Bridge) publish(channel string, frame []byte) error { - client := newRedisClient(b.config) +func (bridge *Bridge) publish(channel string, frame []byte) error { + client := newRedisClient(bridge.config) defer client.Close() payload := envelope{ - SourceID: b.sourceID, + SourceID: bridge.sourceID, Frame: append([]byte(nil), frame...), } encoded := core.JSONMarshal(payload) @@ -200,23 +200,23 @@ func (b *Bridge) publish(channel string, frame []byte) error { return client.Publish(publishContext, channel, encoded.Value).Err() } -func (b *Bridge) broadcastChannel() string { - return b.config.Prefix + ":broadcast" +func (bridge *Bridge) broadcastChannel() string { + return bridge.config.Prefix + ":broadcast" } -func (b *Bridge) channelKey(channel string) string { - return b.config.Prefix + ":channel:" + channel +func (bridge *Bridge) channelKey(channel string) string { + return bridge.config.Prefix + ":channel:" + channel } -func (b *Bridge) channelPattern() string { - return b.config.Prefix + ":channel:*" +func (bridge *Bridge) channelPattern() string { + return bridge.config.Prefix + ":channel:*" } -func (b *Bridge) channelFromRedis(channel string) string { - if channel == b.broadcastChannel() { +func (bridge *Bridge) channelFromRedis(channel string) string { + if channel == bridge.broadcastChannel() { return "" } - return core.TrimPrefix(channel, b.config.Prefix+":channel:") + return core.TrimPrefix(channel, bridge.config.Prefix+":channel:") } func newRedisClient(cfg Config) *redis.Client { diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 73245ac..9d8121f 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -39,38 +39,38 @@ func New(config Config) *Adapter { } // Mount wires the adapter to a hub. Must be called before Handler(). -func (a *Adapter) Mount(hub *stream.Hub) { - a.hub = hub +func (adapter *Adapter) Mount(hub *stream.Hub) { + adapter.hub = hub } // ServeHTTP accepts an SSE connection and subscribes it using the channel query params. // // http.Handle("/stream/events", adapter) -func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { - a.serve(w, r, r.URL.Query()["channel"]) +func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + adapter.serve(w, r, r.URL.Query()["channel"]) } // Handler returns an http.HandlerFunc that accepts SSE connections. -func (a *Adapter) Handler() http.HandlerFunc { - return a.ServeHTTP +func (adapter *Adapter) Handler() http.HandlerFunc { + return adapter.ServeHTTP } // HandlerForChannel returns a handler that auto-subscribes all connections to channel. -func (a *Adapter) HandlerForChannel(channel string) http.HandlerFunc { +func (adapter *Adapter) HandlerForChannel(channel string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - a.serve(w, r, []string{channel}) + adapter.serve(w, r, []string{channel}) } } -func (a *Adapter) serve(w http.ResponseWriter, r *http.Request, channels []string) { - if a.hub == nil { +func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels []string) { + if adapter.hub == nil { http.Error(w, "stream hub not mounted", http.StatusInternalServerError) return } result := stream.AuthResult{Valid: true} - if a.config.Authenticator != nil { - result = a.config.Authenticator.Authenticate(r) + if adapter.config.Authenticator != nil { + result = adapter.config.Authenticator.Authenticate(r) if !result.Valid { http.Error(w, "unauthorised", http.StatusUnauthorized) return @@ -91,20 +91,20 @@ func (a *Adapter) serve(w http.ResponseWriter, r *http.Request, channels []strin peer := stream.NewPeer("sse") peer.UserID = result.UserID peer.Claims = result.Claims - _ = a.hub.AddPeer(peer) - defer a.hub.RemovePeer(peer) + _ = adapter.hub.AddPeer(peer) + defer adapter.hub.RemovePeer(peer) for _, channel := range channels { if channel == "" { continue } - _ = a.hub.SubscribePeer(peer, channel) + _ = adapter.hub.SubscribePeer(peer, channel) } - _, _ = io.WriteString(w, "retry: "+strconv.Itoa(a.config.RetryMs)+"\n\n") + _, _ = io.WriteString(w, "retry: "+strconv.Itoa(adapter.config.RetryMs)+"\n\n") flusher.Flush() - ticker := time.NewTicker(a.config.HeartbeatInterval) + ticker := time.NewTicker(adapter.config.HeartbeatInterval) defer ticker.Stop() done := r.Context().Done() diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 9ce1b57..dd87049 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -49,50 +49,50 @@ func NewReconnectingTCP(config ReconnectConfig) *ReconnectingTCP { } // Connect starts the connection loop. Blocks until ctx is cancelled. -func (rc *ReconnectingTCP) Connect(ctx context.Context) error { - if rc == nil { +func (client *ReconnectingTCP) Connect(ctx context.Context) error { + if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) } if ctx == nil { ctx = context.Background() } - backoff := rc.config.InitialBackoff + backoff := client.config.InitialBackoff attempt := 0 for { - if rc.isClosed() || ctx.Err() != nil { + if client.isClosed() || ctx.Err() != nil { return nil } - conn, err := rc.dial(ctx) + conn, err := client.dial(ctx) if err != nil { attempt++ - if rc.config.MaxRetries > 0 && attempt > rc.config.MaxRetries { + if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { return err } if err := sleepContext(ctx, backoff); err != nil { return err } - backoff = nextTCPBackoff(backoff, rc.config.BackoffMultiplier, rc.config.MaxBackoff) + backoff = nextTCPBackoff(backoff, client.config.BackoffMultiplier, client.config.MaxBackoff) continue } - rc.setConn(conn) - backoff = rc.config.InitialBackoff + client.setConn(conn) + backoff = client.config.InitialBackoff attempt = 0 - if rc.config.OnConnect != nil { - rc.config.OnConnect() + if client.config.OnConnect != nil { + client.config.OnConnect() } - readErr := rc.readLoop(ctx, conn) + readErr := client.readLoop(ctx, conn) - rc.clearConn(conn) + client.clearConn(conn) _ = conn.Close() - if rc.config.OnDisconnect != nil { - rc.config.OnDisconnect() + if client.config.OnDisconnect != nil { + client.config.OnDisconnect() } - if rc.isClosed() || ctx.Err() != nil { + if client.isClosed() || ctx.Err() != nil { return nil } if readErr == nil { @@ -100,24 +100,24 @@ func (rc *ReconnectingTCP) Connect(ctx context.Context) error { } else { attempt++ } - if rc.config.MaxRetries > 0 && attempt > rc.config.MaxRetries { + if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { return readErr } if err := sleepContext(ctx, backoff); err != nil { return err } - backoff = nextTCPBackoff(backoff, rc.config.BackoffMultiplier, rc.config.MaxBackoff) + backoff = nextTCPBackoff(backoff, client.config.BackoffMultiplier, client.config.MaxBackoff) } } // Send transmits frame on channel through the TCP connection. -func (rc *ReconnectingTCP) Send(channel string, frame []byte) error { - if rc == nil { +func (client *ReconnectingTCP) Send(channel string, frame []byte) error { + if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) } - rc.mu.RLock() - conn := rc.conn - rc.mu.RUnlock() + client.mu.RLock() + conn := client.conn + client.mu.RUnlock() if conn == nil { return core.E("stream.tcp", "not connected", nil) } @@ -126,39 +126,39 @@ func (rc *ReconnectingTCP) Send(channel string, frame []byte) error { } // Close shuts down the reconnecting client. -func (rc *ReconnectingTCP) Close() error { - if rc == nil { +func (client *ReconnectingTCP) Close() error { + if client == nil { return nil } - rc.mu.Lock() - rc.closed = true - conn := rc.conn - rc.conn = nil - rc.mu.Unlock() + client.mu.Lock() + client.closed = true + conn := client.conn + client.conn = nil + client.mu.Unlock() if conn != nil { return conn.Close() } return nil } -func (rc *ReconnectingTCP) dial(ctx context.Context) (net.Conn, error) { +func (client *ReconnectingTCP) dial(ctx context.Context) (net.Conn, error) { dialer := &net.Dialer{} - if rc.config.TLS != nil { - conn, err := dialer.DialContext(ctx, "tcp", rc.config.Addr) + if client.config.TLS != nil { + conn, err := dialer.DialContext(ctx, "tcp", client.config.Addr) if err != nil { return nil, err } - tlsConn := tls.Client(conn, rc.config.TLS) + tlsConn := tls.Client(conn, client.config.TLS) if err := tlsConn.HandshakeContext(ctx); err != nil { _ = conn.Close() return nil, err } return tlsConn, nil } - return dialer.DialContext(ctx, "tcp", rc.config.Addr) + return dialer.DialContext(ctx, "tcp", client.config.Addr) } -func (rc *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) error { +func (client *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) error { for { select { case <-ctx.Done(): @@ -169,30 +169,30 @@ func (rc *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) error { if err != nil { return err } - if rc.config.OnMessage != nil { - rc.config.OnMessage(channel, frame) + if client.config.OnMessage != nil { + client.config.OnMessage(channel, frame) } } } -func (rc *ReconnectingTCP) setConn(conn net.Conn) { - rc.mu.Lock() - rc.conn = conn - rc.mu.Unlock() +func (client *ReconnectingTCP) setConn(conn net.Conn) { + client.mu.Lock() + client.conn = conn + client.mu.Unlock() } -func (rc *ReconnectingTCP) clearConn(conn net.Conn) { - rc.mu.Lock() - if rc.conn == conn { - rc.conn = nil +func (client *ReconnectingTCP) clearConn(conn net.Conn) { + client.mu.Lock() + if client.conn == conn { + client.conn = nil } - rc.mu.Unlock() + client.mu.Unlock() } -func (rc *ReconnectingTCP) isClosed() bool { - rc.mu.RLock() - defer rc.mu.RUnlock() - return rc.closed +func (client *ReconnectingTCP) isClosed() bool { + client.mu.RLock() + defer client.mu.RUnlock() + return client.closed } func nextTCPBackoff(current time.Duration, multiplier float64, maximum time.Duration) time.Duration { diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index defbc2c..10e159b 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -49,36 +49,36 @@ func New(config Config) *Adapter { } // Mount wires the adapter to a hub. -func (a *Adapter) Mount(hub *stream.Hub) { - a.hub = hub +func (adapter *Adapter) Mount(hub *stream.Hub) { + adapter.hub = hub } // Listen starts the TCP accept loop. Blocks until ctx cancelled. -func (a *Adapter) Listen(ctx context.Context) error { - if a == nil { +func (adapter *Adapter) Listen(ctx context.Context) error { + if adapter == nil { return core.E("stream.tcp", "nil adapter", nil) } if ctx == nil { ctx = context.Background() } - if a.hub == nil { + if adapter.hub == nil { return core.E("stream.tcp", "stream hub not mounted", nil) } - if a.config.Addr == "" { + if adapter.config.Addr == "" { return core.E("stream.tcp", "empty address", nil) } - listener, err := a.listen() + listener, err := adapter.listen() if err != nil { return err } defer func() { _ = listener.Close() - a.mu.Lock() - if a.listener == listener { - a.listener = nil + adapter.mu.Lock() + if adapter.listener == listener { + adapter.listener = nil } - a.mu.Unlock() + adapter.mu.Unlock() }() go func() { @@ -97,22 +97,22 @@ func (a *Adapter) Listen(ctx context.Context) error { } return err } - go a.handleConn(ctx, conn, a.hub) + go adapter.handleConn(ctx, conn, adapter.hub) } } // Dial connects to a remote TCP stream endpoint. Returns a Peer that can send/receive. -func (a *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer, error) { - if a == nil { +func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer, error) { + if adapter == nil { return nil, core.E("stream.tcp", "nil adapter", nil) } if hub == nil { - hub = a.hub + hub = adapter.hub } if hub == nil { return nil, core.E("stream.tcp", "stream hub not mounted", nil) } - conn, err := a.dial(ctx) + conn, err := adapter.dial(ctx) if err != nil { return nil, err } @@ -120,59 +120,59 @@ func (a *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer, erro peer := stream.NewPeer("tcp") _ = hub.AddPeer(peer) _ = hub.SubscribePeer(peer, "*") - go a.pipePeer(ctx, conn, peer, hub) + go adapter.pipePeer(ctx, conn, peer, hub) return peer, nil } -func (a *Adapter) listen() (net.Listener, error) { - a.mu.Lock() - defer a.mu.Unlock() - if a.listener != nil { - return a.listener, nil +func (adapter *Adapter) listen() (net.Listener, error) { + adapter.mu.Lock() + defer adapter.mu.Unlock() + if adapter.listener != nil { + return adapter.listener, nil } var ( listener net.Listener err error ) - if a.config.TLS != nil { - listener, err = tls.Listen("tcp", a.config.Addr, a.config.TLS) + if adapter.config.TLS != nil { + listener, err = tls.Listen("tcp", adapter.config.Addr, adapter.config.TLS) } else { - listener, err = net.Listen("tcp", a.config.Addr) + listener, err = net.Listen("tcp", adapter.config.Addr) } if err != nil { return nil, err } - a.listener = listener + adapter.listener = listener return listener, nil } -func (a *Adapter) dial(ctx context.Context) (net.Conn, error) { +func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { dialer := &net.Dialer{} - if a.config.TLS != nil { - conn, err := dialer.DialContext(ctx, "tcp", a.config.Addr) + if adapter.config.TLS != nil { + conn, err := dialer.DialContext(ctx, "tcp", adapter.config.Addr) if err != nil { return nil, err } - tlsConn := tls.Client(conn, a.config.TLS) + tlsConn := tls.Client(conn, adapter.config.TLS) if err := tlsConn.HandshakeContext(ctx); err != nil { _ = conn.Close() return nil, err } return tlsConn, nil } - return dialer.DialContext(ctx, "tcp", a.config.Addr) + return dialer.DialContext(ctx, "tcp", adapter.config.Addr) } -func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { +func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { defer conn.Close() - _, handshake, err := readFrame(conn, a.config.HandshakeTimeout, maxHandshakeFrameSize) + _, handshake, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) if err != nil { return } result := stream.AuthResult{Valid: true} - if auth := a.config.ConnAuthenticator; auth != nil { + if auth := adapter.config.ConnAuthenticator; auth != nil { result = auth.AuthenticateConn(handshake) if !result.Valid { return @@ -186,7 +186,7 @@ func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub _ = hub.SubscribePeer(peer, "*") defer hub.RemovePeer(peer) - go a.writePump(ctx, conn, peer, hub.Config().WriteTimeout) + go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { channel, frame, err := readFrame(conn, 0, MaxFrameSize) @@ -201,9 +201,9 @@ func (a *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub } } -func (a *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer, hub *stream.Hub) { +func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer, hub *stream.Hub) { defer conn.Close() - go a.writePump(ctx, conn, peer, hub.Config().WriteTimeout) + go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { channel, frame, err := readFrame(conn, 0, MaxFrameSize) if err != nil { @@ -218,7 +218,7 @@ func (a *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer } } -func (a *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Peer, writeTimeout time.Duration) { +func (adapter *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stream.Peer, writeTimeout time.Duration) { for { select { case <-ctx.Done(): diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 8f1c94d..873ab83 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -54,70 +54,70 @@ func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { } // Connect starts the connection loop. Blocks until ctx is cancelled. -func (rc *ReconnectingClient) Connect(ctx context.Context) error { - if rc == nil { +func (client *ReconnectingClient) Connect(ctx context.Context) error { + if client == nil { return core.E("stream.ws", "nil reconnecting client", nil) } if ctx == nil { ctx = context.Background() } - dialer := rc.config.Dialer + dialer := client.config.Dialer if dialer == nil { dialer = websocket.DefaultDialer } - backoff := rc.config.InitialBackoff + backoff := client.config.InitialBackoff attempt := 0 for { - if rc.isClosed() || ctx.Err() != nil { + if client.isClosed() || ctx.Err() != nil { return nil } - rc.setState(stream.StateConnecting) + client.setState(stream.StateConnecting) - conn, _, err := dialer.DialContext(ctx, rc.config.URL, rc.config.Headers) + conn, _, err := dialer.DialContext(ctx, client.config.URL, client.config.Headers) if err != nil { attempt++ - rc.setState(stream.StateDisconnected) - if rc.config.MaxRetries > 0 && attempt > rc.config.MaxRetries { + client.setState(stream.StateDisconnected) + if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { return err } - if rc.config.OnReconnect != nil { - rc.config.OnReconnect(attempt) + if client.config.OnReconnect != nil { + client.config.OnReconnect(attempt) } if err := sleepContext(ctx, backoff); err != nil { return err } - backoff = nextBackoff(backoff, rc.config.BackoffMultiplier, rc.config.MaxBackoff) + backoff = nextBackoff(backoff, client.config.BackoffMultiplier, client.config.MaxBackoff) continue } - rc.mu.Lock() - rc.conn = conn - rc.state = stream.StateConnected - rc.mu.Unlock() - backoff = rc.config.InitialBackoff + client.mu.Lock() + client.conn = conn + client.state = stream.StateConnected + client.mu.Unlock() + backoff = client.config.InitialBackoff attempt = 0 - if rc.config.OnConnect != nil { - rc.config.OnConnect() + if client.config.OnConnect != nil { + client.config.OnConnect() } - readErr := rc.readLoop(ctx, conn) + readErr := client.readLoop(ctx, conn) - rc.mu.Lock() - if rc.conn == conn { - rc.conn = nil + client.mu.Lock() + if client.conn == conn { + client.conn = nil } - rc.state = stream.StateDisconnected - rc.mu.Unlock() + client.state = stream.StateDisconnected + client.mu.Unlock() _ = conn.Close() - if rc.config.OnDisconnect != nil { - rc.config.OnDisconnect() + if client.config.OnDisconnect != nil { + client.config.OnDisconnect() } - if rc.isClosed() || ctx.Err() != nil { + if client.isClosed() || ctx.Err() != nil { return nil } if readErr == nil { @@ -125,22 +125,22 @@ func (rc *ReconnectingClient) Connect(ctx context.Context) error { } else { attempt++ } - if rc.config.MaxRetries > 0 && attempt > rc.config.MaxRetries { + if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { return readErr } - if rc.config.OnReconnect != nil { - rc.config.OnReconnect(attempt) + if client.config.OnReconnect != nil { + client.config.OnReconnect(attempt) } if err := sleepContext(ctx, backoff); err != nil { return err } - backoff = nextBackoff(backoff, rc.config.BackoffMultiplier, rc.config.MaxBackoff) + backoff = nextBackoff(backoff, client.config.BackoffMultiplier, client.config.MaxBackoff) } } // Send marshals and sends a message through the WebSocket connection. -func (rc *ReconnectingClient) Send(msg stream.Message) error { - if rc == nil { +func (client *ReconnectingClient) Send(msg stream.Message) error { + if client == nil { return core.E("stream.ws", "nil reconnecting client", nil) } if msg.Timestamp.IsZero() { @@ -154,48 +154,48 @@ func (rc *ReconnectingClient) Send(msg stream.Message) error { return core.E("stream.ws", "failed to marshal message", nil) } - rc.mu.RLock() - conn := rc.conn - rc.mu.RUnlock() + client.mu.RLock() + conn := client.conn + client.mu.RUnlock() if conn == nil { return core.E("stream.ws", "not connected", nil) } - rc.mu.Lock() - defer rc.mu.Unlock() - if rc.conn == nil { + client.mu.Lock() + defer client.mu.Unlock() + if client.conn == nil { return core.E("stream.ws", "not connected", nil) } - return rc.conn.WriteMessage(websocket.TextMessage, payload.Value.([]byte)) + return client.conn.WriteMessage(websocket.TextMessage, payload.Value.([]byte)) } // State returns the current connection state. -func (rc *ReconnectingClient) State() stream.ConnectionState { - if rc == nil { +func (client *ReconnectingClient) State() stream.ConnectionState { + if client == nil { return stream.StateDisconnected } - rc.mu.RLock() - defer rc.mu.RUnlock() - return rc.state + client.mu.RLock() + defer client.mu.RUnlock() + return client.state } // Close shuts down the reconnecting client. -func (rc *ReconnectingClient) Close() error { - if rc == nil { +func (client *ReconnectingClient) Close() error { + if client == nil { return nil } - rc.mu.Lock() - rc.closed = true - conn := rc.conn - rc.conn = nil - rc.state = stream.StateDisconnected - rc.mu.Unlock() + client.mu.Lock() + client.closed = true + conn := client.conn + client.conn = nil + client.state = stream.StateDisconnected + client.mu.Unlock() if conn != nil { return conn.Close() } return nil } -func (rc *ReconnectingClient) readLoop(ctx context.Context, conn *websocket.Conn) error { +func (client *ReconnectingClient) readLoop(ctx context.Context, conn *websocket.Conn) error { for { select { case <-ctx.Done(): @@ -213,22 +213,22 @@ func (rc *ReconnectingClient) readLoop(ctx context.Context, conn *websocket.Conn if !core.JSONUnmarshal(payload, &message).OK { continue } - if rc.config.OnMessage != nil { - rc.config.OnMessage(message) + if client.config.OnMessage != nil { + client.config.OnMessage(message) } } } -func (rc *ReconnectingClient) isClosed() bool { - rc.mu.RLock() - defer rc.mu.RUnlock() - return rc.closed +func (client *ReconnectingClient) isClosed() bool { + client.mu.RLock() + defer client.mu.RUnlock() + return client.closed } -func (rc *ReconnectingClient) setState(state stream.ConnectionState) { - rc.mu.Lock() - rc.state = state - rc.mu.Unlock() +func (client *ReconnectingClient) setState(state stream.ConnectionState) { + client.mu.Lock() + client.state = state + client.mu.Unlock() } func nextBackoff(current time.Duration, multiplier float64, maximum time.Duration) time.Duration { diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 07e2dd8..ab257db 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -69,25 +69,25 @@ func New(config Config) *Adapter { // Mount wires the adapter to a hub. Must be called before Handler(). // // adapter.Mount(hub) -func (a *Adapter) Mount(hub *stream.Hub) { - a.hub = hub +func (adapter *Adapter) Mount(hub *stream.Hub) { + adapter.hub = hub } // ServeHTTP upgrades the request to WebSocket and binds the connection to the mounted hub. // // http.Handle("/stream/ws", adapter) -func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if a.hub == nil { +func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if adapter.hub == nil { http.Error(w, "stream hub not mounted", http.StatusInternalServerError) return } result := stream.AuthResult{Valid: true} - if a.config.Authenticator != nil { - result = a.config.Authenticator.Authenticate(r) + if adapter.config.Authenticator != nil { + result = adapter.config.Authenticator.Authenticate(r) if !result.Valid { - if a.config.OnAuthFailure != nil { - a.config.OnAuthFailure(r, result) + if adapter.config.OnAuthFailure != nil { + adapter.config.OnAuthFailure(r, result) } http.Error(w, "unauthorised", http.StatusUnauthorized) return @@ -95,11 +95,11 @@ func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { } upgrader := websocket.Upgrader{ - ReadBufferSize: a.config.ReadBufferSize, - WriteBufferSize: a.config.WriteBufferSize, + ReadBufferSize: adapter.config.ReadBufferSize, + WriteBufferSize: adapter.config.WriteBufferSize, CheckOrigin: func(r *http.Request) bool { - if a.config.CheckOrigin != nil { - return a.config.CheckOrigin(r) + if adapter.config.CheckOrigin != nil { + return adapter.config.CheckOrigin(r) } return true }, @@ -114,11 +114,11 @@ func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { peer := stream.NewPeer("ws") peer.UserID = result.UserID peer.Claims = result.Claims - _ = a.hub.AddPeer(peer) - defer a.hub.RemovePeer(peer) + _ = adapter.hub.AddPeer(peer) + defer adapter.hub.RemovePeer(peer) defer conn.Close() - hubConfig := a.hub.Config() + hubConfig := adapter.hub.Config() if hubConfig.PongTimeout > 0 { _ = conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) conn.SetPongHandler(func(string) error { @@ -126,7 +126,7 @@ func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) } - go a.writePump(conn, peer, hubConfig.WriteTimeout, hubConfig.HeartbeatInterval) + go adapter.writePump(conn, peer, hubConfig.WriteTimeout, hubConfig.HeartbeatInterval) conn.SetReadLimit(1 << 20) for { @@ -143,9 +143,9 @@ func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { } switch message.Type { case stream.TypeSubscribe: - _ = a.hub.SubscribePeer(peer, message.Channel) + _ = adapter.hub.SubscribePeer(peer, message.Channel) case stream.TypeUnsubscribe: - a.hub.UnsubscribePeer(peer, message.Channel) + adapter.hub.UnsubscribePeer(peer, message.Channel) case stream.TypePing: _ = peer.Send([]byte(core.JSONMarshalString(stream.Message{ Type: stream.TypePong, @@ -166,11 +166,11 @@ func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { // // // Gin: // r.GET("/stream/ws", gin.WrapF(adapter.Handler())) -func (a *Adapter) Handler() http.HandlerFunc { - return a.ServeHTTP +func (adapter *Adapter) Handler() http.HandlerFunc { + return adapter.ServeHTTP } -func (a *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, writeTimeout, heartbeatInterval time.Duration) { +func (adapter *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, writeTimeout, heartbeatInterval time.Duration) { var ticker *time.Ticker var heartbeat <-chan time.Time if heartbeatInterval > 0 { From 0782aee903eb0e0484b47c752d7e95d1ea4414d3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:35:37 +0000 Subject: [PATCH 014/140] feat(stream): add bridge delivery hooks Co-Authored-By: Virgil --- adapter/redis/redis.go | 60 +++++++++++-- adapter/redis/redis_test.go | 23 ++++- hub.go | 174 +++++++++++++++++++++++++++--------- 3 files changed, 206 insertions(+), 51 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index c7466bd..c8a2103 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -32,10 +32,12 @@ type Bridge struct { config Config sourceID string - mu sync.RWMutex - cancel context.CancelFunc - pubsub *redis.PubSub - client *redis.Client + mu sync.RWMutex + cancel context.CancelFunc + pubsub *redis.PubSub + client *redis.Client + publishStop func() + broadcastStop func() } type envelope struct { @@ -82,19 +84,40 @@ func (bridge *Bridge) Start(ctx context.Context) error { runContext, runCancel := context.WithCancel(ctx) client := newRedisClient(bridge.config) pubsub := client.PSubscribe(runContext, bridge.broadcastChannel(), bridge.channelPattern()) + publishStop := bridge.hub.SubscribePublished(func(channel string, frame []byte) { + if channel == "" { + return + } + _ = bridge.publishWithClient(client, bridge.channelKey(channel), frame) + }) + broadcastStop := bridge.hub.SubscribeBroadcast(func(frame []byte) { + _ = bridge.publishWithClient(client, bridge.broadcastChannel(), frame) + }) bridge.mu.Lock() bridge.cancel = runCancel bridge.client = client bridge.pubsub = pubsub + bridge.publishStop = publishStop + bridge.broadcastStop = broadcastStop bridge.mu.Unlock() defer func() { bridge.mu.Lock() + publishStop := bridge.publishStop + broadcastStop := bridge.broadcastStop bridge.cancel = nil bridge.client = nil bridge.pubsub = nil + bridge.publishStop = nil + bridge.broadcastStop = nil bridge.mu.Unlock() + if publishStop != nil { + publishStop() + } + if broadcastStop != nil { + broadcastStop() + } runCancel() _ = pubsub.Close() _ = client.Close() @@ -119,10 +142,10 @@ func (bridge *Bridge) Start(ctx context.Context) error { channel := bridge.channelFromRedis(message.Channel) if channel == "" { - _ = bridge.hub.Broadcast(decoded.Frame) + _ = bridge.hub.BroadcastFromBridge(decoded.Frame) continue } - _ = bridge.hub.Publish(channel, decoded.Frame) + _ = bridge.hub.PublishFromBridge(channel, decoded.Frame) } } @@ -136,11 +159,19 @@ func (bridge *Bridge) Stop() error { cancel := bridge.cancel pubsub := bridge.pubsub client := bridge.client + publishStop := bridge.publishStop + broadcastStop := bridge.broadcastStop bridge.mu.RUnlock() if cancel != nil { cancel() } + if publishStop != nil { + publishStop() + } + if broadcastStop != nil { + broadcastStop() + } if pubsub != nil { _ = pubsub.Close() } @@ -180,8 +211,21 @@ func (bridge *Bridge) SourceID() string { } func (bridge *Bridge) publish(channel string, frame []byte) error { - client := newRedisClient(bridge.config) - defer client.Close() + bridge.mu.RLock() + client := bridge.client + bridge.mu.RUnlock() + if client == nil { + client = newRedisClient(bridge.config) + defer client.Close() + } + + return bridge.publishWithClient(client, channel, frame) +} + +func (bridge *Bridge) publishWithClient(client *redis.Client, channel string, frame []byte) error { + if client == nil { + return core.E("stream.redis", "nil redis client", nil) + } payload := envelope{ SourceID: bridge.sourceID, diff --git a/adapter/redis/redis_test.go b/adapter/redis/redis_test.go index ff58d50..f896c61 100644 --- a/adapter/redis/redis_test.go +++ b/adapter/redis/redis_test.go @@ -47,8 +47,8 @@ func TestBridge_Publish_Good(t *testing.T) { }) defer unsubscribe() - if err := bridge1.PublishToChannel("block", []byte("template")); err != nil { - t.Fatalf("PublishToChannel() error = %v", err) + if err := hub1.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) } select { @@ -59,6 +59,25 @@ func TestBridge_Publish_Good(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("timed out waiting for bridged frame") } + + peer := stream.NewPeer("ws") + if err := hub2.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub2.RemovePeer(peer) + + if err := hub1.Broadcast([]byte("shutdown")); err != nil { + t.Fatalf("Broadcast() error = %v", err) + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "shutdown" { + t.Fatalf("received frame = %q, want %q", string(frame), "shutdown") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for bridged broadcast") + } } func TestBridge_Publish_Bad(t *testing.T) { diff --git a/hub.go b/hub.go index c2a4304..e4094d8 100644 --- a/hub.go +++ b/hub.go @@ -21,20 +21,21 @@ import ( // wsAdapter.Mount(hub) // http.Handle("/stream/ws", wsAdapter.Handler()) type Hub struct { - peers map[*Peer]bool - broadcast chan []byte - deliver chan delivery - register chan *Peer - unregister chan *Peer - channels map[string]map[*Peer]bool - handlers map[string]map[uint64]func([]byte) - publishers map[uint64]func(string, []byte) - nextID uint64 - config HubConfig - done chan struct{} - doneOnce sync.Once - running bool - mu sync.RWMutex + peers map[*Peer]bool + broadcast chan broadcastDelivery + deliver chan delivery + register chan *Peer + unregister chan *Peer + channels map[string]map[*Peer]bool + handlers map[string]map[uint64]func([]byte) + broadcastHandlers map[uint64]func([]byte) + publishers map[uint64]func(string, []byte) + nextID uint64 + config HubConfig + done chan struct{} + doneOnce sync.Once + running bool + mu sync.RWMutex } // NewHub creates a hub with default configuration. @@ -54,16 +55,17 @@ func NewHub() *Hub { func NewHubWithConfig(config HubConfig) *Hub { config = normalizeHubConfig(config) return &Hub{ - peers: map[*Peer]bool{}, - broadcast: make(chan []byte, 256), - deliver: make(chan delivery, 256), - register: make(chan *Peer, 256), - unregister: make(chan *Peer, 256), - channels: map[string]map[*Peer]bool{}, - handlers: map[string]map[uint64]func([]byte){}, - publishers: map[uint64]func(string, []byte){}, - config: config, - done: make(chan struct{}), + peers: map[*Peer]bool{}, + broadcast: make(chan broadcastDelivery, 256), + deliver: make(chan delivery, 256), + register: make(chan *Peer, 256), + unregister: make(chan *Peer, 256), + channels: map[string]map[*Peer]bool{}, + handlers: map[string]map[uint64]func([]byte){}, + broadcastHandlers: map[uint64]func([]byte){}, + publishers: map[uint64]func(string, []byte){}, + config: config, + done: make(chan struct{}), } } @@ -124,10 +126,10 @@ func (h *Hub) Run(ctx context.Context) { h.addPeer(peer) case peer := <-h.unregister: h.removePeer(peer) - case frame := <-h.broadcast: - h.broadcastToPeers(frame) + case item := <-h.broadcast: + h.broadcastToPeers(item.frame, item.notifyBroadcastSubscribers) case item := <-h.deliver: - h.processDelivery(item.channel, item.frame) + h.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) } } } @@ -137,6 +139,17 @@ func (h *Hub) Run(ctx context.Context) { // // hub.SendToChannel("process:abc123", frame) func (h *Hub) SendToChannel(channel string, frame []byte) error { + return h.sendToChannel(channel, frame, true) +} + +// PublishFromBridge delivers frame to subscribers without notifying publish hooks. +// +// _ = hub.PublishFromBridge("block", frame) +func (h *Hub) PublishFromBridge(channel string, frame []byte) error { + return h.sendToChannel(channel, frame, false) +} + +func (h *Hub) sendToChannel(channel string, frame []byte, notifyPublishSubscribers bool) error { if h == nil { return core.E("stream.hub", "nil hub", nil) } @@ -145,7 +158,7 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { peersToSend := h.collectChannelPeersLocked(channel) hasHandlers := len(h.handlers[channel]) > 0 hasWildcardHandlers := len(h.handlers["*"]) > 0 && channel != "*" - hasPublishers := len(h.publishers) > 0 + hasPublishers := notifyPublishSubscribers && len(h.publishers) > 0 h.mu.RUnlock() if !running { return ErrHubNotRunning @@ -156,7 +169,7 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { for _, peer := range peersToSend { h.sendToPeer(peer, channel, frame) } - h.enqueueDelivery(channel, frame) + h.enqueueDelivery(channel, frame, notifyPublishSubscribers) return nil } @@ -252,7 +265,7 @@ func (h *Hub) UnsubscribePeer(peer *Peer, channel string) { // // hub.Publish("hashrate", frame) func (h *Hub) Publish(channel string, frame []byte) error { - return h.SendToChannel(channel, frame) + return h.sendToChannel(channel, frame, true) } // Broadcast sends frame to every connected peer regardless of subscriptions. @@ -260,6 +273,17 @@ func (h *Hub) Publish(channel string, frame []byte) error { // // hub.Broadcast([]byte(`{"type":"shutdown"}`)) func (h *Hub) Broadcast(frame []byte) error { + return h.broadcastFrame(frame, true) +} + +// BroadcastFromBridge delivers frame to peers without notifying broadcast hooks. +// +// _ = hub.BroadcastFromBridge([]byte("shutdown")) +func (h *Hub) BroadcastFromBridge(frame []byte) error { + return h.broadcastFrame(frame, false) +} + +func (h *Hub) broadcastFrame(frame []byte, notifyBroadcastSubscribers bool) error { if h == nil { return core.E("stream.hub", "nil hub", nil) } @@ -270,10 +294,13 @@ func (h *Hub) Broadcast(frame []byte) error { return ErrHubNotRunning } select { - case h.broadcast <- append([]byte(nil), frame...): + case h.broadcast <- broadcastDelivery{ + frame: append([]byte(nil), frame...), + notifyBroadcastSubscribers: notifyBroadcastSubscribers, + }: return nil default: - h.broadcastToPeers(frame) + h.broadcastToPeers(frame, notifyBroadcastSubscribers) } return nil } @@ -311,6 +338,36 @@ func (h *Hub) Stats() HubStats { } } +// SubscribePublished registers a handler invoked for each published channel frame. +// +// _ = hub.SubscribePublished(func(channel string, frame []byte) { ... }) +func (h *Hub) SubscribePublished(handler func(string, []byte)) func() { + return h.subscribePublished(handler) +} + +// SubscribeBroadcast registers a handler invoked for each broadcast frame. +// +// _ = hub.SubscribeBroadcast(func(frame []byte) { ... }) +func (h *Hub) SubscribeBroadcast(handler func([]byte)) func() { + if h == nil || handler == nil { + return func() {} + } + h.mu.Lock() + if h.broadcastHandlers == nil { + h.broadcastHandlers = map[uint64]func([]byte){} + } + h.nextID++ + id := h.nextID + h.broadcastHandlers[id] = handler + h.mu.Unlock() + + return func() { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.broadcastHandlers, id) + } +} + // PeerCount returns the number of connected peers. // // n := hub.PeerCount() @@ -533,7 +590,7 @@ func (h *Hub) removePeer(peer *Peer) { } } -func (h *Hub) broadcastToPeers(frame []byte) { +func (h *Hub) broadcastToPeers(frame []byte, notifyBroadcastSubscribers bool) { if h == nil { return } @@ -543,34 +600,45 @@ func (h *Hub) broadcastToPeers(frame []byte) { peers = append(peers, peer) } handlers := cloneHandlers(h.handlers["*"]) + broadcastHandlers := cloneBroadcastHandlers(h.broadcastHandlers) h.mu.RUnlock() for _, peer := range peers { h.sendBroadcastToPeer(peer, frame) } h.invokeHandlers(handlers, frame) + if notifyBroadcastSubscribers { + h.invokeBroadcastHandlers(broadcastHandlers, frame) + } } type delivery struct { - channel string - frame []byte + channel string + frame []byte + notifyPublishSubscribers bool } -func (h *Hub) enqueueDelivery(channel string, frame []byte) { +type broadcastDelivery struct { + frame []byte + notifyBroadcastSubscribers bool +} + +func (h *Hub) enqueueDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { if h == nil { return } item := delivery{ - channel: channel, - frame: append([]byte(nil), frame...), + channel: channel, + frame: append([]byte(nil), frame...), + notifyPublishSubscribers: notifyPublishSubscribers, } select { case h.deliver <- item: default: - h.processDelivery(item.channel, item.frame) + h.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) } } -func (h *Hub) processDelivery(channel string, frame []byte) { +func (h *Hub) processDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { if h == nil { return } @@ -584,7 +652,9 @@ func (h *Hub) processDelivery(channel string, frame []byte) { if channel != "*" { h.invokeHandlers(wildcardHandlers, frame) } - h.invokePublishHandlers(publishers, channel, frame) + if notifyPublishSubscribers { + h.invokePublishHandlers(publishers, channel, frame) + } } func (h *Hub) subscribePublished(handler func(string, []byte)) func() { @@ -607,6 +677,17 @@ func (h *Hub) subscribePublished(handler func(string, []byte)) func() { } } +func (h *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { + for _, handler := range handlers { + func(fn func([]byte)) { + defer func() { + _ = recover() + }() + fn(frame) + }(handler) + } +} + func (h *Hub) invokePublishHandlers(handlers []func(string, []byte), channel string, frame []byte) { for _, handler := range handlers { func(fn func(string, []byte)) { @@ -657,6 +738,17 @@ func clonePublishHandlers(handlers map[uint64]func(string, []byte)) []func(strin return cloned } +func cloneBroadcastHandlers(handlers map[uint64]func([]byte)) []func([]byte) { + if len(handlers) == 0 { + return nil + } + cloned := make([]func([]byte), 0, len(handlers)) + for _, handler := range handlers { + cloned = append(cloned, handler) + } + return cloned +} + func clonePeers(peers map[*Peer]bool) []*Peer { if len(peers) == 0 { return nil From fceec98e871d570c05b21910f29ca42f927d223c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:39:05 +0000 Subject: [PATCH 015/140] fix(stream): forward pipe broadcasts Co-Authored-By: Virgil --- hub_test.go | 37 +++++++++++++++++++++++++++++++++++++ stream.go | 30 +++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/hub_test.go b/hub_test.go index 6ab7f42..53c6e22 100644 --- a/hub_test.go +++ b/hub_test.go @@ -45,6 +45,43 @@ func TestHub_Pipe_Good(t *testing.T) { } } +func TestHub_Pipe_Broadcast_Good(t *testing.T) { + sourceHub := NewHub() + destinationHub := NewHub() + + sourceContext, sourceCancel := context.WithCancel(context.Background()) + defer sourceCancel() + destinationContext, destinationCancel := context.WithCancel(context.Background()) + defer destinationCancel() + + go sourceHub.Run(sourceContext) + go destinationHub.Run(destinationContext) + waitForRunningHub(t, sourceHub) + waitForRunningHub(t, destinationHub) + + stop := Pipe(sourceHub, destinationHub) + defer stop() + + received := make(chan []byte, 1) + unsubscribe := destinationHub.SubscribeBroadcast(func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := sourceHub.Broadcast([]byte("shutdown")); err != nil { + t.Fatalf("Broadcast() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "shutdown" { + t.Fatalf("received broadcast frame = %q, want %q", string(frame), "shutdown") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for broadcast frame") + } +} + func TestHub_Pipe_Bad(t *testing.T) { sourceHub := NewHub() destinationHub := NewHub() diff --git a/stream.go b/stream.go index d2585ea..767c2e1 100644 --- a/stream.go +++ b/stream.go @@ -193,7 +193,8 @@ type Envelope struct { Frame []byte } -// Pipe connects src to dst: every frame published on src is forwarded to dst. +// Pipe connects src to dst: published frames are forwarded with their channel, +// and broadcast frames are forwarded as broadcasts when the source exposes that hook. // Returns a stop function. Safe to call from multiple goroutines. // // stop := stream.Pipe(zmqHub, wsHub) @@ -203,17 +204,32 @@ func Pipe(src Stream, dst Stream) func() { return func() {} } type publishSubscriber interface { - subscribePublished(handler func(string, []byte)) func() + SubscribePublished(handler func(string, []byte)) func() } + type broadcastSubscriber interface { + SubscribeBroadcast(handler func([]byte)) func() + } + stops := make([]func(), 0, 2) if publisher, ok := src.(publishSubscriber); ok { - return publisher.subscribePublished(func(channel string, frame []byte) { + stops = append(stops, publisher.SubscribePublished(func(channel string, frame []byte) { _ = dst.Publish(channel, frame) + })) + } + if broadcaster, ok := src.(broadcastSubscriber); ok { + stops = append(stops, broadcaster.SubscribeBroadcast(func(frame []byte) { + _ = dst.Broadcast(frame) + })) + } + if len(stops) == 0 { + return src.Subscribe("*", func(frame []byte) { + _ = dst.Broadcast(frame) }) } - stop := src.Subscribe("*", func(frame []byte) { - _ = dst.Broadcast(frame) - }) - return stop + return func() { + for index := len(stops) - 1; index >= 0; index-- { + stops[index]() + } + } } // Ensure Hub satisfies Stream at compile time. From c4e50e2a0a3d7c63a5cf814f173d609d06b42314 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:50:36 +0000 Subject: [PATCH 016/140] feat(ws): restore legacy compatibility exports Co-Authored-By: Virgil --- adapter/ws/compat.go | 56 ++++++++++++++++++++ adapter/ws/compat_test.go | 107 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 adapter/ws/compat_test.go diff --git a/adapter/ws/compat.go b/adapter/ws/compat.go index dd9de6e..dff1fd7 100644 --- a/adapter/ws/compat.go +++ b/adapter/ws/compat.go @@ -9,12 +9,18 @@ import ( "dappco.re/go/stream/adapter/redis" ) +// Stream preserves the transport-agnostic stream interface for legacy callers. +type Stream = stream.Stream + // Hub preserves the legacy go-ws Hub type name. type Hub = stream.Hub // HubConfig preserves the legacy go-ws HubConfig type name. type HubConfig = stream.HubConfig +// HubStats preserves the legacy hub stats type name. +type HubStats = stream.HubStats + // Peer preserves the transport-agnostic peer type under the legacy package. type Peer = stream.Peer @@ -30,12 +36,24 @@ type AuthenticatorFunc = stream.AuthenticatorFunc // AuthResult preserves the legacy go-ws AuthResult type name. type AuthResult = stream.AuthResult +// APIKeyAuthenticator preserves the legacy API key authenticator type name. +type APIKeyAuthenticator = stream.APIKeyAuthenticator + +// BearerTokenAuth preserves the legacy bearer-token authenticator type name. +type BearerTokenAuth = stream.BearerTokenAuth + +// QueryTokenAuth preserves the legacy query-token authenticator type name. +type QueryTokenAuth = stream.QueryTokenAuth + // ConnAuthenticator preserves the legacy raw-connection authenticator name. type ConnAuthenticator = stream.ConnAuthenticator // ConnAuthenticatorFunc preserves the legacy raw-connection helper name. type ConnAuthenticatorFunc = stream.ConnAuthenticatorFunc +// ConnectionState preserves the reconnecting client connection state type. +type ConnectionState = stream.ConnectionState + // Message preserves the legacy go-ws WebSocket message envelope. type Message = stream.Message @@ -59,6 +77,29 @@ const ( TypeSubscribe = stream.TypeSubscribe // TypeUnsubscribe preserves the legacy message type constant. TypeUnsubscribe = stream.TypeUnsubscribe + // StateDisconnected preserves the reconnecting client disconnected state. + StateDisconnected = stream.StateDisconnected + // StateConnecting preserves the reconnecting client connecting state. + StateConnecting = stream.StateConnecting + // StateConnected preserves the reconnecting client connected state. + StateConnected = stream.StateConnected +) + +var ( + // ErrMissingAuthHeader preserves the legacy missing-header sentinel error. + ErrMissingAuthHeader = stream.ErrMissingAuthHeader + // ErrMalformedAuthHeader preserves the legacy malformed-header sentinel error. + ErrMalformedAuthHeader = stream.ErrMalformedAuthHeader + // ErrInvalidAPIKey preserves the legacy invalid API key sentinel error. + ErrInvalidAPIKey = stream.ErrInvalidAPIKey + // ErrHandshakeTimeout preserves the legacy handshake timeout sentinel error. + ErrHandshakeTimeout = stream.ErrHandshakeTimeout + // ErrAuthRejected preserves the legacy authenticator rejection sentinel error. + ErrAuthRejected = stream.ErrAuthRejected + // ErrHubNotRunning preserves the legacy hub lifecycle sentinel error. + ErrHubNotRunning = stream.ErrHubNotRunning + // ErrEmptyChannel preserves the legacy empty-channel sentinel error. + ErrEmptyChannel = stream.ErrEmptyChannel ) // RedisBridge preserves the legacy go-ws RedisBridge type name. @@ -69,6 +110,11 @@ func NewRedisBridge(hub *stream.Hub, config redis.Config) (*RedisBridge, error) return redis.NewBridge(hub, config) } +// NewAPIKeyAuth creates the legacy-compatible API key authenticator wrapper. +func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { + return stream.NewAPIKeyAuth(keys) +} + // NewHub creates a legacy-compatible hub. func NewHub() *Hub { return stream.NewHub() @@ -83,3 +129,13 @@ func NewHubWithConfig(config HubConfig) *Hub { func DefaultHubConfig() HubConfig { return stream.DefaultHubConfig() } + +// NewPeer creates a legacy-compatible peer with a buffered send queue. +func NewPeer(transport string) *Peer { + return stream.NewPeer(transport) +} + +// Pipe preserves the legacy stream pipe composition helper. +func Pipe(src Stream, dst Stream) func() { + return stream.Pipe(src, dst) +} diff --git a/adapter/ws/compat_test.go b/adapter/ws/compat_test.go new file mode 100644 index 0000000..538ed82 --- /dev/null +++ b/adapter/ws/compat_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws + +import ( + "context" + "testing" + "time" +) + +func TestCompat_LegacySurface_Good(t *testing.T) { + auth := NewAPIKeyAuth(map[string]string{"valid-key": "user-1"}) + if auth == nil { + t.Fatal("NewAPIKeyAuth() = nil") + } + + if StateDisconnected != 0 || StateConnecting != 1 || StateConnected != 2 { + t.Fatalf("unexpected connection states: %d %d %d", StateDisconnected, StateConnecting, StateConnected) + } + + if ErrMissingAuthHeader == nil || ErrMalformedAuthHeader == nil || ErrInvalidAPIKey == nil { + t.Fatal("expected auth sentinel errors to be re-exported") + } + if ErrHandshakeTimeout == nil || ErrAuthRejected == nil || ErrHubNotRunning == nil || ErrEmptyChannel == nil { + t.Fatal("expected transport sentinel errors to be re-exported") + } + + sourceHub := NewHub() + destinationHub := NewHub() + + sourceContext, sourceCancel := context.WithCancel(context.Background()) + defer sourceCancel() + destinationContext, destinationCancel := context.WithCancel(context.Background()) + defer destinationCancel() + + go sourceHub.Run(sourceContext) + go destinationHub.Run(destinationContext) + waitForRunningHub(t, sourceHub) + waitForRunningHub(t, destinationHub) + + received := make(chan []byte, 1) + unsubscribe := destinationHub.Subscribe("hashrate", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + stop := Pipe(sourceHub, destinationHub) + defer stop() + + if err := sourceHub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for piped frame") + } + + peer := NewPeer("ws") + if peer == nil { + t.Fatal("NewPeer() = nil") + } + if peer.Transport != "ws" { + t.Fatalf("peer.Transport = %q, want %q", peer.Transport, "ws") + } + + stats := destinationHub.Stats() + var _ HubStats = stats +} + +func TestCompat_LegacySurface_Bad(t *testing.T) { + hub := NewHub() + + if err := hub.Publish("hashrate", []byte("123456")); err != ErrHubNotRunning { + t.Fatalf("Publish() error = %v, want %v", err, ErrHubNotRunning) + } + + peer := NewPeer("ws") + if err := hub.SubscribePeer(peer, ""); err != ErrEmptyChannel { + t.Fatalf("SubscribePeer() error = %v, want %v", err, ErrEmptyChannel) + } +} + +func TestCompat_LegacySurface_Ugly(t *testing.T) { + var source Stream + stop := Pipe(source, source) + if stop == nil { + t.Fatal("Pipe(nil, nil) returned nil stop function") + } + stop() +} + +func waitForRunningHub(t *testing.T, hub *Hub) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.Publish("health", nil) == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for hub to start") +} From 21bfd41cd39b14681c070d266432797b30c474f5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 20:00:17 +0000 Subject: [PATCH 017/140] feat(stream): add explicit subscription errors Co-Authored-By: Virgil --- hub.go | 34 +++++++++++++++----- hub_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/hub.go b/hub.go index e4094d8..fd57392 100644 --- a/hub.go +++ b/hub.go @@ -173,15 +173,22 @@ func (h *Hub) sendToChannel(channel string, frame []byte, notifyPublishSubscribe return nil } -// Subscribe registers a handler function invoked for every frame arriving on channel. -// Returns an unsubscribe function. Multiple handlers per channel are allowed. -// Handlers run in the hub's goroutine — keep them non-blocking. +// SubscribeE registers a handler function invoked for every frame arriving on channel. +// Returns an unsubscribe function and an error for invalid input. Multiple handlers +// per channel are allowed. Handlers run in the hub's goroutine — keep them non-blocking. // -// unsub := hub.Subscribe("block", func(f []byte) { ... }) +// unsub, err := hub.SubscribeE("block", func(f []byte) { ... }) +// if err != nil { return err } // defer unsub() -func (h *Hub) Subscribe(channel string, handler func([]byte)) func() { - if h == nil || channel == "" || handler == nil { - return func() {} +func (h *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { + if h == nil { + return func() {}, core.E("stream.hub", "nil hub", nil) + } + if channel == "" { + return func() {}, ErrEmptyChannel + } + if handler == nil { + return func() {}, core.E("stream.hub", "nil handler", nil) } h.mu.Lock() if h.handlers == nil { @@ -207,7 +214,18 @@ func (h *Hub) Subscribe(channel string, handler func([]byte)) func() { delete(h.handlers, channel) } } - } + }, nil +} + +// Subscribe registers a handler function invoked for every frame arriving on channel. +// Returns an unsubscribe function. Multiple handlers per channel are allowed. +// Handlers run in the hub's goroutine — keep them non-blocking. +// +// unsub := hub.Subscribe("block", func(f []byte) { ... }) +// defer unsub() +func (h *Hub) Subscribe(channel string, handler func([]byte)) func() { + unsub, _ := h.SubscribeE(channel, handler) + return unsub } // SubscribePeer adds peer to a named channel. Used by transport adapters when diff --git a/hub_test.go b/hub_test.go index 53c6e22..250b3f2 100644 --- a/hub_test.go +++ b/hub_test.go @@ -294,6 +294,98 @@ func TestHub_Broadcast_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } +func TestHub_SubscribeE_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + received := make(chan []byte, 1) + unsubscribe, err := hub.SubscribeE("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + if err != nil { + t.Fatalf("SubscribeE() error = %v", err) + } + defer unsubscribe() + + if err := hub.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for subscribed frame") + } +} + +func TestHub_SubscribeE_Bad(t *testing.T) { + hub := NewHub() + + unsubscribe, err := hub.SubscribeE("", func(frame []byte) {}) + if err != ErrEmptyChannel { + t.Fatalf("SubscribeE() error = %v, want %v", err, ErrEmptyChannel) + } + if unsubscribe == nil { + t.Fatal("SubscribeE() unsubscribe = nil") + } + unsubscribe() +} + +func TestHub_SubscribeE_Ugly(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + panicked := 0 + unsubscribe, err := hub.SubscribeE("event", func(frame []byte) { + panicked++ + panic("boom") + }) + if err != nil { + t.Fatalf("SubscribeE() error = %v", err) + } + defer unsubscribe() + + received := make(chan []byte, 1) + safeUnsubscribe := hub.Subscribe("event", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer safeUnsubscribe() + + if err := hub.Publish("event", []byte("payload")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "payload" { + t.Fatalf("received frame = %q, want %q", string(frame), "payload") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for safe handler") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if panicked == 1 { + return + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("SubscribeE panic handler count = %d, want 1", panicked) +} + func TestHub_SendToChannel_Wildcard_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) From 6f803f87ba3d50b582b59175d054dbef79622f7f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 20:14:24 +0000 Subject: [PATCH 018/140] fix(redis): enforce bridge startup lifecycle Co-Authored-By: Virgil --- adapter/redis/redis.go | 19 +++++++++++++++++++ adapter/redis/redis_test.go | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index c8a2103..8ae195f 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -33,6 +33,7 @@ type Bridge struct { sourceID string mu sync.RWMutex + running bool cancel context.CancelFunc pubsub *redis.PubSub client *redis.Client @@ -81,6 +82,14 @@ func (bridge *Bridge) Start(ctx context.Context) error { ctx = context.Background() } + bridge.mu.Lock() + if bridge.running { + bridge.mu.Unlock() + return nil + } + bridge.running = true + bridge.mu.Unlock() + runContext, runCancel := context.WithCancel(ctx) client := newRedisClient(bridge.config) pubsub := client.PSubscribe(runContext, bridge.broadcastChannel(), bridge.channelPattern()) @@ -106,6 +115,7 @@ func (bridge *Bridge) Start(ctx context.Context) error { bridge.mu.Lock() publishStop := bridge.publishStop broadcastStop := bridge.broadcastStop + bridge.running = false bridge.cancel = nil bridge.client = nil bridge.pubsub = nil @@ -156,6 +166,7 @@ func (bridge *Bridge) Stop() error { } bridge.mu.RLock() + running := bridge.running cancel := bridge.cancel pubsub := bridge.pubsub client := bridge.client @@ -163,6 +174,10 @@ func (bridge *Bridge) Stop() error { broadcastStop := bridge.broadcastStop bridge.mu.RUnlock() + if !running { + return nil + } + if cancel != nil { cancel() } @@ -212,8 +227,12 @@ func (bridge *Bridge) SourceID() string { func (bridge *Bridge) publish(channel string, frame []byte) error { bridge.mu.RLock() + running := bridge.running client := bridge.client bridge.mu.RUnlock() + if !running { + return core.E("stream.redis", "bridge not started", nil) + } if client == nil { client = newRedisClient(bridge.config) defer client.Close() diff --git a/adapter/redis/redis_test.go b/adapter/redis/redis_test.go index f896c61..ccfea92 100644 --- a/adapter/redis/redis_test.go +++ b/adapter/redis/redis_test.go @@ -88,6 +88,23 @@ func TestBridge_Publish_Bad(t *testing.T) { } } +func TestBridge_Publish_BadBeforeStart(t *testing.T) { + redisServer := miniredis.RunT(t) + + hub := stream.NewHub() + bridge, err := NewBridge(hub, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + if err != nil { + t.Fatalf("NewBridge() error = %v", err) + } + + if err := bridge.PublishToChannel("block", []byte("template")); err == nil { + t.Fatal("PublishToChannel() error = nil, want bridge not started error") + } + if err := bridge.PublishBroadcast([]byte("shutdown")); err == nil { + t.Fatal("PublishBroadcast() error = nil, want bridge not started error") + } +} + func TestBridge_Publish_Ugly(t *testing.T) { redisServer := miniredis.RunT(t) From 87593145f5e3f824b6ad08f47fc18cbcea5b8b14 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 20:18:12 +0000 Subject: [PATCH 019/140] fix(sse): default zero-value heartbeat config Co-Authored-By: Virgil --- adapter/sse/sse.go | 12 ++++++++++-- adapter/sse/sse_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 9d8121f..e956a7d 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -68,6 +68,14 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ return } + config := adapter.config + if config.HeartbeatInterval == 0 { + config.HeartbeatInterval = 15 * time.Second + } + if config.RetryMs == 0 { + config.RetryMs = 3000 + } + result := stream.AuthResult{Valid: true} if adapter.config.Authenticator != nil { result = adapter.config.Authenticator.Authenticate(r) @@ -101,10 +109,10 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ _ = adapter.hub.SubscribePeer(peer, channel) } - _, _ = io.WriteString(w, "retry: "+strconv.Itoa(adapter.config.RetryMs)+"\n\n") + _, _ = io.WriteString(w, "retry: "+strconv.Itoa(config.RetryMs)+"\n\n") flusher.Flush() - ticker := time.NewTicker(adapter.config.HeartbeatInterval) + ticker := time.NewTicker(config.HeartbeatInterval) defer ticker.Stop() done := r.Context().Done() diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index cf60cec..6049123 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -49,6 +49,41 @@ func TestAdapter_Handler_Good(t *testing.T) { } } +func TestAdapter_Handler_ZeroValueConfig_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := &Adapter{} + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=hashrate") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + waitForPeerCount(t, hub, 1) + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + reader := bufio.NewReader(response.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("ReadString() error = %v", err) + } + if strings.TrimSpace(line) == "data: 123456" { + return + } + } +} + func TestAdapter_Handler_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) From 9127d43e2d6de71b99b090b4964ba3f99ade3c23 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 20:26:21 +0000 Subject: [PATCH 020/140] feat(ws): add channel handler Co-Authored-By: Virgil --- adapter/ws/ws.go | 19 +++++++ adapter/ws/ws_test.go | 33 ++++++++++++ adapter/zmq/zmq.go | 120 +++++++++++++++++++++--------------------- 3 files changed, 112 insertions(+), 60 deletions(-) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index ab257db..f93e07c 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -77,6 +77,19 @@ func (adapter *Adapter) Mount(hub *stream.Hub) { // // http.Handle("/stream/ws", adapter) func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + adapter.serveHTTP(w, r, r.URL.Query()["channel"]) +} + +// HandlerForChannel returns a handler that auto-subscribes every connection to one channel. +// +// http.Handle("/stream/hashrate", adapter.HandlerForChannel("hashrate")) +func (adapter *Adapter) HandlerForChannel(channel string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + adapter.serveHTTP(w, r, []string{channel}) + } +} + +func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channels []string) { if adapter.hub == nil { http.Error(w, "stream hub not mounted", http.StatusInternalServerError) return @@ -116,6 +129,12 @@ func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { peer.Claims = result.Claims _ = adapter.hub.AddPeer(peer) defer adapter.hub.RemovePeer(peer) + for _, channel := range channels { + if channel == "" { + continue + } + _ = adapter.hub.SubscribePeer(peer, channel) + } defer conn.Close() hubConfig := adapter.hub.Config() diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 70dc82d..fb47396 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -188,6 +188,39 @@ func TestAdapter_ServeHTTP_Good(t *testing.T) { } } +func TestAdapter_HandlerForChannel_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.HandlerForChannel("hashrate"))) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + waitForChannelSubscriberCount(t, hub, "hashrate", 1) + + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage() error = %v", err) + } + if messageType != websocket.TextMessage { + t.Fatalf("messageType = %d, want %d", messageType, websocket.TextMessage) + } + if string(payload) != "123456" { + t.Fatalf("payload = %q, want %q", string(payload), "123456") + } +} + func dialWebSocket(t *testing.T, serverURL string, header http.Header) *websocket.Conn { t.Helper() conn, resp, err := websocket.DefaultDialer.Dial(websocketURL(serverURL), header) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index ba90b6a..45e8bf8 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -71,76 +71,76 @@ func New(config Config) *Adapter { } // Mount wires the adapter to a hub. -func (a *Adapter) Mount(hub *stream.Hub) { - a.hub = hub +func (adapter *Adapter) Mount(hub *stream.Hub) { + adapter.hub = hub } // Start opens the ZMQ socket and begins receive/dispatch. Blocks until ctx cancelled. -func (a *Adapter) Start(ctx context.Context) error { - if a == nil { +func (adapter *Adapter) Start(ctx context.Context) error { + if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) } if ctx == nil { ctx = context.Background() } - if a.config.Endpoint == "" { + if adapter.config.Endpoint == "" { return core.E("stream.zmq", "empty endpoint", nil) } - if a.hub == nil { + if adapter.hub == nil { return core.E("stream.zmq", "stream hub not mounted", nil) } - if err := a.validateRole(); err != nil { + if err := adapter.validateRole(); err != nil { return err } runContext, runCancel := context.WithCancel(ctx) - socket, err := a.newSocket(runContext) + socket, err := adapter.newSocket(runContext) if err != nil { runCancel() return err } - if err := a.connectSocket(socket); err != nil { + if err := adapter.connectSocket(socket); err != nil { _ = socket.Close() runCancel() return err } - a.mu.Lock() - if a.running { - a.mu.Unlock() + adapter.mu.Lock() + if adapter.running { + adapter.mu.Unlock() _ = socket.Close() runCancel() return nil } - a.running = true - a.socket = socket - a.cancel = runCancel - a.mu.Unlock() + adapter.running = true + adapter.socket = socket + adapter.cancel = runCancel + adapter.mu.Unlock() defer func() { - a.mu.Lock() - a.running = false - a.socket = nil - a.cancel = nil - a.mu.Unlock() + adapter.mu.Lock() + adapter.running = false + adapter.socket = nil + adapter.cancel = nil + adapter.mu.Unlock() runCancel() _ = socket.Close() }() - if !a.isReceiver() { + if !adapter.isReceiver() { <-runContext.Done() return nil } - if a.config.ConnAuthenticator != nil { - handshake, err := a.recvWithTimeout(runContext, socket, a.config.HandshakeTimeout) + if adapter.config.ConnAuthenticator != nil { + handshake, err := adapter.recvWithTimeout(runContext, socket, adapter.config.HandshakeTimeout) if err != nil { if err == context.Canceled { return nil } return err } - result := a.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) + result := adapter.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) if !result.Valid { return stream.ErrAuthRejected } @@ -160,26 +160,26 @@ func (a *Adapter) Start(ctx context.Context) error { continue } if channel == "" { - _ = a.hub.Broadcast(frame) + _ = adapter.hub.Broadcast(frame) continue } - _ = a.hub.Publish(channel, frame) + _ = adapter.hub.Publish(channel, frame) } } // Publish sends frame with topic (channel name) via the ZMQ socket. -func (a *Adapter) Publish(channel string, frame []byte) error { - if a == nil { +func (adapter *Adapter) Publish(channel string, frame []byte) error { + if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) } - if !a.isSender() { + if !adapter.isSender() { return core.E("stream.zmq", "publish not supported for this role", nil) } - a.mu.RLock() - socket := a.socket - running := a.running - a.mu.RUnlock() + adapter.mu.RLock() + socket := adapter.socket + running := adapter.running + adapter.mu.RUnlock() if !running || socket == nil { return core.E("stream.zmq", "adapter not started", nil) } @@ -188,15 +188,15 @@ func (a *Adapter) Publish(channel string, frame []byte) error { } // Stop shuts down the adapter. -func (a *Adapter) Stop() error { - if a == nil { +func (adapter *Adapter) Stop() error { + if adapter == nil { return nil } - a.mu.RLock() - cancel := a.cancel - socket := a.socket - a.mu.RUnlock() + adapter.mu.RLock() + cancel := adapter.cancel + socket := adapter.socket + adapter.mu.RUnlock() if cancel != nil { cancel() @@ -207,14 +207,14 @@ func (a *Adapter) Stop() error { return nil } -func (a *Adapter) validateRole() error { - switch a.config.Mode { +func (adapter *Adapter) validateRole() error { + switch adapter.config.Mode { case ModePubSub: - if a.config.Role != RolePublisher && a.config.Role != RoleSubscriber { + if adapter.config.Role != RolePublisher && adapter.config.Role != RoleSubscriber { return core.E("stream.zmq", "invalid pubsub role", nil) } case ModePushPull: - if a.config.Role != RolePusher && a.config.Role != RolePuller { + if adapter.config.Role != RolePusher && adapter.config.Role != RolePuller { return core.E("stream.zmq", "invalid pushpull role", nil) } default: @@ -223,13 +223,13 @@ func (a *Adapter) validateRole() error { return nil } -func (a *Adapter) newSocket(ctx context.Context) (zmq4.Socket, error) { - switch a.config.Role { +func (adapter *Adapter) newSocket(ctx context.Context) (zmq4.Socket, error) { + switch adapter.config.Role { case RolePublisher: return zmq4.NewPub(ctx), nil case RoleSubscriber: socket := zmq4.NewSub(ctx) - topics := a.config.Topics + topics := adapter.config.Topics if len(topics) == 0 { topics = []string{""} } @@ -248,26 +248,26 @@ func (a *Adapter) newSocket(ctx context.Context) (zmq4.Socket, error) { } } -func (a *Adapter) connectSocket(socket zmq4.Socket) error { - if a.shouldListen() { - return socket.Listen(listenEndpoint(a.config.Endpoint)) +func (adapter *Adapter) connectSocket(socket zmq4.Socket) error { + if adapter.shouldListen() { + return socket.Listen(listenEndpoint(adapter.config.Endpoint)) } - return socket.Dial(a.config.Endpoint) + return socket.Dial(adapter.config.Endpoint) } -func (a *Adapter) shouldListen() bool { - if a.config.Mode == ModePushPull { - return a.config.Role == RolePusher +func (adapter *Adapter) shouldListen() bool { + if adapter.config.Mode == ModePushPull { + return adapter.config.Role == RolePusher } - return a.config.Role == RolePublisher + return adapter.config.Role == RolePublisher } -func (a *Adapter) isSender() bool { - return a.config.Role == RolePublisher || a.config.Role == RolePusher +func (adapter *Adapter) isSender() bool { + return adapter.config.Role == RolePublisher || adapter.config.Role == RolePusher } -func (a *Adapter) isReceiver() bool { - return a.config.Role == RoleSubscriber || a.config.Role == RolePuller +func (adapter *Adapter) isReceiver() bool { + return adapter.config.Role == RoleSubscriber || adapter.config.Role == RolePuller } func decodeMessage(message zmq4.Msg) (string, []byte, bool) { @@ -283,7 +283,7 @@ func decodeMessage(message zmq4.Msg) (string, []byte, bool) { return "", nil, false } -func (a *Adapter) recvWithTimeout(ctx context.Context, socket zmq4.Socket, timeout time.Duration) (zmq4.Msg, error) { +func (adapter *Adapter) recvWithTimeout(ctx context.Context, socket zmq4.Socket, timeout time.Duration) (zmq4.Msg, error) { if timeout <= 0 { msg, err := socket.Recv() return msg, err From 0cb8f0fdf2fa11ff44b9f9416a0ae7682eaf66b1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 20:34:52 +0000 Subject: [PATCH 021/140] fix(tcp): preserve first payload without auth handshake Co-Authored-By: Virgil --- adapter/tcp/tcp.go | 10 ++++----- adapter/tcp/tcp_test.go | 49 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 10e159b..52ee14c 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -116,7 +116,6 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer if err != nil { return nil, err } - _, _ = conn.Write(encodeFrame("", nil)) peer := stream.NewPeer("tcp") _ = hub.AddPeer(peer) _ = hub.SubscribePeer(peer, "*") @@ -166,13 +165,12 @@ func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { defer conn.Close() - _, handshake, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) - if err != nil { - return - } - result := stream.AuthResult{Valid: true} if auth := adapter.config.ConnAuthenticator; auth != nil { + _, handshake, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) + if err != nil { + return + } result = auth.AuthenticateConn(handshake) if !result.Valid { return diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 1d03cd8..3682bd3 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -62,6 +62,50 @@ func TestTCP_Listen_Good(t *testing.T) { waitForPeerCount(t, hub, 1) } +func TestTCP_Listen_NoAuthenticator_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if _, err := connection.Write(encodeFrame("block", []byte("template"))); err != nil { + t.Fatalf("Write() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for unauthenticated frame") + } +} + func TestTCP_Listen_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) @@ -103,7 +147,10 @@ func TestTCP_Listen_Ugly(t *testing.T) { go hub.Run(hubContext) adapter := New(Config{ - Addr: "127.0.0.1:0", + Addr: "127.0.0.1:0", + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + return stream.AuthResult{Valid: true} + }), HandshakeTimeout: 50 * time.Millisecond, }) adapter.Mount(hub) From a1935bb58f34386ce72cd2c7359a95bed1855849 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 20:40:53 +0000 Subject: [PATCH 022/140] Implement inbound transport dispatch semantics --- adapter/tcp/tcp.go | 8 ++-- adapter/tcp/tcp_test.go | 52 +++++++++++++++++++++++ adapter/ws/ws.go | 6 +++ adapter/ws/ws_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ hub.go | 43 ++++++++++++++++--- 5 files changed, 193 insertions(+), 9 deletions(-) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 52ee14c..a499745 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -192,10 +192,10 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre return } if channel == "" { - _ = hub.Broadcast(frame) + _ = hub.BroadcastFromPeer(peer, frame) continue } - _ = hub.Publish(channel, frame) + _ = hub.PublishFromPeer(peer, channel, frame) } } @@ -209,10 +209,10 @@ func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *strea return } if channel == "" { - _ = hub.Broadcast(frame) + _ = hub.BroadcastFromPeer(peer, frame) continue } - _ = hub.Publish(channel, frame) + _ = hub.PublishFromPeer(peer, channel, frame) } } diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 3682bd3..0ad6210 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -106,6 +106,58 @@ func TestTCP_Listen_NoAuthenticator_Good(t *testing.T) { } } +func TestTCP_Listen_NoSelfEcho_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if _, err := connection.Write(encodeFrame("block", []byte("template"))); err != nil { + t.Fatalf("Write() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for published TCP frame") + } + + channel, frame, err := readFrame(connection, 200*time.Millisecond, MaxFrameSize) + if err == nil { + t.Fatalf("readFrame() = (%q, %q, nil), want timeout without self-echo", channel, string(frame)) + } + if err != stream.ErrHandshakeTimeout { + t.Fatalf("readFrame() error = %v, want %v", err, stream.ErrHandshakeTimeout) + } +} + func TestTCP_Listen_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index f93e07c..ea36ca0 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -172,6 +172,12 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe ProcessID: message.ProcessID, Timestamp: time.Now().UTC(), }))) + default: + if message.Channel == "" { + _ = adapter.hub.BroadcastFromPeer(peer, payload) + continue + } + _ = adapter.hub.PublishFromPeer(peer, message.Channel, payload) } } diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index fb47396..928516d 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -4,6 +4,8 @@ package ws import ( "context" + "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" @@ -221,6 +223,97 @@ func TestAdapter_HandlerForChannel_Good(t *testing.T) { } } +func TestAdapter_Handler_InboundPublish_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("agent", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + message := stream.Message{ + Type: stream.TypeEvent, + Channel: "agent", + Data: map[string]any{"status": "ok"}, + Timestamp: time.Now().UTC(), + } + if err := conn.WriteJSON(message); err != nil { + t.Fatalf("WriteJSON() error = %v", err) + } + + select { + case frame := <-received: + var decoded stream.Message + if err := json.Unmarshal(frame, &decoded); err != nil { + t.Fatalf("received invalid JSON frame: %q", string(frame)) + } + if decoded.Type != stream.TypeEvent { + t.Fatalf("decoded.Type = %q, want %q", decoded.Type, stream.TypeEvent) + } + if decoded.Channel != "agent" { + t.Fatalf("decoded.Channel = %q, want %q", decoded.Channel, "agent") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for inbound websocket frame") + } +} + +func TestAdapter_Handler_InboundPublish_NoSelfEcho_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + if err := conn.WriteJSON(stream.Message{ + Type: stream.TypeSubscribe, + Channel: "agent", + }); err != nil { + t.Fatalf("WriteJSON(subscribe) error = %v", err) + } + + waitForChannelSubscriberCount(t, hub, "agent", 1) + _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + if err := conn.WriteJSON(stream.Message{ + Type: stream.TypeEvent, + Channel: "agent", + Data: map[string]any{"status": "ok"}, + Timestamp: time.Now().UTC(), + }); err != nil { + t.Fatalf("WriteJSON(event) error = %v", err) + } + + _, _, err := conn.ReadMessage() + if err == nil { + t.Fatal("ReadMessage() error = nil, want read timeout without self-echo") + } + if netErr, ok := err.(net.Error); !ok || !netErr.Timeout() { + t.Fatalf("ReadMessage() error = %v, want timeout", err) + } + _ = conn.SetReadDeadline(time.Time{}) +} + func dialWebSocket(t *testing.T, serverURL string, header http.Header) *websocket.Conn { t.Helper() conn, resp, err := websocket.DefaultDialer.Dial(websocketURL(serverURL), header) diff --git a/hub.go b/hub.go index fd57392..7155cbc 100644 --- a/hub.go +++ b/hub.go @@ -127,7 +127,7 @@ func (h *Hub) Run(ctx context.Context) { case peer := <-h.unregister: h.removePeer(peer) case item := <-h.broadcast: - h.broadcastToPeers(item.frame, item.notifyBroadcastSubscribers) + h.broadcastToPeers(item.source, item.frame, item.notifyBroadcastSubscribers) case item := <-h.deliver: h.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) } @@ -142,6 +142,13 @@ func (h *Hub) SendToChannel(channel string, frame []byte) error { return h.sendToChannel(channel, frame, true) } +// PublishFromPeer delivers a channel frame while excluding the source peer from fan-out. +// +// _ = hub.PublishFromPeer(peer, "block", frame) +func (h *Hub) PublishFromPeer(source *Peer, channel string, frame []byte) error { + return h.sendToChannelFromPeer(source, channel, frame, true) +} + // PublishFromBridge delivers frame to subscribers without notifying publish hooks. // // _ = hub.PublishFromBridge("block", frame) @@ -150,12 +157,16 @@ func (h *Hub) PublishFromBridge(channel string, frame []byte) error { } func (h *Hub) sendToChannel(channel string, frame []byte, notifyPublishSubscribers bool) error { + return h.sendToChannelFromPeer(nil, channel, frame, notifyPublishSubscribers) +} + +func (h *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte, notifyPublishSubscribers bool) error { if h == nil { return core.E("stream.hub", "nil hub", nil) } h.mu.RLock() running := h.running - peersToSend := h.collectChannelPeersLocked(channel) + peersToSend := h.collectChannelPeersLocked(channel, source) hasHandlers := len(h.handlers[channel]) > 0 hasWildcardHandlers := len(h.handlers["*"]) > 0 && channel != "*" hasPublishers := notifyPublishSubscribers && len(h.publishers) > 0 @@ -294,6 +305,13 @@ func (h *Hub) Broadcast(frame []byte) error { return h.broadcastFrame(frame, true) } +// BroadcastFromPeer delivers a broadcast frame while excluding the source peer from fan-out. +// +// _ = hub.BroadcastFromPeer(peer, []byte("shutdown")) +func (h *Hub) BroadcastFromPeer(source *Peer, frame []byte) error { + return h.broadcastFrameFromPeer(source, frame, true) +} + // BroadcastFromBridge delivers frame to peers without notifying broadcast hooks. // // _ = hub.BroadcastFromBridge([]byte("shutdown")) @@ -302,6 +320,10 @@ func (h *Hub) BroadcastFromBridge(frame []byte) error { } func (h *Hub) broadcastFrame(frame []byte, notifyBroadcastSubscribers bool) error { + return h.broadcastFrameFromPeer(nil, frame, notifyBroadcastSubscribers) +} + +func (h *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadcastSubscribers bool) error { if h == nil { return core.E("stream.hub", "nil hub", nil) } @@ -313,12 +335,13 @@ func (h *Hub) broadcastFrame(frame []byte, notifyBroadcastSubscribers bool) erro } select { case h.broadcast <- broadcastDelivery{ + source: source, frame: append([]byte(nil), frame...), notifyBroadcastSubscribers: notifyBroadcastSubscribers, }: return nil default: - h.broadcastToPeers(frame, notifyBroadcastSubscribers) + h.broadcastToPeers(source, frame, notifyBroadcastSubscribers) } return nil } @@ -608,13 +631,16 @@ func (h *Hub) removePeer(peer *Peer) { } } -func (h *Hub) broadcastToPeers(frame []byte, notifyBroadcastSubscribers bool) { +func (h *Hub) broadcastToPeers(source *Peer, frame []byte, notifyBroadcastSubscribers bool) { if h == nil { return } h.mu.RLock() peers := make([]*Peer, 0, len(h.peers)) for peer := range h.peers { + if peer == source { + continue + } peers = append(peers, peer) } handlers := cloneHandlers(h.handlers["*"]) @@ -636,6 +662,7 @@ type delivery struct { } type broadcastDelivery struct { + source *Peer frame []byte notifyBroadcastSubscribers bool } @@ -717,13 +744,19 @@ func (h *Hub) invokePublishHandlers(handlers []func(string, []byte), channel str } } -func (h *Hub) collectChannelPeersLocked(channel string) []*Peer { +func (h *Hub) collectChannelPeersLocked(channel string, source *Peer) []*Peer { combined := map[*Peer]struct{}{} for peer := range h.channels[channel] { + if peer == source { + continue + } combined[peer] = struct{}{} } if channel != "*" { for peer := range h.channels["*"] { + if peer == source { + continue + } combined[peer] = struct{}{} } } From caa3380db11e6690bc62b63e8a7c14cc647a0387 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:02:36 +0000 Subject: [PATCH 023/140] fix(stream): honour peer transport shutdown Co-Authored-By: Virgil --- adapter/sse/sse.go | 12 +++- adapter/tcp/tcp.go | 6 ++ adapter/ws/ws.go | 3 + adapter/ws/ws_test.go | 51 +++++++++++++++ hub_test.go | 146 ++++++++++++++++++++++++++++++++++++++++++ stream.go | 27 ++++++-- 6 files changed, 240 insertions(+), 5 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index e956a7d..754b42a 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strconv" + "sync" "time" "dappco.re/go/stream" @@ -99,6 +100,13 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ peer := stream.NewPeer("sse") peer.UserID = result.UserID peer.Claims = result.Claims + done := make(chan struct{}) + var doneOnce sync.Once + peer.SetCloseHook(func() { + doneOnce.Do(func() { + close(done) + }) + }) _ = adapter.hub.AddPeer(peer) defer adapter.hub.RemovePeer(peer) @@ -115,11 +123,13 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ ticker := time.NewTicker(config.HeartbeatInterval) defer ticker.Stop() - done := r.Context().Done() + requestDone := r.Context().Done() for { select { case <-done: return + case <-requestDone: + return case frame, ok := <-peer.SendQueue(): if !ok { return diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index a499745..1aa2b62 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -117,6 +117,9 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer return nil, err } peer := stream.NewPeer("tcp") + peer.SetCloseHook(func() { + _ = conn.Close() + }) _ = hub.AddPeer(peer) _ = hub.SubscribePeer(peer, "*") go adapter.pipePeer(ctx, conn, peer, hub) @@ -180,6 +183,9 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre peer := stream.NewPeer("tcp") peer.UserID = result.UserID peer.Claims = result.Claims + peer.SetCloseHook(func() { + _ = conn.Close() + }) _ = hub.AddPeer(peer) _ = hub.SubscribePeer(peer, "*") defer hub.RemovePeer(peer) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index ea36ca0..503c149 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -127,6 +127,9 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe peer := stream.NewPeer("ws") peer.UserID = result.UserID peer.Claims = result.Claims + peer.SetCloseHook(func() { + _ = conn.Close() + }) _ = adapter.hub.AddPeer(peer) defer adapter.hub.RemovePeer(peer) for _, channel := range channels { diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 928516d..4c7b7f0 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -314,6 +314,57 @@ func TestAdapter_Handler_InboundPublish_NoSelfEcho_Good(t *testing.T) { _ = conn.SetReadDeadline(time.Time{}) } +func TestAdapter_Handler_PeerClose_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + var peer *stream.Peer + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + for candidate := range hub.AllPeers() { + peer = candidate + break + } + if peer != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if peer == nil { + t.Fatal("timed out waiting for websocket peer") + } + + peer.Close() + + readDone := make(chan error, 1) + go func() { + _, _, err := conn.ReadMessage() + readDone <- err + }() + + select { + case err := <-readDone: + if err == nil { + t.Fatal("ReadMessage() error = nil, want closed websocket") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for peer close to close websocket") + } + + waitForPeerCount(t, hub, 0) +} + func dialWebSocket(t *testing.T, serverURL string, header http.Header) *websocket.Conn { t.Helper() conn, resp, err := websocket.DefaultDialer.Dial(websocketURL(serverURL), header) diff --git a/hub_test.go b/hub_test.go index 250b3f2..c2d1af3 100644 --- a/hub_test.go +++ b/hub_test.go @@ -4,10 +4,97 @@ package stream import ( "context" + "sync" "testing" "time" ) +type testStream struct { + mu sync.Mutex + subscribers map[string]map[int]func([]byte) + nextID int + published []publishedFrame + broadcasts [][]byte +} + +type publishedFrame struct { + channel string + frame []byte +} + +func newTestStream() *testStream { + return &testStream{ + subscribers: map[string]map[int]func([]byte){}, + } +} + +func (streamValue *testStream) Publish(channel string, frame []byte) error { + streamValue.mu.Lock() + streamValue.published = append(streamValue.published, publishedFrame{ + channel: channel, + frame: append([]byte(nil), frame...), + }) + handlers := streamValue.cloneHandlersLocked(channel) + wildcardHandlers := streamValue.cloneHandlersLocked("*") + streamValue.mu.Unlock() + + for _, handler := range handlers { + handler(frame) + } + if channel != "*" { + for _, handler := range wildcardHandlers { + handler(frame) + } + } + return nil +} + +func (streamValue *testStream) Subscribe(channel string, handler func([]byte)) func() { + streamValue.mu.Lock() + defer streamValue.mu.Unlock() + streamValue.nextID++ + id := streamValue.nextID + if streamValue.subscribers[channel] == nil { + streamValue.subscribers[channel] = map[int]func([]byte){} + } + streamValue.subscribers[channel][id] = handler + return func() { + streamValue.mu.Lock() + defer streamValue.mu.Unlock() + delete(streamValue.subscribers[channel], id) + if len(streamValue.subscribers[channel]) == 0 { + delete(streamValue.subscribers, channel) + } + } +} + +func (streamValue *testStream) Broadcast(frame []byte) error { + streamValue.mu.Lock() + defer streamValue.mu.Unlock() + streamValue.broadcasts = append(streamValue.broadcasts, append([]byte(nil), frame...)) + return nil +} + +func (streamValue *testStream) Pipe(dst Stream) func() { + return Pipe(streamValue, dst) +} + +func (streamValue *testStream) Stats() HubStats { + return HubStats{} +} + +func (streamValue *testStream) cloneHandlersLocked(channel string) []func([]byte) { + handlers := streamValue.subscribers[channel] + if len(handlers) == 0 { + return nil + } + cloned := make([]func([]byte), 0, len(handlers)) + for _, handler := range handlers { + cloned = append(cloned, handler) + } + return cloned +} + func TestHub_Pipe_Good(t *testing.T) { sourceHub := NewHub() destinationHub := NewHub() @@ -147,6 +234,33 @@ func TestHub_Pipe_Ugly(t *testing.T) { } } +func TestHub_Pipe_GenericPublishFallback_Good(t *testing.T) { + sourceStream := newTestStream() + destinationStream := newTestStream() + + stop := Pipe(sourceStream, destinationStream) + defer stop() + + if err := sourceStream.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + destinationStream.mu.Lock() + defer destinationStream.mu.Unlock() + if len(destinationStream.published) != 1 { + t.Fatalf("len(published) = %d, want %d", len(destinationStream.published), 1) + } + if destinationStream.published[0].channel != "*" { + t.Fatalf("published channel = %q, want %q", destinationStream.published[0].channel, "*") + } + if string(destinationStream.published[0].frame) != "123456" { + t.Fatalf("published frame = %q, want %q", string(destinationStream.published[0].frame), "123456") + } + if len(destinationStream.broadcasts) != 0 { + t.Fatalf("len(broadcasts) = %d, want %d", len(destinationStream.broadcasts), 0) + } +} + func TestHub_Publish_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) @@ -417,6 +531,38 @@ func TestHub_SendToChannel_Wildcard_Good(t *testing.T) { t.Fatalf("wildcard handler count = %d, want 1", count) } +func TestPeer_Close_Good(t *testing.T) { + peer := NewPeer("ws") + closed := make(chan struct{}, 1) + + peer.SetCloseHook(func() { + closed <- struct{}{} + }) + peer.Close() + peer.Close() + + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close hook") + } + + select { + case <-closed: + t.Fatal("close hook ran more than once") + case <-time.After(200 * time.Millisecond): + } + + select { + case _, ok := <-peer.SendQueue(): + if ok { + t.Fatal("SendQueue() channel still open after Close()") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for closed SendQueue()") + } +} + func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/stream.go b/stream.go index 767c2e1..9daa5bb 100644 --- a/stream.go +++ b/stream.go @@ -92,6 +92,7 @@ type Peer struct { send chan []byte subscriptions map[string]bool + closeHook func() mu sync.RWMutex closeOnce sync.Once } @@ -158,13 +159,31 @@ func (p *Peer) Close() { } p.closeOnce.Do(func() { p.mu.Lock() - defer p.mu.Unlock() - if p.send != nil { - close(p.send) + send := p.send + closeHook := p.closeHook + p.closeHook = nil + p.mu.Unlock() + if send != nil { + close(send) + } + if closeHook != nil { + closeHook() } }) } +// SetCloseHook installs the transport shutdown hook invoked by Close. +// +// peer.SetCloseHook(func() { _ = conn.Close() }) +func (p *Peer) SetCloseHook(closeHook func()) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.closeHook = closeHook +} + // SendQueue returns the peer's outgoing frame queue. // // for frame := range peer.SendQueue() { ... } @@ -222,7 +241,7 @@ func Pipe(src Stream, dst Stream) func() { } if len(stops) == 0 { return src.Subscribe("*", func(frame []byte) { - _ = dst.Broadcast(frame) + _ = dst.Publish("*", frame) }) } return func() { From a15dd0e1beaef8178318d6b9d31fbe60495ea881 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:07:11 +0000 Subject: [PATCH 024/140] refactor(stream): use semantic receiver names Co-Authored-By: Virgil --- hub.go | 450 +++++++++++++++++++++++++++--------------------------- stream.go | 60 ++++---- 2 files changed, 255 insertions(+), 255 deletions(-) diff --git a/hub.go b/hub.go index 7155cbc..bed02bf 100644 --- a/hub.go +++ b/hub.go @@ -72,49 +72,49 @@ func NewHubWithConfig(config HubConfig) *Hub { // Config returns a normalised copy of the hub configuration. // // cfg := hub.Config() -func (h *Hub) Config() HubConfig { - if h == nil { +func (hub *Hub) Config() HubConfig { + if hub == nil { return DefaultHubConfig() } - h.mu.RLock() - config := h.config - h.mu.RUnlock() + hub.mu.RLock() + config := hub.config + hub.mu.RUnlock() return normalizeHubConfig(config) } // Run starts the hub's select loop. Call in a goroutine. Exits when ctx is cancelled. // // go hub.Run(ctx) -func (h *Hub) Run(ctx context.Context) { - if h == nil { +func (hub *Hub) Run(ctx context.Context) { + if hub == nil { return } if ctx == nil { ctx = context.Background() } - h.mu.Lock() - if h.running { - h.mu.Unlock() + hub.mu.Lock() + if hub.running { + hub.mu.Unlock() return } - h.running = true - h.mu.Unlock() + hub.running = true + hub.mu.Unlock() defer func() { - h.mu.Lock() - peers := make([]*Peer, 0, len(h.peers)) - for peer := range h.peers { + hub.mu.Lock() + peers := make([]*Peer, 0, len(hub.peers)) + for peer := range hub.peers { peers = append(peers, peer) } - h.running = false - h.mu.Unlock() + hub.running = false + hub.mu.Unlock() for _, peer := range peers { - h.removePeer(peer) + hub.removePeer(peer) } - h.doneOnce.Do(func() { - close(h.done) + hub.doneOnce.Do(func() { + close(hub.done) }) }() @@ -122,14 +122,14 @@ func (h *Hub) Run(ctx context.Context) { select { case <-ctx.Done(): return - case peer := <-h.register: - h.addPeer(peer) - case peer := <-h.unregister: - h.removePeer(peer) - case item := <-h.broadcast: - h.broadcastToPeers(item.source, item.frame, item.notifyBroadcastSubscribers) - case item := <-h.deliver: - h.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) + case peer := <-hub.register: + hub.addPeer(peer) + case peer := <-hub.unregister: + hub.removePeer(peer) + case item := <-hub.broadcast: + hub.broadcastToPeers(item.source, item.frame, item.notifyBroadcastSubscribers) + case item := <-hub.deliver: + hub.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) } } } @@ -138,39 +138,39 @@ func (h *Hub) Run(ctx context.Context) { // Returns nil if channel has no subscribers (not an error). // // hub.SendToChannel("process:abc123", frame) -func (h *Hub) SendToChannel(channel string, frame []byte) error { - return h.sendToChannel(channel, frame, true) +func (hub *Hub) SendToChannel(channel string, frame []byte) error { + return hub.sendToChannel(channel, frame, true) } // PublishFromPeer delivers a channel frame while excluding the source peer from fan-out. // // _ = hub.PublishFromPeer(peer, "block", frame) -func (h *Hub) PublishFromPeer(source *Peer, channel string, frame []byte) error { - return h.sendToChannelFromPeer(source, channel, frame, true) +func (hub *Hub) PublishFromPeer(source *Peer, channel string, frame []byte) error { + return hub.sendToChannelFromPeer(source, channel, frame, true) } // PublishFromBridge delivers frame to subscribers without notifying publish hooks. // // _ = hub.PublishFromBridge("block", frame) -func (h *Hub) PublishFromBridge(channel string, frame []byte) error { - return h.sendToChannel(channel, frame, false) +func (hub *Hub) PublishFromBridge(channel string, frame []byte) error { + return hub.sendToChannel(channel, frame, false) } -func (h *Hub) sendToChannel(channel string, frame []byte, notifyPublishSubscribers bool) error { - return h.sendToChannelFromPeer(nil, channel, frame, notifyPublishSubscribers) +func (hub *Hub) sendToChannel(channel string, frame []byte, notifyPublishSubscribers bool) error { + return hub.sendToChannelFromPeer(nil, channel, frame, notifyPublishSubscribers) } -func (h *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte, notifyPublishSubscribers bool) error { - if h == nil { +func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte, notifyPublishSubscribers bool) error { + if hub == nil { return core.E("stream.hub", "nil hub", nil) } - h.mu.RLock() - running := h.running - peersToSend := h.collectChannelPeersLocked(channel, source) - hasHandlers := len(h.handlers[channel]) > 0 - hasWildcardHandlers := len(h.handlers["*"]) > 0 && channel != "*" - hasPublishers := notifyPublishSubscribers && len(h.publishers) > 0 - h.mu.RUnlock() + hub.mu.RLock() + running := hub.running + peersToSend := hub.collectChannelPeersLocked(channel, source) + hasHandlers := len(hub.handlers[channel]) > 0 + hasWildcardHandlers := len(hub.handlers["*"]) > 0 && channel != "*" + hasPublishers := notifyPublishSubscribers && len(hub.publishers) > 0 + hub.mu.RUnlock() if !running { return ErrHubNotRunning } @@ -178,9 +178,9 @@ func (h *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte, return nil } for _, peer := range peersToSend { - h.sendToPeer(peer, channel, frame) + hub.sendToPeer(peer, channel, frame) } - h.enqueueDelivery(channel, frame, notifyPublishSubscribers) + hub.enqueueDelivery(channel, frame, notifyPublishSubscribers) return nil } @@ -191,8 +191,8 @@ func (h *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte, // unsub, err := hub.SubscribeE("block", func(f []byte) { ... }) // if err != nil { return err } // defer unsub() -func (h *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { - if h == nil { +func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { + if hub == nil { return func() {}, core.E("stream.hub", "nil hub", nil) } if channel == "" { @@ -201,28 +201,28 @@ func (h *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { if handler == nil { return func() {}, core.E("stream.hub", "nil handler", nil) } - h.mu.Lock() - if h.handlers == nil { - h.handlers = map[string]map[uint64]func([]byte){} + hub.mu.Lock() + if hub.handlers == nil { + hub.handlers = map[string]map[uint64]func([]byte){} } - if h.channels == nil { - h.channels = map[string]map[*Peer]bool{} + if hub.channels == nil { + hub.channels = map[string]map[*Peer]bool{} } - h.nextID++ - id := h.nextID - if h.handlers[channel] == nil { - h.handlers[channel] = map[uint64]func([]byte){} + hub.nextID++ + id := hub.nextID + if hub.handlers[channel] == nil { + hub.handlers[channel] = map[uint64]func([]byte){} } - h.handlers[channel][id] = handler - h.mu.Unlock() + hub.handlers[channel][id] = handler + hub.mu.Unlock() return func() { - h.mu.Lock() - defer h.mu.Unlock() - if handlers := h.handlers[channel]; handlers != nil { + hub.mu.Lock() + defer hub.mu.Unlock() + if handlers := hub.handlers[channel]; handlers != nil { delete(handlers, id) if len(handlers) == 0 { - delete(h.handlers, channel) + delete(hub.handlers, channel) } } }, nil @@ -234,8 +234,8 @@ func (h *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { // // unsub := hub.Subscribe("block", func(f []byte) { ... }) // defer unsub() -func (h *Hub) Subscribe(channel string, handler func([]byte)) func() { - unsub, _ := h.SubscribeE(channel, handler) +func (hub *Hub) Subscribe(channel string, handler func([]byte)) func() { + unsub, _ := hub.SubscribeE(channel, handler) return unsub } @@ -243,8 +243,8 @@ func (h *Hub) Subscribe(channel string, handler func([]byte)) func() { // a peer requests channel subscription (WebSocket TypeSubscribe message, etc.). // // hub.SubscribePeer(peer, "hashrate") -func (h *Hub) SubscribePeer(peer *Peer, channel string) error { - if h == nil { +func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { + if hub == nil { return core.E("stream.hub", "nil hub", nil) } if peer == nil { @@ -253,9 +253,9 @@ func (h *Hub) SubscribePeer(peer *Peer, channel string) error { if channel == "" { return ErrEmptyChannel } - h.mu.Lock() - defer h.mu.Unlock() - if h.config.ChannelAuthoriser != nil && channel != "*" && !h.config.ChannelAuthoriser(peer, channel) { + hub.mu.Lock() + defer hub.mu.Unlock() + if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } if peer.send == nil { @@ -265,27 +265,27 @@ func (h *Hub) SubscribePeer(peer *Peer, channel string) error { peer.subscriptions = map[string]bool{} } peer.subscriptions[channel] = true - if h.channels[channel] == nil { - h.channels[channel] = map[*Peer]bool{} + if hub.channels[channel] == nil { + hub.channels[channel] = map[*Peer]bool{} } - h.channels[channel][peer] = true + hub.channels[channel][peer] = true return nil } // UnsubscribePeer removes peer from a named channel. // // hub.UnsubscribePeer(peer, "hashrate") -func (h *Hub) UnsubscribePeer(peer *Peer, channel string) { - if h == nil || peer == nil || channel == "" { +func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { + if hub == nil || peer == nil || channel == "" { return } - h.mu.Lock() - defer h.mu.Unlock() + hub.mu.Lock() + defer hub.mu.Unlock() delete(peer.subscriptions, channel) - if peers := h.channels[channel]; peers != nil { + if peers := hub.channels[channel]; peers != nil { delete(peers, peer) if len(peers) == 0 { - delete(h.channels, channel) + delete(hub.channels, channel) } } } @@ -293,55 +293,55 @@ func (h *Hub) UnsubscribePeer(peer *Peer, channel string) { // Publish sends frame to all subscribers of channel. Satisfies Stream interface. // // hub.Publish("hashrate", frame) -func (h *Hub) Publish(channel string, frame []byte) error { - return h.sendToChannel(channel, frame, true) +func (hub *Hub) Publish(channel string, frame []byte) error { + return hub.sendToChannel(channel, frame, true) } // Broadcast sends frame to every connected peer regardless of subscriptions. // Satisfies Stream interface. // // hub.Broadcast([]byte(`{"type":"shutdown"}`)) -func (h *Hub) Broadcast(frame []byte) error { - return h.broadcastFrame(frame, true) +func (hub *Hub) Broadcast(frame []byte) error { + return hub.broadcastFrame(frame, true) } // BroadcastFromPeer delivers a broadcast frame while excluding the source peer from fan-out. // // _ = hub.BroadcastFromPeer(peer, []byte("shutdown")) -func (h *Hub) BroadcastFromPeer(source *Peer, frame []byte) error { - return h.broadcastFrameFromPeer(source, frame, true) +func (hub *Hub) BroadcastFromPeer(source *Peer, frame []byte) error { + return hub.broadcastFrameFromPeer(source, frame, true) } // BroadcastFromBridge delivers frame to peers without notifying broadcast hooks. // // _ = hub.BroadcastFromBridge([]byte("shutdown")) -func (h *Hub) BroadcastFromBridge(frame []byte) error { - return h.broadcastFrame(frame, false) +func (hub *Hub) BroadcastFromBridge(frame []byte) error { + return hub.broadcastFrame(frame, false) } -func (h *Hub) broadcastFrame(frame []byte, notifyBroadcastSubscribers bool) error { - return h.broadcastFrameFromPeer(nil, frame, notifyBroadcastSubscribers) +func (hub *Hub) broadcastFrame(frame []byte, notifyBroadcastSubscribers bool) error { + return hub.broadcastFrameFromPeer(nil, frame, notifyBroadcastSubscribers) } -func (h *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadcastSubscribers bool) error { - if h == nil { +func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadcastSubscribers bool) error { + if hub == nil { return core.E("stream.hub", "nil hub", nil) } - h.mu.RLock() - running := h.running - h.mu.RUnlock() + hub.mu.RLock() + running := hub.running + hub.mu.RUnlock() if !running { return ErrHubNotRunning } select { - case h.broadcast <- broadcastDelivery{ + case hub.broadcast <- broadcastDelivery{ source: source, frame: append([]byte(nil), frame...), notifyBroadcastSubscribers: notifyBroadcastSubscribers, }: return nil default: - h.broadcastToPeers(source, frame, notifyBroadcastSubscribers) + hub.broadcastToPeers(source, frame, notifyBroadcastSubscribers) } return nil } @@ -351,29 +351,29 @@ func (h *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadcast // // stop := hub.Pipe(remoteHub) // defer stop() -func (h *Hub) Pipe(dst Stream) func() { - return Pipe(h, dst) +func (hub *Hub) Pipe(dst Stream) func() { + return Pipe(hub, dst) } // Stats returns a snapshot of current hub state. // // s := hub.Stats() // core.Print("stream", "peers=%d channels=%d", s.Peers, s.Channels) -func (h *Hub) Stats() HubStats { - if h == nil { +func (hub *Hub) Stats() HubStats { + if hub == nil { return HubStats{} } - h.mu.RLock() - defer h.mu.RUnlock() + hub.mu.RLock() + defer hub.mu.RUnlock() subscriberCount := map[string]int{} - for channel, peers := range h.channels { + for channel, peers := range hub.channels { if channel == "*" { continue } subscriberCount[channel] = len(peers) } return HubStats{ - Peers: len(h.peers), + Peers: len(hub.peers), Channels: len(subscriberCount), SubscriberCount: subscriberCount, } @@ -382,56 +382,56 @@ func (h *Hub) Stats() HubStats { // SubscribePublished registers a handler invoked for each published channel frame. // // _ = hub.SubscribePublished(func(channel string, frame []byte) { ... }) -func (h *Hub) SubscribePublished(handler func(string, []byte)) func() { - return h.subscribePublished(handler) +func (hub *Hub) SubscribePublished(handler func(string, []byte)) func() { + return hub.subscribePublished(handler) } // SubscribeBroadcast registers a handler invoked for each broadcast frame. // // _ = hub.SubscribeBroadcast(func(frame []byte) { ... }) -func (h *Hub) SubscribeBroadcast(handler func([]byte)) func() { - if h == nil || handler == nil { +func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { + if hub == nil || handler == nil { return func() {} } - h.mu.Lock() - if h.broadcastHandlers == nil { - h.broadcastHandlers = map[uint64]func([]byte){} + hub.mu.Lock() + if hub.broadcastHandlers == nil { + hub.broadcastHandlers = map[uint64]func([]byte){} } - h.nextID++ - id := h.nextID - h.broadcastHandlers[id] = handler - h.mu.Unlock() + hub.nextID++ + id := hub.nextID + hub.broadcastHandlers[id] = handler + hub.mu.Unlock() return func() { - h.mu.Lock() - defer h.mu.Unlock() - delete(h.broadcastHandlers, id) + hub.mu.Lock() + defer hub.mu.Unlock() + delete(hub.broadcastHandlers, id) } } // PeerCount returns the number of connected peers. // // n := hub.PeerCount() -func (h *Hub) PeerCount() int { - if h == nil { +func (hub *Hub) PeerCount() int { + if hub == nil { return 0 } - h.mu.RLock() - defer h.mu.RUnlock() - return len(h.peers) + hub.mu.RLock() + defer hub.mu.RUnlock() + return len(hub.peers) } // ChannelCount returns the number of active channels. // // n := hub.ChannelCount() -func (h *Hub) ChannelCount() int { - if h == nil { +func (hub *Hub) ChannelCount() int { + if hub == nil { return 0 } - h.mu.RLock() - defer h.mu.RUnlock() + hub.mu.RLock() + defer hub.mu.RUnlock() count := 0 - for channel, peers := range h.channels { + for channel, peers := range hub.channels { if channel == "*" || len(peers) == 0 { continue } @@ -444,28 +444,28 @@ func (h *Hub) ChannelCount() int { // Returns 0 if the channel has no subscribers. // // n := hub.ChannelSubscriberCount("hashrate") -func (h *Hub) ChannelSubscriberCount(channel string) int { - if h == nil { +func (hub *Hub) ChannelSubscriberCount(channel string) int { + if hub == nil { return 0 } - h.mu.RLock() - defer h.mu.RUnlock() - return len(h.channels[channel]) + hub.mu.RLock() + defer hub.mu.RUnlock() + return len(hub.channels[channel]) } // AllPeers returns an iterator for all connected peers. // // for peer := range hub.AllPeers() { log.Println(peer.UserID) } -func (h *Hub) AllPeers() iter.Seq[*Peer] { - if h == nil { +func (hub *Hub) AllPeers() iter.Seq[*Peer] { + if hub == nil { return func(yield func(*Peer) bool) {} } - h.mu.RLock() - peers := make([]*Peer, 0, len(h.peers)) - for peer := range h.peers { + hub.mu.RLock() + peers := make([]*Peer, 0, len(hub.peers)) + for peer := range hub.peers { peers = append(peers, peer) } - h.mu.RUnlock() + hub.mu.RUnlock() return func(yield func(*Peer) bool) { for _, peer := range peers { if !yield(peer) { @@ -478,19 +478,19 @@ func (h *Hub) AllPeers() iter.Seq[*Peer] { // AllChannels returns an iterator for all active channels. // // for ch := range hub.AllChannels() { log.Println(ch) } -func (h *Hub) AllChannels() iter.Seq[string] { - if h == nil { +func (hub *Hub) AllChannels() iter.Seq[string] { + if hub == nil { return func(yield func(string) bool) {} } - h.mu.RLock() - channels := make([]string, 0, len(h.channels)) - for channel, peers := range h.channels { + hub.mu.RLock() + channels := make([]string, 0, len(hub.channels)) + for channel, peers := range hub.channels { if channel == "*" || len(peers) == 0 { continue } channels = append(channels, channel) } - h.mu.RUnlock() + hub.mu.RUnlock() sort.Strings(channels) return func(yield func(string) bool) { for _, channel := range channels { @@ -504,8 +504,8 @@ func (h *Hub) AllChannels() iter.Seq[string] { // AddPeer registers a peer with the hub and invokes OnConnect. // // hub.AddPeer(stream.NewPeer("ws")) -func (h *Hub) AddPeer(peer *Peer) error { - if h == nil { +func (hub *Hub) AddPeer(peer *Peer) error { + if hub == nil { return core.E("stream.hub", "nil hub", nil) } if peer == nil { @@ -517,41 +517,41 @@ func (h *Hub) AddPeer(peer *Peer) error { if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} } - h.mu.RLock() - running := h.running - h.mu.RUnlock() + hub.mu.RLock() + running := hub.running + hub.mu.RUnlock() if running { select { - case h.register <- peer: + case hub.register <- peer: return nil default: } } - h.addPeer(peer) + hub.addPeer(peer) return nil } // RemovePeer unregisters a peer from the hub and invokes OnDisconnect. // // hub.RemovePeer(peer) -func (h *Hub) RemovePeer(peer *Peer) { - if h == nil || peer == nil { +func (hub *Hub) RemovePeer(peer *Peer) { + if hub == nil || peer == nil { return } - h.mu.RLock() - running := h.running - h.mu.RUnlock() + hub.mu.RLock() + running := hub.running + hub.mu.RUnlock() if running { select { - case h.unregister <- peer: + case hub.unregister <- peer: return default: } } - h.removePeer(peer) + hub.removePeer(peer) } -func (h *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { +func (hub *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { if peer == nil { return } @@ -562,7 +562,7 @@ func (h *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { _ = peer.Send(frame) } -func (h *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { +func (hub *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { if peer == nil { return } @@ -573,7 +573,7 @@ func (h *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { _ = peer.Send(frame) } -func (h *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { +func (hub *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { func(fn func([]byte)) { defer func() { @@ -584,74 +584,74 @@ func (h *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { } } -func (h *Hub) addPeer(peer *Peer) { - if h == nil || peer == nil { +func (hub *Hub) addPeer(peer *Peer) { + if hub == nil || peer == nil { return } - h.mu.Lock() - if h.peers == nil { - h.peers = map[*Peer]bool{} + hub.mu.Lock() + if hub.peers == nil { + hub.peers = map[*Peer]bool{} } - if h.peers[peer] { - h.mu.Unlock() + if hub.peers[peer] { + hub.mu.Unlock() return } - h.peers[peer] = true - onConnect := h.config.OnConnect - h.mu.Unlock() + hub.peers[peer] = true + onConnect := hub.config.OnConnect + hub.mu.Unlock() if onConnect != nil { onConnect(peer) } } -func (h *Hub) removePeer(peer *Peer) { - if h == nil || peer == nil { +func (hub *Hub) removePeer(peer *Peer) { + if hub == nil || peer == nil { return } - h.mu.Lock() - if !h.peers[peer] { - h.mu.Unlock() + hub.mu.Lock() + if !hub.peers[peer] { + hub.mu.Unlock() return } - delete(h.peers, peer) - for channel, peers := range h.channels { + delete(hub.peers, peer) + for channel, peers := range hub.channels { delete(peers, peer) if len(peers) == 0 { - delete(h.channels, channel) + delete(hub.channels, channel) } } peer.mu.Lock() peer.subscriptions = map[string]bool{} peer.mu.Unlock() - onDisconnect := h.config.OnDisconnect - h.mu.Unlock() + onDisconnect := hub.config.OnDisconnect + hub.mu.Unlock() peer.Close() if onDisconnect != nil { onDisconnect(peer) } } -func (h *Hub) broadcastToPeers(source *Peer, frame []byte, notifyBroadcastSubscribers bool) { - if h == nil { +func (hub *Hub) broadcastToPeers(source *Peer, frame []byte, notifyBroadcastSubscribers bool) { + if hub == nil { return } - h.mu.RLock() - peers := make([]*Peer, 0, len(h.peers)) - for peer := range h.peers { + hub.mu.RLock() + peers := make([]*Peer, 0, len(hub.peers)) + for peer := range hub.peers { if peer == source { continue } peers = append(peers, peer) } - handlers := cloneHandlers(h.handlers["*"]) - broadcastHandlers := cloneBroadcastHandlers(h.broadcastHandlers) - h.mu.RUnlock() + handlers := cloneHandlers(hub.handlers["*"]) + broadcastHandlers := cloneBroadcastHandlers(hub.broadcastHandlers) + hub.mu.RUnlock() for _, peer := range peers { - h.sendBroadcastToPeer(peer, frame) + hub.sendBroadcastToPeer(peer, frame) } - h.invokeHandlers(handlers, frame) + hub.invokeHandlers(handlers, frame) if notifyBroadcastSubscribers { - h.invokeBroadcastHandlers(broadcastHandlers, frame) + hub.invokeBroadcastHandlers(broadcastHandlers, frame) } } @@ -667,8 +667,8 @@ type broadcastDelivery struct { notifyBroadcastSubscribers bool } -func (h *Hub) enqueueDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { - if h == nil { +func (hub *Hub) enqueueDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { + if hub == nil { return } item := delivery{ @@ -677,52 +677,52 @@ func (h *Hub) enqueueDelivery(channel string, frame []byte, notifyPublishSubscri notifyPublishSubscribers: notifyPublishSubscribers, } select { - case h.deliver <- item: + case hub.deliver <- item: default: - h.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) + hub.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) } } -func (h *Hub) processDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { - if h == nil { +func (hub *Hub) processDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { + if hub == nil { return } - h.mu.RLock() - handlers := cloneHandlers(h.handlers[channel]) - wildcardHandlers := cloneHandlers(h.handlers["*"]) - publishers := clonePublishHandlers(h.publishers) - h.mu.RUnlock() + hub.mu.RLock() + handlers := cloneHandlers(hub.handlers[channel]) + wildcardHandlers := cloneHandlers(hub.handlers["*"]) + publishers := clonePublishHandlers(hub.publishers) + hub.mu.RUnlock() - h.invokeHandlers(handlers, frame) + hub.invokeHandlers(handlers, frame) if channel != "*" { - h.invokeHandlers(wildcardHandlers, frame) + hub.invokeHandlers(wildcardHandlers, frame) } if notifyPublishSubscribers { - h.invokePublishHandlers(publishers, channel, frame) + hub.invokePublishHandlers(publishers, channel, frame) } } -func (h *Hub) subscribePublished(handler func(string, []byte)) func() { - if h == nil || handler == nil { +func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { + if hub == nil || handler == nil { return func() {} } - h.mu.Lock() - if h.publishers == nil { - h.publishers = map[uint64]func(string, []byte){} + hub.mu.Lock() + if hub.publishers == nil { + hub.publishers = map[uint64]func(string, []byte){} } - h.nextID++ - id := h.nextID - h.publishers[id] = handler - h.mu.Unlock() + hub.nextID++ + id := hub.nextID + hub.publishers[id] = handler + hub.mu.Unlock() return func() { - h.mu.Lock() - defer h.mu.Unlock() - delete(h.publishers, id) + hub.mu.Lock() + defer hub.mu.Unlock() + delete(hub.publishers, id) } } -func (h *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { +func (hub *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { func(fn func([]byte)) { defer func() { @@ -733,7 +733,7 @@ func (h *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { } } -func (h *Hub) invokePublishHandlers(handlers []func(string, []byte), channel string, frame []byte) { +func (hub *Hub) invokePublishHandlers(handlers []func(string, []byte), channel string, frame []byte) { for _, handler := range handlers { func(fn func(string, []byte)) { defer func() { @@ -744,16 +744,16 @@ func (h *Hub) invokePublishHandlers(handlers []func(string, []byte), channel str } } -func (h *Hub) collectChannelPeersLocked(channel string, source *Peer) []*Peer { +func (hub *Hub) collectChannelPeersLocked(channel string, source *Peer) []*Peer { combined := map[*Peer]struct{}{} - for peer := range h.channels[channel] { + for peer := range hub.channels[channel] { if peer == source { continue } combined[peer] = struct{}{} } if channel != "*" { - for peer := range h.channels["*"] { + for peer := range hub.channels["*"] { if peer == source { continue } diff --git a/stream.go b/stream.go index 9daa5bb..e5f4e13 100644 --- a/stream.go +++ b/stream.go @@ -112,14 +112,14 @@ func NewPeer(transport string) *Peer { // Subscriptions returns a copy of this peer's current channel subscriptions. // // channels := peer.Subscriptions() // ["hashrate", "block"] -func (p *Peer) Subscriptions() []string { - if p == nil { +func (peer *Peer) Subscriptions() []string { + if peer == nil { return nil } - p.mu.RLock() - defer p.mu.RUnlock() - channels := make([]string, 0, len(p.subscriptions)) - for channel := range p.subscriptions { + peer.mu.RLock() + defer peer.mu.RUnlock() + channels := make([]string, 0, len(peer.subscriptions)) + for channel := range peer.subscriptions { channels = append(channels, channel) } sort.Strings(channels) @@ -129,21 +129,21 @@ func (p *Peer) Subscriptions() []string { // Send enqueues frame for delivery. Non-blocking: drops and returns false if buffer full. // // ok := peer.Send(frame) -func (p *Peer) Send(frame []byte) bool { - if p == nil { +func (peer *Peer) Send(frame []byte) bool { + if peer == nil { return false } defer func() { _ = recover() }() - p.mu.RLock() - defer p.mu.RUnlock() - if p.send == nil { + peer.mu.RLock() + defer peer.mu.RUnlock() + if peer.send == nil { return false } payload := append([]byte(nil), frame...) select { - case p.send <- payload: + case peer.send <- payload: return true default: return false @@ -153,16 +153,16 @@ func (p *Peer) Send(frame []byte) bool { // Close signals the transport adapter to shut down this connection. // // peer.Close() -func (p *Peer) Close() { - if p == nil { +func (peer *Peer) Close() { + if peer == nil { return } - p.closeOnce.Do(func() { - p.mu.Lock() - send := p.send - closeHook := p.closeHook - p.closeHook = nil - p.mu.Unlock() + peer.closeOnce.Do(func() { + peer.mu.Lock() + send := peer.send + closeHook := peer.closeHook + peer.closeHook = nil + peer.mu.Unlock() if send != nil { close(send) } @@ -175,25 +175,25 @@ func (p *Peer) Close() { // SetCloseHook installs the transport shutdown hook invoked by Close. // // peer.SetCloseHook(func() { _ = conn.Close() }) -func (p *Peer) SetCloseHook(closeHook func()) { - if p == nil { +func (peer *Peer) SetCloseHook(closeHook func()) { + if peer == nil { return } - p.mu.Lock() - defer p.mu.Unlock() - p.closeHook = closeHook + peer.mu.Lock() + defer peer.mu.Unlock() + peer.closeHook = closeHook } // SendQueue returns the peer's outgoing frame queue. // // for frame := range peer.SendQueue() { ... } -func (p *Peer) SendQueue() <-chan []byte { - if p == nil { +func (peer *Peer) SendQueue() <-chan []byte { + if peer == nil { return nil } - p.mu.RLock() - defer p.mu.RUnlock() - return p.send + peer.mu.RLock() + defer peer.mu.RUnlock() + return peer.send } // ConnectionState represents the lifecycle state of a reconnecting client. From ce4a7c6bd08735a06cc84936de967291d43dff6f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:10:41 +0000 Subject: [PATCH 025/140] refactor(stream): add AX-oriented usage examples Co-Authored-By: Virgil --- adapter/redis/redis.go | 16 ++++++++++++--- adapter/sse/sse.go | 10 +++++++--- adapter/tcp/reconnect.go | 10 +++++++++- adapter/tcp/tcp.go | 12 +++++++++-- adapter/ws/reconnect.go | 10 +++++++++- adapter/ws/ws.go | 9 ++++++--- adapter/zmq/zmq.go | 10 +++++++++- auth.go | 43 ++++++++++++++++++++++++++++------------ 8 files changed, 93 insertions(+), 27 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 8ae195f..ece3af6 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -46,7 +46,9 @@ type envelope struct { Frame []byte `json:"f"` } -// NewBridge creates and validates the Redis connection. Does not start listening. +// NewBridge creates and validates the Redis connection. +// +// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { if hub == nil { return nil, core.E("stream.redis", "nil hub", nil) @@ -73,7 +75,9 @@ func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { }, nil } -// Start begins the Redis pub/sub listener. Blocks in a goroutine until Stop() or ctx cancel. +// Start begins the Redis pub/sub listener. +// +// go bridge.Start(ctx) func (bridge *Bridge) Start(ctx context.Context) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -159,7 +163,9 @@ func (bridge *Bridge) Start(ctx context.Context) error { } } -// Stop cleanly shuts down the bridge. Closes the pub/sub subscription and Redis client. +// Stop cleanly shuts down the bridge. +// +// defer bridge.Stop() func (bridge *Bridge) Stop() error { if bridge == nil { return nil @@ -197,6 +203,8 @@ func (bridge *Bridge) Stop() error { } // PublishToChannel publishes frame to a specific hub channel via Redis. +// +// _ = bridge.PublishToChannel("block", templateBytes) func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -209,6 +217,8 @@ func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { } // PublishBroadcast publishes frame as a broadcast via Redis. +// +// _ = bridge.PublishBroadcast(shutdownFrame) func (bridge *Bridge) PublishBroadcast(frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 754b42a..8c7cd14 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -28,7 +28,7 @@ type Adapter struct { config Config } -// New creates an SSE adapter. Call Mount before serving requests. +// New creates an SSE adapter. func New(config Config) *Adapter { if config.HeartbeatInterval == 0 { config.HeartbeatInterval = 15 * time.Second @@ -39,24 +39,28 @@ func New(config Config) *Adapter { return &Adapter{config: config} } -// Mount wires the adapter to a hub. Must be called before Handler(). +// Mount wires the adapter to a hub. func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } // ServeHTTP accepts an SSE connection and subscribes it using the channel query params. // -// http.Handle("/stream/events", adapter) +// http.Handle("/stream/events", adapter.Handler()) func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { adapter.serve(w, r, r.URL.Query()["channel"]) } // Handler returns an http.HandlerFunc that accepts SSE connections. +// +// http.Handle("/stream/events", adapter.Handler()) func (adapter *Adapter) Handler() http.HandlerFunc { return adapter.ServeHTTP } // HandlerForChannel returns a handler that auto-subscribes all connections to channel. +// +// http.Handle("/stream/hashrate", adapter.HandlerForChannel("hashrate")) func (adapter *Adapter) HandlerForChannel(channel string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { adapter.serve(w, r, []string{channel}) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index dd87049..7f77aeb 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -26,6 +26,8 @@ type ReconnectConfig struct { } // ReconnectingTCP connects to a TCP stream endpoint with automatic reconnection. +// +// client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{Addr: "10.69.69.165:9000"}) type ReconnectingTCP struct { config ReconnectConfig @@ -48,7 +50,9 @@ func NewReconnectingTCP(config ReconnectConfig) *ReconnectingTCP { return &ReconnectingTCP{config: config} } -// Connect starts the connection loop. Blocks until ctx is cancelled. +// Connect starts the connection loop. +// +// err := client.Connect(ctx) func (client *ReconnectingTCP) Connect(ctx context.Context) error { if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) @@ -111,6 +115,8 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { } // Send transmits frame on channel through the TCP connection. +// +// _ = client.Send("vpn:peer-abc123", encryptedPacket) func (client *ReconnectingTCP) Send(channel string, frame []byte) error { if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) @@ -126,6 +132,8 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { } // Close shuts down the reconnecting client. +// +// _ = client.Close() func (client *ReconnectingTCP) Close() error { if client == nil { return nil diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 1aa2b62..4bd7d0e 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -40,7 +40,9 @@ type Adapter struct { listener net.Listener } -// New creates a TCP adapter. Call Mount before Listen or Dial. +// New creates a TCP adapter. +// +// adapter := tcp.New(tcp.Config{Addr: ":9000", ConnAuthenticator: auth}) func New(config Config) *Adapter { if config.HandshakeTimeout == 0 { config.HandshakeTimeout = 5 * time.Second @@ -49,11 +51,15 @@ func New(config Config) *Adapter { } // Mount wires the adapter to a hub. +// +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } // Listen starts the TCP accept loop. Blocks until ctx cancelled. +// +// go adapter.Listen(ctx) func (adapter *Adapter) Listen(ctx context.Context) error { if adapter == nil { return core.E("stream.tcp", "nil adapter", nil) @@ -101,7 +107,9 @@ func (adapter *Adapter) Listen(ctx context.Context) error { } } -// Dial connects to a remote TCP stream endpoint. Returns a Peer that can send/receive. +// Dial connects to a remote TCP stream endpoint. +// +// peer, err := adapter.Dial(ctx, hub) func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer, error) { if adapter == nil { return nil, core.E("stream.tcp", "nil adapter", nil) diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 873ab83..9c7ff65 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -40,6 +40,8 @@ type ReconnectingClient struct { } // NewReconnectingClient creates a reconnecting WebSocket client. +// +// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://localhost:8080/stream/ws"}) func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { if config.InitialBackoff == 0 { config.InitialBackoff = 500 * time.Millisecond @@ -53,7 +55,9 @@ func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { return &ReconnectingClient{config: config, state: stream.StateDisconnected} } -// Connect starts the connection loop. Blocks until ctx is cancelled. +// Connect starts the connection loop. +// +// err := client.Connect(ctx) func (client *ReconnectingClient) Connect(ctx context.Context) error { if client == nil { return core.E("stream.ws", "nil reconnecting client", nil) @@ -139,6 +143,8 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { } // Send marshals and sends a message through the WebSocket connection. +// +// _ = client.Send(stream.Message{Type: stream.TypeEvent, Channel: "hashrate", Data: map[string]any{"h": 1234567}}) func (client *ReconnectingClient) Send(msg stream.Message) error { if client == nil { return core.E("stream.ws", "nil reconnecting client", nil) @@ -179,6 +185,8 @@ func (client *ReconnectingClient) State() stream.ConnectionState { } // Close shuts down the reconnecting client. +// +// _ = client.Close() func (client *ReconnectingClient) Close() error { if client == nil { return nil diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 503c149..9e5ba92 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -53,7 +53,7 @@ type Adapter struct { config Config } -// New creates a WebSocket adapter. Call Mount before serving requests. +// New creates a WebSocket adapter. // // adapter := ws.New(ws.Config{Authenticator: auth}) func New(config Config) *Adapter { @@ -66,7 +66,7 @@ func New(config Config) *Adapter { return &Adapter{config: config} } -// Mount wires the adapter to a hub. Must be called before Handler(). +// Mount wires the adapter to a hub. // // adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { @@ -75,7 +75,10 @@ func (adapter *Adapter) Mount(hub *stream.Hub) { // ServeHTTP upgrades the request to WebSocket and binds the connection to the mounted hub. // -// http.Handle("/stream/ws", adapter) +// http.Handle("/stream/ws", adapter.Handler()) +// +// // Gin: +// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { adapter.serveHTTP(w, r, r.URL.Query()["channel"]) } diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 45e8bf8..210d293 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -62,7 +62,9 @@ type Adapter struct { cancel context.CancelFunc } -// New creates a ZMQ adapter. Call Mount and Start before use. +// New creates a ZMQ adapter. +// +// adapter := zmq.New(zmq.Config{Mode: zmq.ModePubSub, Endpoint: "tcp://127.0.0.1:5555", Role: zmq.RoleSubscriber}) func New(config Config) *Adapter { if config.HandshakeTimeout == 0 { config.HandshakeTimeout = 5 * time.Second @@ -71,11 +73,15 @@ func New(config Config) *Adapter { } // Mount wires the adapter to a hub. +// +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } // Start opens the ZMQ socket and begins receive/dispatch. Blocks until ctx cancelled. +// +// go adapter.Start(ctx) func (adapter *Adapter) Start(ctx context.Context) error { if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) @@ -168,6 +174,8 @@ func (adapter *Adapter) Start(ctx context.Context) error { } // Publish sends frame with topic (channel name) via the ZMQ socket. +// +// _ = adapter.Publish("block", templateBytes) func (adapter *Adapter) Publish(channel string, frame []byte) error { if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) diff --git a/auth.go b/auth.go index e9e241f..14783dd 100644 --- a/auth.go +++ b/auth.go @@ -9,7 +9,10 @@ import ( ) // Authenticator validates an HTTP request during the WebSocket upgrade or SSE -// connection. Implementations may inspect headers, query parameters, or cookies. +// connection. +// +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// result := auth.Authenticate(r) type Authenticator interface { Authenticate(r *http.Request) AuthResult } @@ -33,19 +36,22 @@ type AuthResult struct { // // auth := stream.AuthenticatorFunc(func(r *http.Request) stream.AuthResult { // token := r.Header.Get("X-Api-Key") -// if token == "" { return stream.AuthResult{Valid: false} } +// if token == "" { +// return stream.AuthResult{Valid: false} +// } // return stream.AuthResult{Valid: true, UserID: lookupUser(token)} // }) type AuthenticatorFunc func(r *http.Request) AuthResult -// Authenticate calls f(r). +// Authenticate calls the wrapped function. func (f AuthenticatorFunc) Authenticate(r *http.Request) AuthResult { return f(r) } -// APIKeyAuthenticator validates Authorization: Bearer against a static map. +// APIKeyAuthenticator validates `Authorization: Bearer ` against a static map. // // auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// result := auth.Authenticate(r) type APIKeyAuthenticator struct { Keys map[string]string } @@ -64,7 +70,10 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return &APIKeyAuthenticator{Keys: copied} } -// Authenticate validates the request's Authorization Bearer token against the key map. +// Authenticate validates the request's `Authorization: Bearer ` header. +// +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// result := auth.Authenticate(r) func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { if a == nil { return AuthResult{Valid: false} @@ -92,7 +101,9 @@ func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { // auth := &stream.BearerTokenAuth{ // Validate: func(token string) stream.AuthResult { // claims, err := jwt.Parse(token, keyFunc) -// if err != nil { return stream.AuthResult{Valid: false, Error: err} } +// if err != nil { +// return stream.AuthResult{Valid: false, Error: err} +// } // return stream.AuthResult{Valid: true, UserID: claims.Subject} // }, // } @@ -100,7 +111,10 @@ type BearerTokenAuth struct { Validate func(token string) AuthResult } -// Authenticate extracts the Bearer token and delegates to Validate. +// Authenticate extracts the bearer token and delegates to Validate. +// +// auth := &stream.BearerTokenAuth{Validate: validateJWT} +// result := auth.Authenticate(r) func (b *BearerTokenAuth) Authenticate(r *http.Request) AuthResult { if b == nil || b.Validate == nil { return AuthResult{Valid: false} @@ -119,17 +133,21 @@ func (b *BearerTokenAuth) Authenticate(r *http.Request) AuthResult { return b.Validate(token) } -// QueryTokenAuth extracts a ?token= query parameter and validates via caller function. -// Use when browser clients cannot set headers (native WebSocket API). +// QueryTokenAuth extracts a `?token=` query parameter and validates via a caller function. // // auth := &stream.QueryTokenAuth{ -// Validate: func(token string) stream.AuthResult { ... }, +// Validate: func(token string) stream.AuthResult { +// return lookupToken(token) +// }, // } type QueryTokenAuth struct { Validate func(token string) AuthResult } -// Authenticate extracts the token query parameter and delegates to Validate. +// Authenticate extracts the `token` query parameter and delegates to Validate. +// +// auth := &stream.QueryTokenAuth{Validate: lookupToken} +// result := auth.Authenticate(r) func (q *QueryTokenAuth) Authenticate(r *http.Request) AuthResult { if q == nil || q.Validate == nil { return AuthResult{Valid: false} @@ -142,7 +160,6 @@ func (q *QueryTokenAuth) Authenticate(r *http.Request) AuthResult { } // ConnAuthenticator validates a raw connection handshake for TCP and ZMQ adapters. -// The handshake is the first message received on the connection (up to 4 KB). // // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { // var h tcp.Handshake @@ -158,7 +175,7 @@ type ConnAuthenticator interface { // ConnAuthenticatorFunc adapts a plain function to ConnAuthenticator. type ConnAuthenticatorFunc func(handshake []byte) AuthResult -// AuthenticateConn calls f(handshake). +// AuthenticateConn calls the wrapped function. func (f ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { return f(handshake) } From f5962ad96d4c589288c5f6cc7a2fb607e9bc9786 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:13:54 +0000 Subject: [PATCH 026/140] refactor(stream): align pipe fallback with broadcast semantics Co-Authored-By: Virgil --- hub_test.go | 17 +++++++---------- stream.go | 4 +++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/hub_test.go b/hub_test.go index c2d1af3..7df32d9 100644 --- a/hub_test.go +++ b/hub_test.go @@ -234,7 +234,7 @@ func TestHub_Pipe_Ugly(t *testing.T) { } } -func TestHub_Pipe_GenericPublishFallback_Good(t *testing.T) { +func TestHub_Pipe_GenericBroadcastFallback_Good(t *testing.T) { sourceStream := newTestStream() destinationStream := newTestStream() @@ -247,17 +247,14 @@ func TestHub_Pipe_GenericPublishFallback_Good(t *testing.T) { destinationStream.mu.Lock() defer destinationStream.mu.Unlock() - if len(destinationStream.published) != 1 { - t.Fatalf("len(published) = %d, want %d", len(destinationStream.published), 1) + if len(destinationStream.broadcasts) != 1 { + t.Fatalf("len(broadcasts) = %d, want %d", len(destinationStream.broadcasts), 1) } - if destinationStream.published[0].channel != "*" { - t.Fatalf("published channel = %q, want %q", destinationStream.published[0].channel, "*") + if string(destinationStream.broadcasts[0]) != "123456" { + t.Fatalf("broadcast frame = %q, want %q", string(destinationStream.broadcasts[0]), "123456") } - if string(destinationStream.published[0].frame) != "123456" { - t.Fatalf("published frame = %q, want %q", string(destinationStream.published[0].frame), "123456") - } - if len(destinationStream.broadcasts) != 0 { - t.Fatalf("len(broadcasts) = %d, want %d", len(destinationStream.broadcasts), 0) + if len(destinationStream.published) != 0 { + t.Fatalf("len(published) = %d, want %d", len(destinationStream.published), 0) } } diff --git a/stream.go b/stream.go index e5f4e13..c630bc4 100644 --- a/stream.go +++ b/stream.go @@ -240,8 +240,10 @@ func Pipe(src Stream, dst Stream) func() { })) } if len(stops) == 0 { + // Generic Stream implementations do not expose channel names, so fall back + // to forwarding the frame as a broadcast. return src.Subscribe("*", func(frame []byte) { - _ = dst.Publish("*", frame) + _ = dst.Broadcast(frame) }) } return func() { From daf343ed0f3744efbea08d647181c3263ec9cc8f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:18:51 +0000 Subject: [PATCH 027/140] chore(stream): refine AX-facing APIs Co-Authored-By: Virgil --- hub.go | 14 +++++++++++--- hub_test.go | 2 ++ stream.go | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/hub.go b/hub.go index bed02bf..e5e7e14 100644 --- a/hub.go +++ b/hub.go @@ -72,6 +72,7 @@ func NewHubWithConfig(config HubConfig) *Hub { // Config returns a normalised copy of the hub configuration. // // cfg := hub.Config() +// writeTimeout := cfg.WriteTimeout func (hub *Hub) Config() HubConfig { if hub == nil { return DefaultHubConfig() @@ -381,14 +382,19 @@ func (hub *Hub) Stats() HubStats { // SubscribePublished registers a handler invoked for each published channel frame. // -// _ = hub.SubscribePublished(func(channel string, frame []byte) { ... }) +// stop := hub.SubscribePublished(func(channel string, frame []byte) { +// _ = channel +// _ = frame +// }) func (hub *Hub) SubscribePublished(handler func(string, []byte)) func() { return hub.subscribePublished(handler) } // SubscribeBroadcast registers a handler invoked for each broadcast frame. // -// _ = hub.SubscribeBroadcast(func(frame []byte) { ... }) +// stop := hub.SubscribeBroadcast(func(frame []byte) { +// _ = frame +// }) func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { if hub == nil || handler == nil { return func() {} @@ -503,7 +509,9 @@ func (hub *Hub) AllChannels() iter.Seq[string] { // AddPeer registers a peer with the hub and invokes OnConnect. // -// hub.AddPeer(stream.NewPeer("ws")) +// peer := stream.NewPeer("ws") +// peer.UserID = "user-42" +// _ = hub.AddPeer(peer) func (hub *Hub) AddPeer(peer *Peer) error { if hub == nil { return core.E("stream.hub", "nil hub", nil) diff --git a/hub_test.go b/hub_test.go index 7df32d9..c635358 100644 --- a/hub_test.go +++ b/hub_test.go @@ -190,6 +190,8 @@ func TestHub_Pipe_Bad(t *testing.T) { }) defer unsubscribe() + stop() + // Idempotent teardown should be safe. stop() if err := sourceHub.Publish("block", []byte("template")); err != nil { diff --git a/stream.go b/stream.go index c630bc4..ccda84c 100644 --- a/stream.go +++ b/stream.go @@ -217,6 +217,7 @@ type Envelope struct { // Returns a stop function. Safe to call from multiple goroutines. // // stop := stream.Pipe(zmqHub, wsHub) +// Forward ZMQ frames to WebSocket clients. // defer stop() func Pipe(src Stream, dst Stream) func() { if src == nil || dst == nil || src == dst { @@ -242,14 +243,21 @@ func Pipe(src Stream, dst Stream) func() { if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back // to forwarding the frame as a broadcast. - return src.Subscribe("*", func(frame []byte) { + stop := src.Subscribe("*", func(frame []byte) { _ = dst.Broadcast(frame) }) + var once sync.Once + return func() { + once.Do(stop) + } } + var once sync.Once return func() { - for index := len(stops) - 1; index >= 0; index-- { - stops[index]() - } + once.Do(func() { + for index := len(stops) - 1; index >= 0; index-- { + stops[index]() + } + }) } } From 1aace9384bb0abdba5f3d55cc28920be13aae0f0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:22:00 +0000 Subject: [PATCH 028/140] refactor(stream): align public docs with AX examples Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 10 +++------ adapter/ws/reconnect.go | 12 +++------- auth.go | 47 +++++++++++++++------------------------- 3 files changed, 24 insertions(+), 45 deletions(-) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 7f77aeb..6171124 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -36,7 +36,7 @@ type ReconnectingTCP struct { closed bool } -// NewReconnectingTCP creates a reconnecting TCP client. +// client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{Addr: "10.69.69.165:9000"}) func NewReconnectingTCP(config ReconnectConfig) *ReconnectingTCP { if config.InitialBackoff == 0 { config.InitialBackoff = time.Second @@ -50,9 +50,7 @@ func NewReconnectingTCP(config ReconnectConfig) *ReconnectingTCP { return &ReconnectingTCP{config: config} } -// Connect starts the connection loop. -// -// err := client.Connect(ctx) +// err := client.Connect(ctx) func (client *ReconnectingTCP) Connect(ctx context.Context) error { if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) @@ -131,9 +129,7 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { return err } -// Close shuts down the reconnecting client. -// -// _ = client.Close() +// _ = client.Close() func (client *ReconnectingTCP) Close() error { if client == nil { return nil diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 9c7ff65..00d7eec 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -39,9 +39,7 @@ type ReconnectingClient struct { closed bool } -// NewReconnectingClient creates a reconnecting WebSocket client. -// -// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://localhost:8080/stream/ws"}) +// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://localhost:8080/stream/ws"}) func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { if config.InitialBackoff == 0 { config.InitialBackoff = 500 * time.Millisecond @@ -55,9 +53,7 @@ func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { return &ReconnectingClient{config: config, state: stream.StateDisconnected} } -// Connect starts the connection loop. -// -// err := client.Connect(ctx) +// err := client.Connect(ctx) func (client *ReconnectingClient) Connect(ctx context.Context) error { if client == nil { return core.E("stream.ws", "nil reconnecting client", nil) @@ -184,9 +180,7 @@ func (client *ReconnectingClient) State() stream.ConnectionState { return client.state } -// Close shuts down the reconnecting client. -// -// _ = client.Close() +// _ = client.Close() func (client *ReconnectingClient) Close() error { if client == nil { return nil diff --git a/auth.go b/auth.go index 14783dd..58f73ed 100644 --- a/auth.go +++ b/auth.go @@ -32,8 +32,6 @@ type AuthResult struct { Error error } -// AuthenticatorFunc adapts a plain function to the Authenticator interface. -// // auth := stream.AuthenticatorFunc(func(r *http.Request) stream.AuthResult { // token := r.Header.Get("X-Api-Key") // if token == "" { @@ -43,7 +41,9 @@ type AuthResult struct { // }) type AuthenticatorFunc func(r *http.Request) AuthResult -// Authenticate calls the wrapped function. +// auth := stream.AuthenticatorFunc(func(r *http.Request) stream.AuthResult { +// return stream.AuthResult{Valid: true, UserID: r.Header.Get("X-User")} +// }) func (f AuthenticatorFunc) Authenticate(r *http.Request) AuthResult { return f(r) } @@ -56,9 +56,7 @@ type APIKeyAuthenticator struct { Keys map[string]string } -// NewAPIKeyAuth creates an API key authenticator from a key-to-user map. -// -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { if keys == nil { keys = map[string]string{} @@ -70,10 +68,8 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return &APIKeyAuthenticator{Keys: copied} } -// Authenticate validates the request's `Authorization: Bearer ` header. -// -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// result := auth.Authenticate(r) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// result := auth.Authenticate(r) func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { if a == nil { return AuthResult{Valid: false} @@ -96,8 +92,6 @@ func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { return AuthResult{Valid: true, UserID: userID} } -// BearerTokenAuth delegates bearer token validation to a caller-supplied function. -// // auth := &stream.BearerTokenAuth{ // Validate: func(token string) stream.AuthResult { // claims, err := jwt.Parse(token, keyFunc) @@ -111,10 +105,8 @@ type BearerTokenAuth struct { Validate func(token string) AuthResult } -// Authenticate extracts the bearer token and delegates to Validate. -// -// auth := &stream.BearerTokenAuth{Validate: validateJWT} -// result := auth.Authenticate(r) +// auth := &stream.BearerTokenAuth{Validate: validateJWT} +// result := auth.Authenticate(r) func (b *BearerTokenAuth) Authenticate(r *http.Request) AuthResult { if b == nil || b.Validate == nil { return AuthResult{Valid: false} @@ -133,8 +125,6 @@ func (b *BearerTokenAuth) Authenticate(r *http.Request) AuthResult { return b.Validate(token) } -// QueryTokenAuth extracts a `?token=` query parameter and validates via a caller function. -// // auth := &stream.QueryTokenAuth{ // Validate: func(token string) stream.AuthResult { // return lookupToken(token) @@ -144,10 +134,8 @@ type QueryTokenAuth struct { Validate func(token string) AuthResult } -// Authenticate extracts the `token` query parameter and delegates to Validate. -// -// auth := &stream.QueryTokenAuth{Validate: lookupToken} -// result := auth.Authenticate(r) +// auth := &stream.QueryTokenAuth{Validate: lookupToken} +// result := auth.Authenticate(r) func (q *QueryTokenAuth) Authenticate(r *http.Request) AuthResult { if q == nil || q.Validate == nil { return AuthResult{Valid: false} @@ -159,23 +147,24 @@ func (q *QueryTokenAuth) Authenticate(r *http.Request) AuthResult { return q.Validate(token) } -// ConnAuthenticator validates a raw connection handshake for TCP and ZMQ adapters. -// // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// var h tcp.Handshake -// if r := core.JSONUnmarshal(handshake, &h); !r.OK { +// if len(handshake) == 0 { // return stream.AuthResult{Valid: false} // } -// return verifyHMAC(h.Token, h.Timestamp) +// return stream.AuthResult{Valid: true, UserID: "peer-1"} // }) type ConnAuthenticator interface { AuthenticateConn(handshake []byte) AuthResult } -// ConnAuthenticatorFunc adapts a plain function to ConnAuthenticator. +// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { +// return stream.AuthResult{Valid: true} +// }) type ConnAuthenticatorFunc func(handshake []byte) AuthResult -// AuthenticateConn calls the wrapped function. +// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { +// return stream.AuthResult{Valid: true} +// }) func (f ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { return f(handshake) } From d7162563c06bb7420deef19648cf9a6def194680 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:26:36 +0000 Subject: [PATCH 029/140] fix(stream): harden auth and tcp first-frame handling Co-Authored-By: Virgil --- adapter/tcp/tcp.go | 29 ++++++++++++++++--------- auth.go | 53 ++++++++++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 4bd7d0e..885c0e8 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -176,13 +176,14 @@ func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { defer conn.Close() + channel, frame, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) + if err != nil { + return + } + result := stream.AuthResult{Valid: true} if auth := adapter.config.ConnAuthenticator; auth != nil { - _, handshake, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) - if err != nil { - return - } - result = auth.AuthenticateConn(handshake) + result = auth.AuthenticateConn(frame) if !result.Valid { return } @@ -200,6 +201,10 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) + if auth := adapter.config.ConnAuthenticator; auth == nil { + dispatchFrame(hub, peer, channel, frame) + } + for { channel, frame, err := readFrame(conn, 0, MaxFrameSize) if err != nil { @@ -213,6 +218,14 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre } } +func dispatchFrame(hub *stream.Hub, peer *stream.Peer, channel string, frame []byte) { + if channel == "" { + _ = hub.BroadcastFromPeer(peer, frame) + return + } + _ = hub.PublishFromPeer(peer, channel, frame) +} + func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer, hub *stream.Hub) { defer conn.Close() go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) @@ -222,11 +235,7 @@ func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *strea hub.RemovePeer(peer) return } - if channel == "" { - _ = hub.BroadcastFromPeer(peer, frame) - continue - } - _ = hub.PublishFromPeer(peer, channel, frame) + dispatchFrame(hub, peer, channel, frame) } } diff --git a/auth.go b/auth.go index 58f73ed..8e8eb52 100644 --- a/auth.go +++ b/auth.go @@ -45,6 +45,9 @@ type AuthenticatorFunc func(r *http.Request) AuthResult // return stream.AuthResult{Valid: true, UserID: r.Header.Get("X-User")} // }) func (f AuthenticatorFunc) Authenticate(r *http.Request) AuthResult { + if f == nil || r == nil { + return AuthResult{Valid: false} + } return f(r) } @@ -71,19 +74,12 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { // auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) // result := auth.Authenticate(r) func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { - if a == nil { + if a == nil || r == nil { return AuthResult{Valid: false} } - header := r.Header.Get("Authorization") - if header == "" { - return AuthResult{Valid: false, Error: ErrMissingAuthHeader} - } - if !core.HasPrefix(header, "Bearer ") { - return AuthResult{Valid: false, Error: ErrMalformedAuthHeader} - } - token := core.TrimPrefix(header, "Bearer ") - if token == "" { - return AuthResult{Valid: false, Error: ErrMalformedAuthHeader} + token, result := bearerTokenFromRequest(r) + if !result.Valid { + return result } userID, ok := a.Keys[token] if !ok { @@ -108,19 +104,12 @@ type BearerTokenAuth struct { // auth := &stream.BearerTokenAuth{Validate: validateJWT} // result := auth.Authenticate(r) func (b *BearerTokenAuth) Authenticate(r *http.Request) AuthResult { - if b == nil || b.Validate == nil { + if b == nil || b.Validate == nil || r == nil { return AuthResult{Valid: false} } - header := r.Header.Get("Authorization") - if header == "" { - return AuthResult{Valid: false, Error: ErrMissingAuthHeader} - } - if !core.HasPrefix(header, "Bearer ") { - return AuthResult{Valid: false, Error: ErrMalformedAuthHeader} - } - token := core.TrimPrefix(header, "Bearer ") - if token == "" { - return AuthResult{Valid: false, Error: ErrMalformedAuthHeader} + token, result := bearerTokenFromRequest(r) + if !result.Valid { + return result } return b.Validate(token) } @@ -137,7 +126,7 @@ type QueryTokenAuth struct { // auth := &stream.QueryTokenAuth{Validate: lookupToken} // result := auth.Authenticate(r) func (q *QueryTokenAuth) Authenticate(r *http.Request) AuthResult { - if q == nil || q.Validate == nil { + if q == nil || q.Validate == nil || r == nil { return AuthResult{Valid: false} } token := r.URL.Query().Get("token") @@ -166,5 +155,23 @@ type ConnAuthenticatorFunc func(handshake []byte) AuthResult // return stream.AuthResult{Valid: true} // }) func (f ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { + if f == nil { + return AuthResult{Valid: false} + } return f(handshake) } + +func bearerTokenFromRequest(r *http.Request) (string, AuthResult) { + header := r.Header.Get("Authorization") + if header == "" { + return "", AuthResult{Valid: false, Error: ErrMissingAuthHeader} + } + if !core.HasPrefix(header, "Bearer ") { + return "", AuthResult{Valid: false, Error: ErrMalformedAuthHeader} + } + token := core.TrimPrefix(header, "Bearer ") + if token == "" { + return "", AuthResult{Valid: false, Error: ErrMalformedAuthHeader} + } + return token, AuthResult{Valid: true} +} From 6e29d4a97b7d58f6f8596e2f45b0390ca8d8bcfd Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:29:29 +0000 Subject: [PATCH 030/140] fix(stream): generate proper peer UUIDs Co-Authored-By: Virgil --- stream.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/stream.go b/stream.go index ccda84c..d6612c0 100644 --- a/stream.go +++ b/stream.go @@ -97,12 +97,12 @@ type Peer struct { closeOnce sync.Once } -// NewPeer creates a peer with a generated identifier and a buffered send queue. +// NewPeer creates a peer with a generated UUID and a buffered send queue. // // peer := stream.NewPeer("ws") func NewPeer(transport string) *Peer { return &Peer{ - ID: randomID(), + ID: randomUUID(), Transport: transport, send: make(chan []byte, 256), subscriptions: map[string]bool{}, @@ -272,9 +272,11 @@ var ( _ time.Duration ) -func randomID() string { +func randomUUID() string { var raw [16]byte _, _ = rand.Read(raw[:]) + raw[6] = (raw[6] & 0x0f) | 0x40 + raw[8] = (raw[8] & 0x3f) | 0x80 return hex.EncodeToString(raw[:4]) + "-" + hex.EncodeToString(raw[4:6]) + "-" + hex.EncodeToString(raw[6:8]) + "-" + From 10d54fb5f7326329df8a2e3d32ea54ceb5d15009 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:32:32 +0000 Subject: [PATCH 031/140] fix(stream): keep overflow deliveries on hub loop Co-Authored-By: Virgil --- hub.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/hub.go b/hub.go index e5e7e14..697b429 100644 --- a/hub.go +++ b/hub.go @@ -342,7 +342,11 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca }: return nil default: - hub.broadcastToPeers(source, frame, notifyBroadcastSubscribers) + go hub.enqueueBroadcast(broadcastDelivery{ + source: source, + frame: append([]byte(nil), frame...), + notifyBroadcastSubscribers: notifyBroadcastSubscribers, + }) } return nil } @@ -687,7 +691,27 @@ func (hub *Hub) enqueueDelivery(channel string, frame []byte, notifyPublishSubsc select { case hub.deliver <- item: default: - hub.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) + go hub.enqueueDeliveryAsync(item) + } +} + +func (hub *Hub) enqueueBroadcast(item broadcastDelivery) { + if hub == nil { + return + } + select { + case hub.broadcast <- item: + case <-hub.done: + } +} + +func (hub *Hub) enqueueDeliveryAsync(item delivery) { + if hub == nil { + return + } + select { + case hub.deliver <- item: + case <-hub.done: } } From bb44ee5d40ff42eb89c518ccd9be51ef4f3c6fd6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:34:50 +0000 Subject: [PATCH 032/140] fix(stream): re-export frame aliases Co-Authored-By: Virgil --- adapter/ws/compat.go | 6 ++++++ adapter/ws/compat_test.go | 10 ++++++++++ stats.go | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/adapter/ws/compat.go b/adapter/ws/compat.go index dff1fd7..e62520b 100644 --- a/adapter/ws/compat.go +++ b/adapter/ws/compat.go @@ -12,6 +12,12 @@ import ( // Stream preserves the transport-agnostic stream interface for legacy callers. type Stream = stream.Stream +// Frame preserves the legacy raw payload alias. +type Frame = stream.Frame + +// Channel preserves the legacy channel name alias. +type Channel = stream.Channel + // Hub preserves the legacy go-ws Hub type name. type Hub = stream.Hub diff --git a/adapter/ws/compat_test.go b/adapter/ws/compat_test.go index 538ed82..1f8b885 100644 --- a/adapter/ws/compat_test.go +++ b/adapter/ws/compat_test.go @@ -14,6 +14,16 @@ func TestCompat_LegacySurface_Good(t *testing.T) { t.Fatal("NewAPIKeyAuth() = nil") } + var frame Frame = []byte("payload") + if string(frame) != "payload" { + t.Fatalf("Frame alias produced %q, want %q", string(frame), "payload") + } + + var channel Channel = "hashrate" + if channel != "hashrate" { + t.Fatalf("Channel alias produced %q, want %q", channel, "hashrate") + } + if StateDisconnected != 0 || StateConnecting != 1 || StateConnected != 2 { t.Fatalf("unexpected connection states: %d %d %d", StateDisconnected, StateConnecting, StateConnected) } diff --git a/stats.go b/stats.go index dd2a365..636c1b1 100644 --- a/stats.go +++ b/stats.go @@ -5,7 +5,7 @@ package stream // HubStats is a snapshot of hub state at a point in time. // // s := hub.Stats() -// log.Printf("peers=%d channels=%d", s.Peers, s.Channels) +// core.Print(nil, "peers=%d channels=%d", s.Peers, s.Channels) type HubStats struct { // Peers is the number of currently connected peers across all transports. Peers int `json:"peers"` From bdfa4dbb5bd891a7ac77cdffcb5bb497b21d3741 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:38:27 +0000 Subject: [PATCH 033/140] fix(ws): add legacy compatibility facade Co-Authored-By: Virgil --- ws/compat.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++ ws/compat_test.go | 117 ++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 ws/compat.go create mode 100644 ws/compat_test.go diff --git a/ws/compat.go b/ws/compat.go new file mode 100644 index 0000000..1948d60 --- /dev/null +++ b/ws/compat.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package ws preserves the legacy go-ws compatibility surface while the new +// transport-agnostic stream package does the actual work. +package ws + +import ( + "dappco.re/go/stream" + adapterredis "dappco.re/go/stream/adapter/redis" + adapterws "dappco.re/go/stream/adapter/ws" +) + +// Stream preserves the transport-agnostic stream interface for legacy callers. +type Stream = stream.Stream + +// Frame preserves the legacy raw payload alias. +type Frame = stream.Frame + +// Channel preserves the legacy channel name alias. +type Channel = stream.Channel + +// Hub preserves the legacy go-ws Hub type name. +type Hub = stream.Hub + +// HubConfig preserves the legacy go-ws HubConfig type name. +type HubConfig = stream.HubConfig + +// HubStats preserves the legacy hub stats type name. +type HubStats = stream.HubStats + +// Peer preserves the transport-agnostic peer type under the legacy package. +type Peer = stream.Peer + +// Client preserves the legacy go-ws Client type name. +type Client = stream.Peer + +// Authenticator preserves the legacy go-ws Authenticator type name. +type Authenticator = stream.Authenticator + +// AuthenticatorFunc preserves the legacy go-ws AuthenticatorFunc helper. +type AuthenticatorFunc = stream.AuthenticatorFunc + +// AuthResult preserves the legacy go-ws AuthResult type name. +type AuthResult = stream.AuthResult + +// APIKeyAuthenticator preserves the legacy API key authenticator type name. +type APIKeyAuthenticator = stream.APIKeyAuthenticator + +// BearerTokenAuth preserves the legacy bearer-token authenticator type name. +type BearerTokenAuth = stream.BearerTokenAuth + +// QueryTokenAuth preserves the legacy query-token authenticator type name. +type QueryTokenAuth = stream.QueryTokenAuth + +// ConnAuthenticator preserves the legacy raw-connection authenticator name. +type ConnAuthenticator = stream.ConnAuthenticator + +// ConnAuthenticatorFunc preserves the legacy raw-connection helper name. +type ConnAuthenticatorFunc = stream.ConnAuthenticatorFunc + +// ConnectionState preserves the reconnecting client connection state type. +type ConnectionState = stream.ConnectionState + +// Message preserves the legacy go-ws WebSocket message envelope. +type Message = stream.Message + +// MessageType preserves the legacy go-ws message type name. +type MessageType = stream.MessageType + +const ( + // TypeProcessOutput preserves the legacy message type constant. + TypeProcessOutput = stream.TypeProcessOutput + // TypeProcessStatus preserves the legacy message type constant. + TypeProcessStatus = stream.TypeProcessStatus + // TypeEvent preserves the legacy message type constant. + TypeEvent = stream.TypeEvent + // TypeError preserves the legacy message type constant. + TypeError = stream.TypeError + // TypePing preserves the legacy message type constant. + TypePing = stream.TypePing + // TypePong preserves the legacy message type constant. + TypePong = stream.TypePong + // TypeSubscribe preserves the legacy message type constant. + TypeSubscribe = stream.TypeSubscribe + // TypeUnsubscribe preserves the legacy message type constant. + TypeUnsubscribe = stream.TypeUnsubscribe + // StateDisconnected preserves the reconnecting client disconnected state. + StateDisconnected = stream.StateDisconnected + // StateConnecting preserves the reconnecting client connecting state. + StateConnecting = stream.StateConnecting + // StateConnected preserves the reconnecting client connected state. + StateConnected = stream.StateConnected +) + +var ( + // ErrMissingAuthHeader preserves the legacy missing-header sentinel error. + ErrMissingAuthHeader = stream.ErrMissingAuthHeader + // ErrMalformedAuthHeader preserves the legacy malformed-header sentinel error. + ErrMalformedAuthHeader = stream.ErrMalformedAuthHeader + // ErrInvalidAPIKey preserves the legacy invalid API key sentinel error. + ErrInvalidAPIKey = stream.ErrInvalidAPIKey + // ErrHandshakeTimeout preserves the legacy handshake timeout sentinel error. + ErrHandshakeTimeout = stream.ErrHandshakeTimeout + // ErrAuthRejected preserves the legacy authenticator rejection sentinel error. + ErrAuthRejected = stream.ErrAuthRejected + // ErrHubNotRunning preserves the legacy hub lifecycle sentinel error. + ErrHubNotRunning = stream.ErrHubNotRunning + // ErrEmptyChannel preserves the legacy empty-channel sentinel error. + ErrEmptyChannel = stream.ErrEmptyChannel +) + +// Adapter preserves the legacy WebSocket adapter type name. +type Adapter = adapterws.Adapter + +// Config preserves the legacy WebSocket adapter configuration type name. +type Config = adapterws.Config + +// ReconnectConfig preserves the legacy reconnecting WebSocket configuration type name. +type ReconnectConfig = adapterws.ReconnectConfig + +// RedisBridge preserves the legacy go-ws RedisBridge type name. +type RedisBridge = adapterredis.Bridge + +// NewRedisBridge creates the legacy Redis bridge wrapper. +func NewRedisBridge(hub *stream.Hub, config adapterredis.Config) (*RedisBridge, error) { + return adapterredis.NewBridge(hub, config) +} + +// NewAPIKeyAuth creates the legacy-compatible API key authenticator wrapper. +func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { + return stream.NewAPIKeyAuth(keys) +} + +// NewHub creates a legacy-compatible hub. +func NewHub() *Hub { + return stream.NewHub() +} + +// NewHubWithConfig creates a legacy-compatible hub with explicit configuration. +func NewHubWithConfig(config HubConfig) *Hub { + return stream.NewHubWithConfig(config) +} + +// DefaultHubConfig returns the default hub configuration for legacy callers. +func DefaultHubConfig() HubConfig { + return stream.DefaultHubConfig() +} + +// NewPeer creates a legacy-compatible peer with a buffered send queue. +func NewPeer(transport string) *Peer { + return stream.NewPeer(transport) +} + +// Pipe preserves the legacy stream pipe composition helper. +func Pipe(src Stream, dst Stream) func() { + return stream.Pipe(src, dst) +} + +// New creates a legacy-compatible WebSocket adapter. +func New(config Config) *Adapter { + return adapterws.New(config) +} + +// NewReconnectingClient creates the legacy reconnecting WebSocket client. +func NewReconnectingClient(config ReconnectConfig) *adapterws.ReconnectingClient { + return adapterws.NewReconnectingClient(config) +} diff --git a/ws/compat_test.go b/ws/compat_test.go new file mode 100644 index 0000000..1f8b885 --- /dev/null +++ b/ws/compat_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws + +import ( + "context" + "testing" + "time" +) + +func TestCompat_LegacySurface_Good(t *testing.T) { + auth := NewAPIKeyAuth(map[string]string{"valid-key": "user-1"}) + if auth == nil { + t.Fatal("NewAPIKeyAuth() = nil") + } + + var frame Frame = []byte("payload") + if string(frame) != "payload" { + t.Fatalf("Frame alias produced %q, want %q", string(frame), "payload") + } + + var channel Channel = "hashrate" + if channel != "hashrate" { + t.Fatalf("Channel alias produced %q, want %q", channel, "hashrate") + } + + if StateDisconnected != 0 || StateConnecting != 1 || StateConnected != 2 { + t.Fatalf("unexpected connection states: %d %d %d", StateDisconnected, StateConnecting, StateConnected) + } + + if ErrMissingAuthHeader == nil || ErrMalformedAuthHeader == nil || ErrInvalidAPIKey == nil { + t.Fatal("expected auth sentinel errors to be re-exported") + } + if ErrHandshakeTimeout == nil || ErrAuthRejected == nil || ErrHubNotRunning == nil || ErrEmptyChannel == nil { + t.Fatal("expected transport sentinel errors to be re-exported") + } + + sourceHub := NewHub() + destinationHub := NewHub() + + sourceContext, sourceCancel := context.WithCancel(context.Background()) + defer sourceCancel() + destinationContext, destinationCancel := context.WithCancel(context.Background()) + defer destinationCancel() + + go sourceHub.Run(sourceContext) + go destinationHub.Run(destinationContext) + waitForRunningHub(t, sourceHub) + waitForRunningHub(t, destinationHub) + + received := make(chan []byte, 1) + unsubscribe := destinationHub.Subscribe("hashrate", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + stop := Pipe(sourceHub, destinationHub) + defer stop() + + if err := sourceHub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for piped frame") + } + + peer := NewPeer("ws") + if peer == nil { + t.Fatal("NewPeer() = nil") + } + if peer.Transport != "ws" { + t.Fatalf("peer.Transport = %q, want %q", peer.Transport, "ws") + } + + stats := destinationHub.Stats() + var _ HubStats = stats +} + +func TestCompat_LegacySurface_Bad(t *testing.T) { + hub := NewHub() + + if err := hub.Publish("hashrate", []byte("123456")); err != ErrHubNotRunning { + t.Fatalf("Publish() error = %v, want %v", err, ErrHubNotRunning) + } + + peer := NewPeer("ws") + if err := hub.SubscribePeer(peer, ""); err != ErrEmptyChannel { + t.Fatalf("SubscribePeer() error = %v, want %v", err, ErrEmptyChannel) + } +} + +func TestCompat_LegacySurface_Ugly(t *testing.T) { + var source Stream + stop := Pipe(source, source) + if stop == nil { + t.Fatal("Pipe(nil, nil) returned nil stop function") + } + stop() +} + +func waitForRunningHub(t *testing.T, hub *Hub) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.Publish("health", nil) == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for hub to start") +} From 752a96e7870e1de4e53efa6d101289e3016ae981 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:41:56 +0000 Subject: [PATCH 034/140] fix(pipe): publish wildcard frames in generic fallback Co-Authored-By: Virgil --- hub_test.go | 17 ++++++++++------- stream.go | 4 ++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/hub_test.go b/hub_test.go index c635358..0647e5a 100644 --- a/hub_test.go +++ b/hub_test.go @@ -236,7 +236,7 @@ func TestHub_Pipe_Ugly(t *testing.T) { } } -func TestHub_Pipe_GenericBroadcastFallback_Good(t *testing.T) { +func TestHub_Pipe_GenericPublishFallback_Good(t *testing.T) { sourceStream := newTestStream() destinationStream := newTestStream() @@ -249,14 +249,17 @@ func TestHub_Pipe_GenericBroadcastFallback_Good(t *testing.T) { destinationStream.mu.Lock() defer destinationStream.mu.Unlock() - if len(destinationStream.broadcasts) != 1 { - t.Fatalf("len(broadcasts) = %d, want %d", len(destinationStream.broadcasts), 1) + if len(destinationStream.published) != 1 { + t.Fatalf("len(published) = %d, want %d", len(destinationStream.published), 1) } - if string(destinationStream.broadcasts[0]) != "123456" { - t.Fatalf("broadcast frame = %q, want %q", string(destinationStream.broadcasts[0]), "123456") + if destinationStream.published[0].channel != "*" { + t.Fatalf("published channel = %q, want %q", destinationStream.published[0].channel, "*") } - if len(destinationStream.published) != 0 { - t.Fatalf("len(published) = %d, want %d", len(destinationStream.published), 0) + if string(destinationStream.published[0].frame) != "123456" { + t.Fatalf("published frame = %q, want %q", string(destinationStream.published[0].frame), "123456") + } + if len(destinationStream.broadcasts) != 0 { + t.Fatalf("len(broadcasts) = %d, want %d", len(destinationStream.broadcasts), 0) } } diff --git a/stream.go b/stream.go index d6612c0..ee6e788 100644 --- a/stream.go +++ b/stream.go @@ -242,9 +242,9 @@ func Pipe(src Stream, dst Stream) func() { } if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back - // to forwarding the frame as a broadcast. + // to publishing on the wildcard channel. stop := src.Subscribe("*", func(frame []byte) { - _ = dst.Broadcast(frame) + _ = dst.Publish("*", frame) }) var once sync.Once return func() { From e1abc27636f62538887826e1b8afefc8cb51aff8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 21:59:08 +0000 Subject: [PATCH 035/140] docs(stream): apply AX examples to transport APIs Co-Authored-By: Virgil --- adapter/redis/redis.go | 11 +++++++ adapter/sse/sse.go | 15 ++++++++++ adapter/sse/sse_test.go | 63 ++++++++++++++++++++++++++++++++++++++++ adapter/tcp/reconnect.go | 8 +++++ adapter/ws/reconnect.go | 13 +++++++++ 5 files changed, 110 insertions(+) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index ece3af6..7dac217 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -18,6 +18,13 @@ import ( ) // Config configures the Redis bridge. +// +// bridge, err := redis.NewBridge(hub, redis.Config{ +// Addr: "127.0.0.1:6379", +// Prefix: "pool", +// }) +// _ = bridge +// _ = err type Config struct { Addr string Password string @@ -27,6 +34,10 @@ type Config struct { } // Bridge connects a Hub to Redis pub/sub for cross-instance messaging. +// +// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"}) +// _ = err +// go bridge.Start(ctx) type Bridge struct { hub *stream.Hub config Config diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 8c7cd14..4686271 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -16,6 +16,12 @@ import ( ) // Config configures the SSE adapter. +// +// adapter := sse.New(sse.Config{ +// Authenticator: stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}), +// HeartbeatInterval: 15 * time.Second, +// RetryMs: 3000, +// }) type Config struct { Authenticator stream.Authenticator HeartbeatInterval time.Duration @@ -23,12 +29,18 @@ type Config struct { } // Adapter is the SSE transport adapter for a stream.Hub. +// +// adapter := sse.New(sse.Config{}) +// adapter.Mount(hub) +// http.Handle("/stream/events", adapter.Handler()) type Adapter struct { hub *stream.Hub config Config } // New creates an SSE adapter. +// +// adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) func New(config Config) *Adapter { if config.HeartbeatInterval == 0 { config.HeartbeatInterval = 15 * time.Second @@ -40,6 +52,8 @@ func New(config Config) *Adapter { } // Mount wires the adapter to a hub. +// +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } @@ -54,6 +68,7 @@ func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Handler returns an http.HandlerFunc that accepts SSE connections. // // http.Handle("/stream/events", adapter.Handler()) +// http.Get("http://127.0.0.1:8080/stream/events?channel=hashrate") func (adapter *Adapter) Handler() http.HandlerFunc { return adapter.ServeHTTP } diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 6049123..58f10f2 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -173,6 +173,69 @@ func TestAdapter_ServeHTTP_Good(t *testing.T) { } } +func TestAdapter_HandlerForChannel_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{HeartbeatInterval: 20 * time.Millisecond}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.HandlerForChannel("hashrate"))) + defer server.Close() + + response, err := http.Get(server.URL) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + waitForPeerCount(t, hub, 1) + if err := hub.Publish("hashrate", []byte("654321")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + reader := bufio.NewReader(response.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("ReadString() error = %v", err) + } + if strings.TrimSpace(line) == "data: 654321" { + return + } + } +} + +func TestAdapter_Handler_RetryMs_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{RetryMs: 1234, HeartbeatInterval: time.Second}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=hashrate") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + reader := bufio.NewReader(response.Body) + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("ReadString() error = %v", err) + } + if strings.TrimSpace(line) != "retry: 1234" { + t.Fatalf("first line = %q, want %q", strings.TrimSpace(line), "retry: 1234") + } +} + func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 6171124..6787672 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -13,6 +13,14 @@ import ( ) // ReconnectConfig configures the client-side reconnecting TCP connection. +// +// client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{ +// Addr: "127.0.0.1:9000", +// OnMessage: func(channel string, frame []byte) { +// _ = channel +// _ = frame +// }, +// }) type ReconnectConfig struct { Addr string InitialBackoff time.Duration diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 00d7eec..2639b87 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -15,6 +15,13 @@ import ( ) // ReconnectConfig configures the client-side reconnecting WebSocket. +// +// client := ws.NewReconnectingClient(ws.ReconnectConfig{ +// URL: "ws://127.0.0.1:8080/stream/ws", +// OnMessage: func(message stream.Message) { +// _ = message.Channel +// }, +// }) type ReconnectConfig struct { URL string InitialBackoff time.Duration @@ -30,6 +37,9 @@ type ReconnectConfig struct { } // ReconnectingClient is a WebSocket client with automatic reconnection. +// +// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://127.0.0.1:8080/stream/ws"}) +// _ = client.Connect(context.Background()) type ReconnectingClient struct { config ReconnectConfig state stream.ConnectionState @@ -53,6 +63,7 @@ func NewReconnectingClient(config ReconnectConfig) *ReconnectingClient { return &ReconnectingClient{config: config, state: stream.StateDisconnected} } +// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://127.0.0.1:8080/stream/ws"}) // err := client.Connect(ctx) func (client *ReconnectingClient) Connect(ctx context.Context) error { if client == nil { @@ -171,6 +182,8 @@ func (client *ReconnectingClient) Send(msg stream.Message) error { } // State returns the current connection state. +// +// state := client.State() func (client *ReconnectingClient) State() stream.ConnectionState { if client == nil { return stream.StateDisconnected From 353f5348ddb0fa8d4145a75689e87a2f0c4ae0de Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:11:21 +0000 Subject: [PATCH 036/140] docs(stream): improve peer usage examples Co-Authored-By: Virgil --- stream.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/stream.go b/stream.go index ee6e788..2dbd4b7 100644 --- a/stream.go +++ b/stream.go @@ -70,12 +70,9 @@ type Channel = string // Peer represents one connected endpoint. Created by a transport adapter. // -// peer := &stream.Peer{ -// ID: uuid.New(), -// UserID: authResult.UserID, -// Claims: authResult.Claims, -// Transport: "ws", -// } +// peer := stream.NewPeer("ws") +// peer.UserID = authResult.UserID +// peer.Claims = authResult.Claims type Peer struct { // ID is a random UUID assigned on creation. ID string @@ -152,6 +149,7 @@ func (peer *Peer) Send(frame []byte) bool { // Close signals the transport adapter to shut down this connection. // +// peer.SetCloseHook(func() { _ = conn.Close() }) // peer.Close() func (peer *Peer) Close() { if peer == nil { @@ -186,7 +184,7 @@ func (peer *Peer) SetCloseHook(closeHook func()) { // SendQueue returns the peer's outgoing frame queue. // -// for frame := range peer.SendQueue() { ... } +// for frame := range peer.SendQueue() { handle(frame) } func (peer *Peer) SendQueue() <-chan []byte { if peer == nil { return nil From 45daa23277f07a4faac720a503fa41eca0d94e14 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:14:42 +0000 Subject: [PATCH 037/140] fix(zmq): enforce handshake size limit Co-Authored-By: Virgil --- adapter/zmq/zmq.go | 5 ++++ adapter/zmq/zmq_test.go | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 210d293..0a2557a 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -17,6 +17,8 @@ import ( "dappco.re/go/stream" ) +const maxHandshakeFrameSize = 4 << 10 + // Mode selects the ZMQ socket pattern. type Mode int @@ -146,6 +148,9 @@ func (adapter *Adapter) Start(ctx context.Context) error { } return err } + if len(handshake.Bytes()) > maxHandshakeFrameSize { + return stream.ErrAuthRejected + } result := adapter.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) if !result.Valid { return stream.ErrAuthRejected diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go index 1fff8c8..af90d8e 100644 --- a/adapter/zmq/zmq_test.go +++ b/adapter/zmq/zmq_test.go @@ -298,6 +298,59 @@ func TestAdapter_Start_Auth_Timeout(t *testing.T) { } } +func TestAdapter_Start_Auth_HandshakeTooLarge_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + endpoint := randomTCPEndpoint(t) + subscriber := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RoleSubscriber, + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + return stream.AuthResult{Valid: true} + }), + HandshakeTimeout: 500 * time.Millisecond, + }) + subscriber.Mount(hub) + + publisher := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RolePublisher, + }) + publisher.Mount(stream.NewHub()) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + errs := make(chan error, 1) + go func() { errs <- subscriber.Start(runContext) }() + go func() { _ = publisher.Start(runContext) }() + waitForAdapterRunning(t, subscriber) + waitForAdapterRunning(t, publisher) + + tooLargeHandshake := make([]byte, maxHandshakeFrameSize+1) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := publisher.Publish("block", tooLargeHandshake); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case err := <-errs: + if err != stream.ErrAuthRejected { + t.Fatalf("Start() error = %v, want %v", err, stream.ErrAuthRejected) + } + return + case <-time.After(100 * time.Millisecond): + } + } + + t.Fatal("timed out waiting for handshake rejection") +} + func randomTCPEndpoint(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") From b4aad1844184555a242dd599035321b24835a703 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:17:30 +0000 Subject: [PATCH 038/140] fix(tcp): guarantee full-frame writes Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 3 +-- adapter/tcp/tcp.go | 16 +++++++++++++- adapter/tcp/tcp_test.go | 45 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 6787672..3ac4a21 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -133,8 +133,7 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { if conn == nil { return core.E("stream.tcp", "not connected", nil) } - _, err := conn.Write(encodeFrame(channel, frame)) - return err + return writeFull(conn, encodeFrame(channel, frame)) } // _ = client.Close() diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 885c0e8..c6107d8 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -251,7 +251,7 @@ func (adapter *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stre if writeTimeout > 0 { _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) } - if _, err := conn.Write(frame); err != nil { + if err := writeFull(conn, frame); err != nil { return } } @@ -300,6 +300,20 @@ func encodeFrame(channel string, frame []byte) []byte { copy(buffer[8+len(channelBytes):], frame) return buffer } + +func writeFull(conn net.Conn, payload []byte) error { + for len(payload) > 0 { + written, err := conn.Write(payload) + if err != nil { + return err + } + if written <= 0 { + return io.ErrShortWrite + } + payload = payload[written:] + } + return nil +} func isClosedNetworkError(err error) bool { if err == nil { return false diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 0ad6210..7d82e58 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -4,6 +4,7 @@ package tcp import ( "context" + "io" "net" "testing" "time" @@ -283,3 +284,47 @@ func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { } t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), expected) } + +func TestWriteFull_Good(t *testing.T) { + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + wrapped := &partialWriteConn{Conn: left, chunkSize: 2} + payload := []byte("hello") + received := make(chan []byte, 1) + go func() { + buffer := make([]byte, len(payload)) + _, err := io.ReadFull(right, buffer) + if err != nil { + received <- nil + return + } + received <- buffer + }() + + if err := writeFull(wrapped, payload); err != nil { + t.Fatalf("writeFull() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "hello" { + t.Fatalf("received frame = %q, want %q", string(frame), "hello") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for payload") + } +} + +type partialWriteConn struct { + net.Conn + chunkSize int +} + +func (conn *partialWriteConn) Write(payload []byte) (int, error) { + if conn.chunkSize > 0 && len(payload) > conn.chunkSize { + payload = payload[:conn.chunkSize] + } + return conn.Conn.Write(payload) +} From 7c94303ac9b50f966bfd9d846cf279340c0e7754 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:20:50 +0000 Subject: [PATCH 039/140] docs(stream): tighten AX-facing comments Co-Authored-By: Virgil --- adapter/sse/sse.go | 1 + adapter/ws/ws.go | 4 +--- auth.go | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 4686271..85ad664 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -61,6 +61,7 @@ func (adapter *Adapter) Mount(hub *stream.Hub) { // ServeHTTP accepts an SSE connection and subscribes it using the channel query params. // // http.Handle("/stream/events", adapter.Handler()) +// http.Get("http://127.0.0.1:8080/stream/events?channel=hashrate") func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { adapter.serve(w, r, r.URL.Query()["channel"]) } diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 9e5ba92..92a3a54 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -191,12 +191,10 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } // Handler returns an http.HandlerFunc for WebSocket connections. -// Compatible with net/http and gin (use gin.WrapF). // // http.Handle("/stream/ws", adapter.Handler()) -// -// // Gin: // r.GET("/stream/ws", gin.WrapF(adapter.Handler())) +// func (adapter *Adapter) Handler() http.HandlerFunc { return adapter.ServeHTTP } diff --git a/auth.go b/auth.go index 8e8eb52..75a7348 100644 --- a/auth.go +++ b/auth.go @@ -8,8 +8,7 @@ import ( "dappco.re/go/core" ) -// Authenticator validates an HTTP request during the WebSocket upgrade or SSE -// connection. +// Authenticator checks an HTTP request during connection setup. // // auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) // result := auth.Authenticate(r) @@ -17,7 +16,7 @@ type Authenticator interface { Authenticate(r *http.Request) AuthResult } -// AuthResult holds the outcome of an authentication attempt. +// AuthResult is the outcome of an authentication attempt. type AuthResult struct { // Valid indicates whether authentication succeeded. Valid bool @@ -72,6 +71,7 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { } // auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// r.Header.Set("Authorization", "Bearer sk-prod-1") // result := auth.Authenticate(r) func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { if a == nil || r == nil { From b779ac99bc58990a229eb1eda49115eeec6e2a2e Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:24:01 +0000 Subject: [PATCH 040/140] feat(sse): add auth failure callback Co-Authored-By: Virgil --- adapter/sse/sse.go | 4 ++++ adapter/sse/sse_test.go | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 85ad664..c823da4 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -24,6 +24,7 @@ import ( // }) type Config struct { Authenticator stream.Authenticator + OnAuthFailure func(r *http.Request, result stream.AuthResult) HeartbeatInterval time.Duration RetryMs int } @@ -101,6 +102,9 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ if adapter.config.Authenticator != nil { result = adapter.config.Authenticator.Authenticate(r) if !result.Valid { + if adapter.config.OnAuthFailure != nil { + adapter.config.OnAuthFailure(r, result) + } http.Error(w, "unauthorised", http.StatusUnauthorized) return } diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 58f10f2..9f76fe5 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -90,8 +91,12 @@ func TestAdapter_Handler_Bad(t *testing.T) { defer hubCancel() go hub.Run(hubContext) + var authFailureCount atomic.Int32 adapter := New(Config{ Authenticator: stream.NewAPIKeyAuth(map[string]string{"valid-key": "user-1"}), + OnAuthFailure: func(r *http.Request, result stream.AuthResult) { + authFailureCount.Add(1) + }, }) adapter.Mount(hub) @@ -107,6 +112,9 @@ func TestAdapter_Handler_Bad(t *testing.T) { if response.StatusCode != http.StatusUnauthorized { t.Fatalf("StatusCode = %d, want %d", response.StatusCode, http.StatusUnauthorized) } + if authFailureCount.Load() != 1 { + t.Fatalf("OnAuthFailure invoked %d times, want %d", authFailureCount.Load(), 1) + } } func TestAdapter_Handler_Ugly(t *testing.T) { From 1ce94831c80022770ce50ae05e62de025419eb79 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:27:18 +0000 Subject: [PATCH 041/140] docs(stream): add example-led adapter comments Co-Authored-By: Virgil --- adapter/redis/redis.go | 9 ++------- adapter/tcp/tcp.go | 3 +++ adapter/zmq/zmq.go | 7 +++++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 7dac217..3752daa 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -19,12 +19,8 @@ import ( // Config configures the Redis bridge. // -// bridge, err := redis.NewBridge(hub, redis.Config{ -// Addr: "127.0.0.1:6379", -// Prefix: "pool", -// }) -// _ = bridge -// _ = err +// cfg := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} +// bridge, err := redis.NewBridge(hub, cfg) type Config struct { Addr string Password string @@ -36,7 +32,6 @@ type Config struct { // Bridge connects a Hub to Redis pub/sub for cross-instance messaging. // // bridge, err := redis.NewBridge(hub, redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"}) -// _ = err // go bridge.Start(ctx) type Bridge struct { hub *stream.Hub diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index c6107d8..91b50cd 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -24,6 +24,9 @@ const MaxFrameSize = 65535 const maxHandshakeFrameSize = 4 << 10 // Config configures the TCP adapter. +// +// cfg := tcp.Config{Addr: ":9000", ConnAuthenticator: auth} +// adapter := tcp.New(cfg) type Config struct { Addr string ConnAuthenticator stream.ConnAuthenticator diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 0a2557a..195e612 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -38,6 +38,13 @@ const ( ) // Config configures the ZMQ adapter. +// +// cfg := zmq.Config{ +// Mode: zmq.ModePubSub, +// Endpoint: "tcp://127.0.0.1:5555", +// Role: zmq.RoleSubscriber, +// } +// adapter := zmq.New(cfg) type Config struct { Mode Mode Endpoint string From 3b55ed8b7c1e1a0556c38e6bb63edf5acd1325cc Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:32:34 +0000 Subject: [PATCH 042/140] style(stream): clarify AX examples Co-Authored-By: Virgil --- adapter/redis/redis.go | 10 ++++++---- adapter/sse/sse.go | 5 +++-- adapter/tcp/tcp.go | 4 ++-- adapter/ws/reconnect.go | 5 +++-- adapter/ws/ws.go | 2 +- adapter/zmq/zmq.go | 4 ++-- hub_config.go | 4 ++-- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 3752daa..470ee6a 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -19,8 +19,8 @@ import ( // Config configures the Redis bridge. // -// cfg := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} -// bridge, err := redis.NewBridge(hub, cfg) +// config := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} +// bridge, err := redis.NewBridge(hub, config) type Config struct { Addr string Password string @@ -31,7 +31,8 @@ type Config struct { // Bridge connects a Hub to Redis pub/sub for cross-instance messaging. // -// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"}) +// config := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} +// bridge, err := redis.NewBridge(hub, config) // go bridge.Start(ctx) type Bridge struct { hub *stream.Hub @@ -54,7 +55,8 @@ type envelope struct { // NewBridge creates and validates the Redis connection. // -// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) +// config := redis.Config{Addr: "redis:6379", Prefix: "pool"} +// bridge, err := redis.NewBridge(hub, config) func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { if hub == nil { return nil, core.E("stream.redis", "nil hub", nil) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index c823da4..bb102e7 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -17,11 +17,12 @@ import ( // Config configures the SSE adapter. // -// adapter := sse.New(sse.Config{ +// config := sse.Config{ // Authenticator: stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}), // HeartbeatInterval: 15 * time.Second, // RetryMs: 3000, -// }) +// } +// adapter := sse.New(config) type Config struct { Authenticator stream.Authenticator OnAuthFailure func(r *http.Request, result stream.AuthResult) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 91b50cd..804dd45 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -25,8 +25,8 @@ const maxHandshakeFrameSize = 4 << 10 // Config configures the TCP adapter. // -// cfg := tcp.Config{Addr: ":9000", ConnAuthenticator: auth} -// adapter := tcp.New(cfg) +// config := tcp.Config{Addr: ":9000", ConnAuthenticator: auth} +// adapter := tcp.New(config) type Config struct { Addr string ConnAuthenticator stream.ConnAuthenticator diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 2639b87..719a23b 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -16,12 +16,13 @@ import ( // ReconnectConfig configures the client-side reconnecting WebSocket. // -// client := ws.NewReconnectingClient(ws.ReconnectConfig{ +// reconnectConfig := ws.ReconnectConfig{ // URL: "ws://127.0.0.1:8080/stream/ws", // OnMessage: func(message stream.Message) { // _ = message.Channel // }, -// }) +// } +// client := ws.NewReconnectingClient(reconnectConfig) type ReconnectConfig struct { URL string InitialBackoff time.Duration diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 92a3a54..6e62711 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -21,7 +21,7 @@ import ( // Config configures the WebSocket adapter. // -// cfg := ws.Config{ +// config := ws.Config{ // Authenticator: stream.NewAPIKeyAuth(keys), // OnAuthFailure: func(r *http.Request, res stream.AuthResult) { // log.Printf("ws auth fail from %s", r.RemoteAddr) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 195e612..be418a6 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -39,12 +39,12 @@ const ( // Config configures the ZMQ adapter. // -// cfg := zmq.Config{ +// config := zmq.Config{ // Mode: zmq.ModePubSub, // Endpoint: "tcp://127.0.0.1:5555", // Role: zmq.RoleSubscriber, // } -// adapter := zmq.New(cfg) +// adapter := zmq.New(config) type Config struct { Mode Mode Endpoint string diff --git a/hub_config.go b/hub_config.go index 16436d6..3a01fec 100644 --- a/hub_config.go +++ b/hub_config.go @@ -6,7 +6,7 @@ import "time" // HubConfig controls hub behaviour and lifecycle callbacks. // -// cfg := stream.HubConfig{ +// config := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, // OnConnect: func(p *stream.Peer) { metrics.Inc("peers") }, // ChannelAuthoriser: func(p *stream.Peer, ch string) bool { @@ -45,7 +45,7 @@ type HubConfig struct { // DefaultHubConfig returns sensible defaults. // -// cfg := stream.DefaultHubConfig() +// config := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { return HubConfig{ HeartbeatInterval: 30 * time.Second, From 41f62bbbfe7d7b1eeb84cc0afac9bbdbb438a0a4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:35:39 +0000 Subject: [PATCH 043/140] refactor(stream): ax polish auth and docs Co-Authored-By: Virgil --- auth.go | 76 +++++++++++++++++++++++++++--------------------------- errors.go | 6 ++++- message.go | 7 ++--- stats.go | 6 ++--- 4 files changed, 50 insertions(+), 45 deletions(-) diff --git a/auth.go b/auth.go index 75a7348..6fa2d61 100644 --- a/auth.go +++ b/auth.go @@ -10,10 +10,10 @@ import ( // Authenticator checks an HTTP request during connection setup. // -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// result := auth.Authenticate(r) +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// result := authenticator.Authenticate(request) type Authenticator interface { - Authenticate(r *http.Request) AuthResult + Authenticate(request *http.Request) AuthResult } // AuthResult is the outcome of an authentication attempt. @@ -31,23 +31,23 @@ type AuthResult struct { Error error } -// auth := stream.AuthenticatorFunc(func(r *http.Request) stream.AuthResult { -// token := r.Header.Get("X-Api-Key") +// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// token := request.Header.Get("X-Api-Key") // if token == "" { // return stream.AuthResult{Valid: false} // } // return stream.AuthResult{Valid: true, UserID: lookupUser(token)} // }) -type AuthenticatorFunc func(r *http.Request) AuthResult +type AuthenticatorFunc func(request *http.Request) AuthResult -// auth := stream.AuthenticatorFunc(func(r *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: r.Header.Get("X-User")} +// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// return stream.AuthResult{Valid: true, UserID: request.Header.Get("X-User")} // }) -func (f AuthenticatorFunc) Authenticate(r *http.Request) AuthResult { - if f == nil || r == nil { +func (function AuthenticatorFunc) Authenticate(request *http.Request) AuthResult { + if function == nil || request == nil { return AuthResult{Valid: false} } - return f(r) + return function(request) } // APIKeyAuthenticator validates `Authorization: Bearer ` against a static map. @@ -58,7 +58,7 @@ type APIKeyAuthenticator struct { Keys map[string]string } -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { if keys == nil { keys = map[string]string{} @@ -70,25 +70,25 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return &APIKeyAuthenticator{Keys: copied} } -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// r.Header.Set("Authorization", "Bearer sk-prod-1") -// result := auth.Authenticate(r) -func (a *APIKeyAuthenticator) Authenticate(r *http.Request) AuthResult { - if a == nil || r == nil { +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// request.Header.Set("Authorization", "Bearer sk-prod-1") +// result := authenticator.Authenticate(request) +func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) AuthResult { + if authenticator == nil || request == nil { return AuthResult{Valid: false} } - token, result := bearerTokenFromRequest(r) + token, result := bearerTokenFromRequest(request) if !result.Valid { return result } - userID, ok := a.Keys[token] + userID, ok := authenticator.Keys[token] if !ok { return AuthResult{Valid: false, Error: ErrInvalidAPIKey} } return AuthResult{Valid: true, UserID: userID} } -// auth := &stream.BearerTokenAuth{ +// authenticator := &stream.BearerTokenAuth{ // Validate: func(token string) stream.AuthResult { // claims, err := jwt.Parse(token, keyFunc) // if err != nil { @@ -101,20 +101,20 @@ type BearerTokenAuth struct { Validate func(token string) AuthResult } -// auth := &stream.BearerTokenAuth{Validate: validateJWT} -// result := auth.Authenticate(r) -func (b *BearerTokenAuth) Authenticate(r *http.Request) AuthResult { - if b == nil || b.Validate == nil || r == nil { +// authenticator := &stream.BearerTokenAuth{Validate: validateJWT} +// result := authenticator.Authenticate(request) +func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthResult { + if authenticator == nil || authenticator.Validate == nil || request == nil { return AuthResult{Valid: false} } - token, result := bearerTokenFromRequest(r) + token, result := bearerTokenFromRequest(request) if !result.Valid { return result } - return b.Validate(token) + return authenticator.Validate(token) } -// auth := &stream.QueryTokenAuth{ +// authenticator := &stream.QueryTokenAuth{ // Validate: func(token string) stream.AuthResult { // return lookupToken(token) // }, @@ -123,17 +123,17 @@ type QueryTokenAuth struct { Validate func(token string) AuthResult } -// auth := &stream.QueryTokenAuth{Validate: lookupToken} -// result := auth.Authenticate(r) -func (q *QueryTokenAuth) Authenticate(r *http.Request) AuthResult { - if q == nil || q.Validate == nil || r == nil { +// authenticator := &stream.QueryTokenAuth{Validate: lookupToken} +// result := authenticator.Authenticate(request) +func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthResult { + if authenticator == nil || authenticator.Validate == nil || request == nil { return AuthResult{Valid: false} } - token := r.URL.Query().Get("token") + token := request.URL.Query().Get("token") if token == "" { return AuthResult{Valid: false} } - return q.Validate(token) + return authenticator.Validate(token) } // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { @@ -154,15 +154,15 @@ type ConnAuthenticatorFunc func(handshake []byte) AuthResult // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { // return stream.AuthResult{Valid: true} // }) -func (f ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { - if f == nil { +func (function ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { + if function == nil { return AuthResult{Valid: false} } - return f(handshake) + return function(handshake) } -func bearerTokenFromRequest(r *http.Request) (string, AuthResult) { - header := r.Header.Get("Authorization") +func bearerTokenFromRequest(request *http.Request) (string, AuthResult) { + header := request.Header.Get("Authorization") if header == "" { return "", AuthResult{Valid: false, Error: ErrMissingAuthHeader} } diff --git a/errors.go b/errors.go index 4872e36..4eb3bc8 100644 --- a/errors.go +++ b/errors.go @@ -4,7 +4,11 @@ package stream import "dappco.re/go/core" -// Sentinel errors for the stream package. All errors use core.E(). +// Sentinel errors for the stream package. +// +// if err := hub.Publish("hashrate", frame); err == ErrHubNotRunning { +// return +// } var ( // ErrMissingAuthHeader is returned when no Authorization header is present. ErrMissingAuthHeader = core.E("stream.auth", "missing Authorization header", nil) diff --git a/message.go b/message.go index 4c84704..6d8746e 100644 --- a/message.go +++ b/message.go @@ -4,8 +4,9 @@ package stream import "time" -// MessageType identifies the purpose of a WebSocket message. -// Preserved from go-ws for backward compatibility with browser clients. +// MessageType is used with stream.Message for WebSocket envelopes. +// +// msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} type MessageType string const ( @@ -19,7 +20,7 @@ const ( TypeUnsubscribe MessageType = "unsubscribe" // client cancels channel subscription ) -// Message is the JSON envelope for WebSocket frames. Preserved from go-ws. +// Message is the JSON envelope for WebSocket frames. // // msg := stream.Message{ // Type: stream.TypeEvent, diff --git a/stats.go b/stats.go index 636c1b1..874d071 100644 --- a/stats.go +++ b/stats.go @@ -2,10 +2,10 @@ package stream -// HubStats is a snapshot of hub state at a point in time. +// HubStats captures a hub snapshot at a point in time. // -// s := hub.Stats() -// core.Print(nil, "peers=%d channels=%d", s.Peers, s.Channels) +// stats := hub.Stats() +// core.Print(nil, "peers=%d channels=%d", stats.Peers, stats.Channels) type HubStats struct { // Peers is the number of currently connected peers across all transports. Peers int `json:"peers"` From 80c343f49340f4f8430ffda4373f109cee3225fc Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:38:41 +0000 Subject: [PATCH 044/140] refactor(stream): clarify auth adapter names Co-Authored-By: Virgil --- auth.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/auth.go b/auth.go index 6fa2d61..d9d572a 100644 --- a/auth.go +++ b/auth.go @@ -43,11 +43,11 @@ type AuthenticatorFunc func(request *http.Request) AuthResult // authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { // return stream.AuthResult{Valid: true, UserID: request.Header.Get("X-User")} // }) -func (function AuthenticatorFunc) Authenticate(request *http.Request) AuthResult { - if function == nil || request == nil { +func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) AuthResult { + if authenticatorFunc == nil || request == nil { return AuthResult{Valid: false} } - return function(request) + return authenticatorFunc(request) } // APIKeyAuthenticator validates `Authorization: Bearer ` against a static map. @@ -154,11 +154,11 @@ type ConnAuthenticatorFunc func(handshake []byte) AuthResult // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { // return stream.AuthResult{Valid: true} // }) -func (function ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { - if function == nil { +func (connAuthenticatorFunc ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { + if connAuthenticatorFunc == nil { return AuthResult{Valid: false} } - return function(handshake) + return connAuthenticatorFunc(handshake) } func bearerTokenFromRequest(request *http.Request) (string, AuthResult) { From 47a1b9073b2849b4b52f386eb323e39f80632198 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:42:13 +0000 Subject: [PATCH 045/140] fix(stream): clone piped frames at bridge boundary Co-Authored-By: Virgil --- stream.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/stream.go b/stream.go index 2dbd4b7..f2d2961 100644 --- a/stream.go +++ b/stream.go @@ -230,19 +230,19 @@ func Pipe(src Stream, dst Stream) func() { stops := make([]func(), 0, 2) if publisher, ok := src.(publishSubscriber); ok { stops = append(stops, publisher.SubscribePublished(func(channel string, frame []byte) { - _ = dst.Publish(channel, frame) + _ = dst.Publish(channel, cloneFrame(frame)) })) } if broadcaster, ok := src.(broadcastSubscriber); ok { stops = append(stops, broadcaster.SubscribeBroadcast(func(frame []byte) { - _ = dst.Broadcast(frame) + _ = dst.Broadcast(cloneFrame(frame)) })) } if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back // to publishing on the wildcard channel. stop := src.Subscribe("*", func(frame []byte) { - _ = dst.Publish("*", frame) + _ = dst.Publish("*", cloneFrame(frame)) }) var once sync.Once return func() { @@ -292,3 +292,10 @@ func encodeTCPFrame(channel string, frame []byte) []byte { copy(output[8+len(channelBytes):], frame) return output } + +func cloneFrame(frame []byte) []byte { + if len(frame) == 0 { + return nil + } + return append([]byte(nil), frame...) +} From f49b9f2669fd081336e8bb8a0cb0c8c250aabe3d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:44:40 +0000 Subject: [PATCH 046/140] docs(stream): align public comments with AX Co-Authored-By: Virgil --- auth.go | 8 +++++++- message.go | 2 +- stats.go | 1 + stream.go | 11 ++++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/auth.go b/auth.go index d9d572a..181f956 100644 --- a/auth.go +++ b/auth.go @@ -16,7 +16,13 @@ type Authenticator interface { Authenticate(request *http.Request) AuthResult } -// AuthResult is the outcome of an authentication attempt. +// AuthResult captures the outcome of an authentication attempt. +// +// result := stream.AuthResult{ +// Valid: true, +// UserID: "user-42", +// Claims: map[string]any{"role": "admin"}, +// } type AuthResult struct { // Valid indicates whether authentication succeeded. Valid bool diff --git a/message.go b/message.go index 6d8746e..50cf2e5 100644 --- a/message.go +++ b/message.go @@ -4,7 +4,7 @@ package stream import "time" -// MessageType is used with stream.Message for WebSocket envelopes. +// MessageType identifies the payload shape in a stream.Message envelope. // // msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} type MessageType string diff --git a/stats.go b/stats.go index 874d071..2f8f672 100644 --- a/stats.go +++ b/stats.go @@ -6,6 +6,7 @@ package stream // // stats := hub.Stats() // core.Print(nil, "peers=%d channels=%d", stats.Peers, stats.Channels) +// // Example: peers=12 channels=4 type HubStats struct { // Peers is the number of currently connected peers across all transports. Peers int `json:"peers"` diff --git a/stream.go b/stream.go index f2d2961..c766fec 100644 --- a/stream.go +++ b/stream.go @@ -194,7 +194,16 @@ func (peer *Peer) SendQueue() <-chan []byte { return peer.send } -// ConnectionState represents the lifecycle state of a reconnecting client. +// ConnectionState describes a reconnecting client's lifecycle. +// +// switch client.State() { +// case stream.StateConnected: +// // send frames +// case stream.StateConnecting: +// // wait for dial +// default: +// // disconnected +// } type ConnectionState int const ( From e184c19836d629a13061b44c365fdf7bfd92e0ed Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:48:07 +0000 Subject: [PATCH 047/140] chore(stream): clarify auth result naming Co-Authored-By: Virgil --- adapter/sse/sse.go | 12 ++++++------ adapter/tcp/tcp.go | 10 +++++----- adapter/ws/ws.go | 12 ++++++------ adapter/zmq/zmq.go | 4 ++-- auth.go | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index bb102e7..4188cc3 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -99,12 +99,12 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ config.RetryMs = 3000 } - result := stream.AuthResult{Valid: true} + authResult := stream.AuthResult{Valid: true} if adapter.config.Authenticator != nil { - result = adapter.config.Authenticator.Authenticate(r) - if !result.Valid { + authResult = adapter.config.Authenticator.Authenticate(r) + if !authResult.Valid { if adapter.config.OnAuthFailure != nil { - adapter.config.OnAuthFailure(r, result) + adapter.config.OnAuthFailure(r, authResult) } http.Error(w, "unauthorised", http.StatusUnauthorized) return @@ -123,8 +123,8 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ header.Set("X-Accel-Buffering", "no") peer := stream.NewPeer("sse") - peer.UserID = result.UserID - peer.Claims = result.Claims + peer.UserID = authResult.UserID + peer.Claims = authResult.Claims done := make(chan struct{}) var doneOnce sync.Once peer.SetCloseHook(func() { diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 804dd45..a325655 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -184,17 +184,17 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre return } - result := stream.AuthResult{Valid: true} + authResult := stream.AuthResult{Valid: true} if auth := adapter.config.ConnAuthenticator; auth != nil { - result = auth.AuthenticateConn(frame) - if !result.Valid { + authResult = auth.AuthenticateConn(frame) + if !authResult.Valid { return } } peer := stream.NewPeer("tcp") - peer.UserID = result.UserID - peer.Claims = result.Claims + peer.UserID = authResult.UserID + peer.Claims = authResult.Claims peer.SetCloseHook(func() { _ = conn.Close() }) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 6e62711..24da18a 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -98,12 +98,12 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe return } - result := stream.AuthResult{Valid: true} + authResult := stream.AuthResult{Valid: true} if adapter.config.Authenticator != nil { - result = adapter.config.Authenticator.Authenticate(r) - if !result.Valid { + authResult = adapter.config.Authenticator.Authenticate(r) + if !authResult.Valid { if adapter.config.OnAuthFailure != nil { - adapter.config.OnAuthFailure(r, result) + adapter.config.OnAuthFailure(r, authResult) } http.Error(w, "unauthorised", http.StatusUnauthorized) return @@ -128,8 +128,8 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } peer := stream.NewPeer("ws") - peer.UserID = result.UserID - peer.Claims = result.Claims + peer.UserID = authResult.UserID + peer.Claims = authResult.Claims peer.SetCloseHook(func() { _ = conn.Close() }) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index be418a6..a8deb7a 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -158,8 +158,8 @@ func (adapter *Adapter) Start(ctx context.Context) error { if len(handshake.Bytes()) > maxHandshakeFrameSize { return stream.ErrAuthRejected } - result := adapter.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) - if !result.Valid { + authResult := adapter.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) + if !authResult.Valid { return stream.ErrAuthRejected } } diff --git a/auth.go b/auth.go index 181f956..2125c19 100644 --- a/auth.go +++ b/auth.go @@ -8,7 +8,7 @@ import ( "dappco.re/go/core" ) -// Authenticator checks an HTTP request during connection setup. +// Authenticator validates an HTTP request during connection setup. // // authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) // result := authenticator.Authenticate(request) From 70d58097284b37879785f985bedfa7363a0f2b35 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 22:56:33 +0000 Subject: [PATCH 048/140] Add executable AX usage examples --- adapter/sse/example_test.go | 24 +++++++++ adapter/tcp/example_test.go | 33 +++++++++++++ adapter/ws/example_test.go | 28 +++++++++++ example_test.go | 97 +++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 adapter/sse/example_test.go create mode 100644 adapter/tcp/example_test.go create mode 100644 adapter/ws/example_test.go create mode 100644 example_test.go diff --git a/adapter/sse/example_test.go b/adapter/sse/example_test.go new file mode 100644 index 0000000..c015b67 --- /dev/null +++ b/adapter/sse/example_test.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package sse_test + +import ( + "context" + "net/http" + + "dappco.re/go/stream" + "dappco.re/go/stream/adapter/sse" +) + +func ExampleAdapter_HandlerForChannel() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + adapter := sse.New(sse.Config{}) + adapter.Mount(hub) + + http.Handle("/stream/hashrate", adapter.HandlerForChannel("hashrate")) +} diff --git a/adapter/tcp/example_test.go b/adapter/tcp/example_test.go new file mode 100644 index 0000000..2b81f33 --- /dev/null +++ b/adapter/tcp/example_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tcp_test + +import ( + "context" + + "dappco.re/go/stream" + "dappco.re/go/stream/adapter/tcp" +) + +func ExampleAdapter_Listen() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + adapter := tcp.New(tcp.Config{ + Addr: ":9000", + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + if string(handshake) != "trusted" { + return stream.AuthResult{Valid: false} + } + return stream.AuthResult{Valid: true, UserID: "peer-1"} + }), + }) + adapter.Mount(hub) + + go func() { + _ = adapter.Listen(ctx) + }() +} diff --git a/adapter/ws/example_test.go b/adapter/ws/example_test.go new file mode 100644 index 0000000..5cf941a --- /dev/null +++ b/adapter/ws/example_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws_test + +import ( + "context" + "net/http" + + "dappco.re/go/stream" + "dappco.re/go/stream/adapter/ws" +) + +func ExampleAdapter_Handler() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + adapter := ws.New(ws.Config{ + Authenticator: stream.NewAPIKeyAuth(map[string]string{ + "sk-live": "user-42", + }), + }) + adapter.Mount(hub) + + http.Handle("/stream/ws", adapter.Handler()) +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..6af80f5 --- /dev/null +++ b/example_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream_test + +import ( + "context" + "fmt" + "net/http/httptest" + "time" + + "dappco.re/go/stream" +) + +func ExampleNewHub() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + received := make(chan string, 1) + stop := hub.Subscribe("hashrate", func(frame []byte) { + received <- string(frame) + }) + defer stop() + + waitForHub(hub) + _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) + + select { + case frame := <-received: + fmt.Println(frame) + case <-time.After(time.Second): + fmt.Println("timeout") + } + + // Output: + // {"h":123456} +} + +func ExamplePipe() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sourceHub := stream.NewHub() + destinationHub := stream.NewHub() + go sourceHub.Run(ctx) + go destinationHub.Run(ctx) + + received := make(chan string, 1) + stopSubscribe := destinationHub.Subscribe("block", func(frame []byte) { + received <- string(frame) + }) + defer stopSubscribe() + + stopPipe := stream.Pipe(sourceHub, destinationHub) + defer stopPipe() + + waitForHub(sourceHub) + waitForHub(destinationHub) + _ = sourceHub.Publish("block", []byte(`{"height":42}`)) + + select { + case frame := <-received: + fmt.Println(frame) + case <-time.After(time.Second): + fmt.Println("timeout") + } + + // Output: + // {"height":42} +} + +func waitForHub(hub *stream.Hub) { + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if hub.Broadcast(nil) == nil { + return + } + time.Sleep(10 * time.Millisecond) + } +} + +func ExampleNewAPIKeyAuth() { + authenticator := stream.NewAPIKeyAuth(map[string]string{ + "sk-live": "user-42", + }) + + request := httptest.NewRequest("GET", "http://example.com/stream/ws", nil) + request.Header.Set("Authorization", "Bearer sk-live") + + result := authenticator.Authenticate(request) + fmt.Println(result.Valid, result.UserID) + + // Output: + // true user-42 +} From 089554acc3349171037d12d02be1f8c76785f071 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:03:27 +0000 Subject: [PATCH 049/140] style(stream): align public comments with AX Co-Authored-By: Virgil --- auth.go | 25 ++++++++++--------------- hub_config.go | 31 ++++++++++++++++--------------- message.go | 6 +----- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/auth.go b/auth.go index 2125c19..4d82310 100644 --- a/auth.go +++ b/auth.go @@ -8,21 +8,18 @@ import ( "dappco.re/go/core" ) -// Authenticator validates an HTTP request during connection setup. -// -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// result := authenticator.Authenticate(request) +// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// token := request.Header.Get("X-Api-Key") +// if token == "" { +// return stream.AuthResult{Valid: false, Error: stream.ErrMissingAuthHeader} +// } +// return stream.AuthResult{Valid: true, UserID: lookupUser(token)} +// }) type Authenticator interface { Authenticate(request *http.Request) AuthResult } -// AuthResult captures the outcome of an authentication attempt. -// -// result := stream.AuthResult{ -// Valid: true, -// UserID: "user-42", -// Claims: map[string]any{"role": "admin"}, -// } +// result := stream.AuthResult{Valid: true, UserID: "user-42", Claims: map[string]any{"role": "admin"}} type AuthResult struct { // Valid indicates whether authentication succeeded. Valid bool @@ -56,10 +53,8 @@ func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) A return authenticatorFunc(request) } -// APIKeyAuthenticator validates `Authorization: Bearer ` against a static map. -// -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// result := auth.Authenticate(r) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// result := auth.Authenticate(r) type APIKeyAuthenticator struct { Keys map[string]string } diff --git a/hub_config.go b/hub_config.go index 3a01fec..c2f39c8 100644 --- a/hub_config.go +++ b/hub_config.go @@ -4,48 +4,49 @@ package stream import "time" -// HubConfig controls hub behaviour and lifecycle callbacks. -// -// config := stream.HubConfig{ +// cfg := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, -// OnConnect: func(p *stream.Peer) { metrics.Inc("peers") }, -// ChannelAuthoriser: func(p *stream.Peer, ch string) bool { -// return p.Claims["role"] == "admin" || ch == "public" +// OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, +// ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { +// return peer.Claims["role"] == "admin" || channel == "public" // }, // } type HubConfig struct { // HeartbeatInterval is the server-side ping interval for WebSocket peers. + // Example: `HeartbeatInterval: 30 * time.Second`. // Defaults to 30 seconds. Ignored by SSE and TCP adapters. HeartbeatInterval time.Duration // PongTimeout is the deadline after a ping before the WS connection is closed. + // Example: `PongTimeout: 60 * time.Second`. // Must be greater than HeartbeatInterval. Defaults to 60 seconds. PongTimeout time.Duration // WriteTimeout is the per-write deadline for WS and TCP adapters. + // Example: `WriteTimeout: 10 * time.Second`. // Defaults to 10 seconds. WriteTimeout time.Duration - // OnConnect is called when a peer registers. Optional. + // OnConnect runs when a peer registers. // - // OnConnect: func(p *stream.Peer) { metrics.Inc("peers") }, + // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, OnConnect func(peer *Peer) - // OnDisconnect is called when a peer unregisters. Optional. + // OnDisconnect runs when a peer unregisters. OnDisconnect func(peer *Peer) - // ChannelAuthoriser optionally decides whether a peer may subscribe to a channel. - // Return true to allow. When nil, all subscriptions are allowed. + // ChannelAuthoriser decides whether a peer may subscribe to a channel. // - // ChannelAuthoriser: func(p *stream.Peer, ch string) bool { - // return p.Claims["role"] == "admin" || ch == "public" - // }, + // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + // return peer.Claims["role"] == "admin" || channel == "public" + // }, + // When nil, all subscriptions are allowed. ChannelAuthoriser func(peer *Peer, channel string) bool } // DefaultHubConfig returns sensible defaults. // -// config := stream.DefaultHubConfig() +// cfg := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { return HubConfig{ HeartbeatInterval: 30 * time.Second, diff --git a/message.go b/message.go index 50cf2e5..9d19711 100644 --- a/message.go +++ b/message.go @@ -4,9 +4,7 @@ package stream import "time" -// MessageType identifies the payload shape in a stream.Message envelope. -// -// msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} +// msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} type MessageType string const ( @@ -20,8 +18,6 @@ const ( TypeUnsubscribe MessageType = "unsubscribe" // client cancels channel subscription ) -// Message is the JSON envelope for WebSocket frames. -// // msg := stream.Message{ // Type: stream.TypeEvent, // Channel: "hashrate", From 5bdeb4ea0c3d4ad5bf159b3aa67b8016bbc647e6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:05:29 +0000 Subject: [PATCH 050/140] feat(stream): add predictable subscribe API Co-Authored-By: Virgil --- hub.go | 20 ++++++++++++++------ hub_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/hub.go b/hub.go index 697b429..866ffc9 100644 --- a/hub.go +++ b/hub.go @@ -185,14 +185,15 @@ func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte return nil } -// SubscribeE registers a handler function invoked for every frame arriving on channel. -// Returns an unsubscribe function and an error for invalid input. Multiple handlers -// per channel are allowed. Handlers run in the hub's goroutine — keep them non-blocking. +// SubscribeWithError registers a handler function invoked for every frame arriving +// on channel. Returns an unsubscribe function and an error for invalid input. +// Multiple handlers per channel are allowed. Handlers run in the hub's goroutine — +// keep them non-blocking. // -// unsub, err := hub.SubscribeE("block", func(f []byte) { ... }) +// unsub, err := hub.SubscribeWithError("block", func(frame []byte) { ... }) // if err != nil { return err } // defer unsub() -func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { +func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func(), error) { if hub == nil { return func() {}, core.E("stream.hub", "nil hub", nil) } @@ -229,6 +230,13 @@ func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) }, nil } +// SubscribeE is a compatibility alias for SubscribeWithError. +// +// unsub, err := hub.SubscribeE("block", func(frame []byte) { ... }) +func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { + return hub.SubscribeWithError(channel, handler) +} + // Subscribe registers a handler function invoked for every frame arriving on channel. // Returns an unsubscribe function. Multiple handlers per channel are allowed. // Handlers run in the hub's goroutine — keep them non-blocking. @@ -236,7 +244,7 @@ func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) // unsub := hub.Subscribe("block", func(f []byte) { ... }) // defer unsub() func (hub *Hub) Subscribe(channel string, handler func([]byte)) func() { - unsub, _ := hub.SubscribeE(channel, handler) + unsub, _ := hub.SubscribeWithError(channel, handler) return unsub } diff --git a/hub_test.go b/hub_test.go index 0647e5a..c613c83 100644 --- a/hub_test.go +++ b/hub_test.go @@ -441,6 +441,37 @@ func TestHub_SubscribeE_Good(t *testing.T) { } } +func TestHub_SubscribeWithError_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + received := make(chan []byte, 1) + unsubscribe, err := hub.SubscribeWithError("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + if err != nil { + t.Fatalf("SubscribeWithError() error = %v", err) + } + defer unsubscribe() + + if err := hub.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for subscribed frame") + } +} + func TestHub_SubscribeE_Bad(t *testing.T) { hub := NewHub() From 0ac22ad79e58498d1b01ca289555763a48ceb0bd Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:09:13 +0000 Subject: [PATCH 051/140] docs(stream): make auth examples more concrete Co-Authored-By: Virgil --- auth.go | 58 +++++++++++++++++++++++---------------------------------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/auth.go b/auth.go index 4d82310..304c163 100644 --- a/auth.go +++ b/auth.go @@ -9,11 +9,8 @@ import ( ) // authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// token := request.Header.Get("X-Api-Key") -// if token == "" { -// return stream.AuthResult{Valid: false, Error: stream.ErrMissingAuthHeader} -// } -// return stream.AuthResult{Valid: true, UserID: lookupUser(token)} +// request.Header.Set("Authorization", "Bearer sk-live") +// return stream.AuthResult{Valid: true, UserID: "user-42"} // }) type Authenticator interface { Authenticate(request *http.Request) AuthResult @@ -35,17 +32,10 @@ type AuthResult struct { } // authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// token := request.Header.Get("X-Api-Key") -// if token == "" { -// return stream.AuthResult{Valid: false} -// } -// return stream.AuthResult{Valid: true, UserID: lookupUser(token)} +// return stream.AuthResult{Valid: true, UserID: "user-42"} // }) type AuthenticatorFunc func(request *http.Request) AuthResult -// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: request.Header.Get("X-User")} -// }) func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) AuthResult { if authenticatorFunc == nil || request == nil { return AuthResult{Valid: false} @@ -53,13 +43,13 @@ func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) A return authenticatorFunc(request) } -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// result := auth.Authenticate(r) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// result := auth.Authenticate(r) type APIKeyAuthenticator struct { Keys map[string]string } -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { if keys == nil { keys = map[string]string{} @@ -71,9 +61,9 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return &APIKeyAuthenticator{Keys: copied} } -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-prod-1": "user-42"}) -// request.Header.Set("Authorization", "Bearer sk-prod-1") -// result := authenticator.Authenticate(request) +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// request.Header.Set("Authorization", "Bearer sk-live") +// result := authenticator.Authenticate(request) func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) AuthResult { if authenticator == nil || request == nil { return AuthResult{Valid: false} @@ -91,19 +81,16 @@ func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) Au // authenticator := &stream.BearerTokenAuth{ // Validate: func(token string) stream.AuthResult { -// claims, err := jwt.Parse(token, keyFunc) -// if err != nil { -// return stream.AuthResult{Valid: false, Error: err} +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} // } -// return stream.AuthResult{Valid: true, UserID: claims.Subject} +// return stream.AuthResult{Valid: false} // }, // } type BearerTokenAuth struct { Validate func(token string) AuthResult } -// authenticator := &stream.BearerTokenAuth{Validate: validateJWT} -// result := authenticator.Authenticate(request) func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthResult { if authenticator == nil || authenticator.Validate == nil || request == nil { return AuthResult{Valid: false} @@ -117,15 +104,16 @@ func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthRe // authenticator := &stream.QueryTokenAuth{ // Validate: func(token string) stream.AuthResult { -// return lookupToken(token) +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} // }, // } type QueryTokenAuth struct { Validate func(token string) AuthResult } -// authenticator := &stream.QueryTokenAuth{Validate: lookupToken} -// result := authenticator.Authenticate(request) func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthResult { if authenticator == nil || authenticator.Validate == nil || request == nil { return AuthResult{Valid: false} @@ -138,23 +126,23 @@ func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthRes } // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if len(handshake) == 0 { -// return stream.AuthResult{Valid: false} +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} // } -// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// return stream.AuthResult{Valid: false} // }) type ConnAuthenticator interface { AuthenticateConn(handshake []byte) AuthResult } // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// return stream.AuthResult{Valid: true} +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} // }) type ConnAuthenticatorFunc func(handshake []byte) AuthResult -// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// return stream.AuthResult{Valid: true} -// }) func (connAuthenticatorFunc ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { if connAuthenticatorFunc == nil { return AuthResult{Valid: false} From c29ebf5e650781334eaf05f568bc73d6f57037cc Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:11:03 +0000 Subject: [PATCH 052/140] AX document stream message types --- message.go | 21 +++++++++++++-------- stats.go | 6 ++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/message.go b/message.go index 9d19711..ff4ef90 100644 --- a/message.go +++ b/message.go @@ -5,17 +5,19 @@ package stream import "time" // msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} +// frame, _ := core.JSONMarshal(msg) +// _ = frame type MessageType string const ( - TypeProcessOutput MessageType = "process_output" // real-time process output line - TypeProcessStatus MessageType = "process_status" // process status change (running/exited) - TypeEvent MessageType = "event" // generic named event - TypeError MessageType = "error" // error message - TypePing MessageType = "ping" // client → server keepalive - TypePong MessageType = "pong" // server → client keepalive response - TypeSubscribe MessageType = "subscribe" // client requests channel subscription - TypeUnsubscribe MessageType = "unsubscribe" // client cancels channel subscription + TypeProcessOutput MessageType = "process_output" // stream a process line to clients + TypeProcessStatus MessageType = "process_status" // signal a process transition such as running or exited + TypeEvent MessageType = "event" // generic named event payload + TypeError MessageType = "error" // report an error envelope + TypePing MessageType = "ping" // client keepalive ping + TypePong MessageType = "pong" // server keepalive pong + TypeSubscribe MessageType = "subscribe" // request subscription to a channel + TypeUnsubscribe MessageType = "unsubscribe" // cancel a channel subscription ) // msg := stream.Message{ @@ -23,6 +25,9 @@ const ( // Channel: "hashrate", // Data: map[string]any{"h": 1234567}, // } +// +// frame, _ := core.JSONMarshal(msg) +// _ = frame type Message struct { Type MessageType `json:"type"` Channel string `json:"channel,omitempty"` diff --git a/stats.go b/stats.go index 2f8f672..5269837 100644 --- a/stats.go +++ b/stats.go @@ -9,11 +9,17 @@ package stream // // Example: peers=12 channels=4 type HubStats struct { // Peers is the number of currently connected peers across all transports. + // + // Example: peers=12 Peers int `json:"peers"` // Channels is the number of active named channels with at least one subscriber. + // + // Example: channels=4 Channels int `json:"channels"` // SubscriberCount maps channel name to subscriber count. + // + // Example: {"hashrate": 3, "block": 2} SubscriberCount map[string]int `json:"subscriber_count"` } From 0179542b46199399e755524514d5b4d928799634 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:13:42 +0000 Subject: [PATCH 053/140] style(stream): align pipe naming with AX Co-Authored-By: Virgil --- hub.go | 6 +++--- hub_config.go | 4 ++-- stream.go | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/hub.go b/hub.go index 866ffc9..7afd608 100644 --- a/hub.go +++ b/hub.go @@ -359,13 +359,13 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca return nil } -// Pipe connects this hub to dst: every frame published here is forwarded to dst. +// Pipe connects this hub to destination: every frame published here is forwarded to destination. // Returns a stop function. Satisfies Stream interface. // // stop := hub.Pipe(remoteHub) // defer stop() -func (hub *Hub) Pipe(dst Stream) func() { - return Pipe(hub, dst) +func (hub *Hub) Pipe(destination Stream) func() { + return Pipe(hub, destination) } // Stats returns a snapshot of current hub state. diff --git a/hub_config.go b/hub_config.go index c2f39c8..b508327 100644 --- a/hub_config.go +++ b/hub_config.go @@ -4,7 +4,7 @@ package stream import "time" -// cfg := stream.HubConfig{ +// config := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { @@ -46,7 +46,7 @@ type HubConfig struct { // DefaultHubConfig returns sensible defaults. // -// cfg := stream.DefaultHubConfig() +// config := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { return HubConfig{ HeartbeatInterval: 30 * time.Second, diff --git a/stream.go b/stream.go index c766fec..bc10ade 100644 --- a/stream.go +++ b/stream.go @@ -48,12 +48,12 @@ type Stream interface { // hub.Broadcast([]byte(`{"type":"shutdown"}`)) Broadcast(frame []byte) error - // Pipe connects this stream to dst: every frame published here is forwarded to dst. + // Pipe connects this stream to destination: every frame published here is forwarded to destination. // Returns a stop function. // // stop := hub.Pipe(remoteHub) // defer stop() - Pipe(dst Stream) func() + Pipe(destination Stream) func() // Stats returns a snapshot of current hub state. // @@ -226,8 +226,8 @@ type Envelope struct { // stop := stream.Pipe(zmqHub, wsHub) // Forward ZMQ frames to WebSocket clients. // defer stop() -func Pipe(src Stream, dst Stream) func() { - if src == nil || dst == nil || src == dst { +func Pipe(src Stream, destination Stream) func() { + if src == nil || destination == nil || src == destination { return func() {} } type publishSubscriber interface { @@ -239,19 +239,19 @@ func Pipe(src Stream, dst Stream) func() { stops := make([]func(), 0, 2) if publisher, ok := src.(publishSubscriber); ok { stops = append(stops, publisher.SubscribePublished(func(channel string, frame []byte) { - _ = dst.Publish(channel, cloneFrame(frame)) + _ = destination.Publish(channel, cloneFrame(frame)) })) } if broadcaster, ok := src.(broadcastSubscriber); ok { stops = append(stops, broadcaster.SubscribeBroadcast(func(frame []byte) { - _ = dst.Broadcast(cloneFrame(frame)) + _ = destination.Broadcast(cloneFrame(frame)) })) } if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back // to publishing on the wildcard channel. stop := src.Subscribe("*", func(frame []byte) { - _ = dst.Publish("*", cloneFrame(frame)) + _ = destination.Publish("*", cloneFrame(frame)) }) var once sync.Once return func() { From a04e4035330b1b02527b70edc446f64c34d8399e Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:17:14 +0000 Subject: [PATCH 054/140] style(stream): align public comments with AX Co-Authored-By: Virgil --- adapter/redis/redis.go | 33 ++++++++------------------------- adapter/sse/sse.go | 37 +++++++++++-------------------------- adapter/tcp/tcp.go | 26 +++++++++----------------- adapter/ws/ws.go | 35 +++++++++++------------------------ adapter/zmq/zmq.go | 23 ++++++----------------- hub_config.go | 30 +++++++++++++++--------------- 6 files changed, 60 insertions(+), 124 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 470ee6a..63c2b8a 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -17,10 +17,7 @@ import ( "dappco.re/go/stream" ) -// Config configures the Redis bridge. -// -// config := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} -// bridge, err := redis.NewBridge(hub, config) +// cfg := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} type Config struct { Addr string Password string @@ -29,11 +26,8 @@ type Config struct { TLSConfig *tls.Config } -// Bridge connects a Hub to Redis pub/sub for cross-instance messaging. -// -// config := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} -// bridge, err := redis.NewBridge(hub, config) -// go bridge.Start(ctx) +// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"}) +// go bridge.Start(ctx) type Bridge struct { hub *stream.Hub config Config @@ -53,10 +47,7 @@ type envelope struct { Frame []byte `json:"f"` } -// NewBridge creates and validates the Redis connection. -// -// config := redis.Config{Addr: "redis:6379", Prefix: "pool"} -// bridge, err := redis.NewBridge(hub, config) +// bridge, err := redis.NewBridge(hub, cfg) func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { if hub == nil { return nil, core.E("stream.redis", "nil hub", nil) @@ -83,9 +74,7 @@ func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { }, nil } -// Start begins the Redis pub/sub listener. -// -// go bridge.Start(ctx) +// go bridge.Start(ctx) func (bridge *Bridge) Start(ctx context.Context) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -171,9 +160,7 @@ func (bridge *Bridge) Start(ctx context.Context) error { } } -// Stop cleanly shuts down the bridge. -// -// defer bridge.Stop() +// defer bridge.Stop() func (bridge *Bridge) Stop() error { if bridge == nil { return nil @@ -210,9 +197,7 @@ func (bridge *Bridge) Stop() error { return nil } -// PublishToChannel publishes frame to a specific hub channel via Redis. -// -// _ = bridge.PublishToChannel("block", templateBytes) +// _ = bridge.PublishToChannel("block", templateBytes) func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -224,9 +209,7 @@ func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { return bridge.publish(bridge.channelKey(channel), frame) } -// PublishBroadcast publishes frame as a broadcast via Redis. -// -// _ = bridge.PublishBroadcast(shutdownFrame) +// _ = bridge.PublishBroadcast(shutdownFrame) func (bridge *Bridge) PublishBroadcast(frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 4188cc3..2eb0aa4 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -15,14 +15,11 @@ import ( "dappco.re/go/stream" ) -// Config configures the SSE adapter. -// -// config := sse.Config{ +// cfg := sse.Config{ // Authenticator: stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}), // HeartbeatInterval: 15 * time.Second, // RetryMs: 3000, // } -// adapter := sse.New(config) type Config struct { Authenticator stream.Authenticator OnAuthFailure func(r *http.Request, result stream.AuthResult) @@ -30,19 +27,15 @@ type Config struct { RetryMs int } -// Adapter is the SSE transport adapter for a stream.Hub. -// -// adapter := sse.New(sse.Config{}) -// adapter.Mount(hub) -// http.Handle("/stream/events", adapter.Handler()) +// adapter := sse.New(sse.Config{}) +// adapter.Mount(hub) +// http.Handle("/stream/events", adapter.Handler()) type Adapter struct { hub *stream.Hub config Config } -// New creates an SSE adapter. -// -// adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) +// adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) func New(config Config) *Adapter { if config.HeartbeatInterval == 0 { config.HeartbeatInterval = 15 * time.Second @@ -53,32 +46,24 @@ func New(config Config) *Adapter { return &Adapter{config: config} } -// Mount wires the adapter to a hub. -// -// adapter.Mount(hub) +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } -// ServeHTTP accepts an SSE connection and subscribes it using the channel query params. -// -// http.Handle("/stream/events", adapter.Handler()) -// http.Get("http://127.0.0.1:8080/stream/events?channel=hashrate") +// http.Handle("/stream/events", adapter.Handler()) +// http.Get("http://127.0.0.1:8080/stream/events?channel=hashrate") func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { adapter.serve(w, r, r.URL.Query()["channel"]) } -// Handler returns an http.HandlerFunc that accepts SSE connections. -// -// http.Handle("/stream/events", adapter.Handler()) -// http.Get("http://127.0.0.1:8080/stream/events?channel=hashrate") +// http.Handle("/stream/events", adapter.Handler()) +// http.Get("http://127.0.0.1:8080/stream/events?channel=hashrate") func (adapter *Adapter) Handler() http.HandlerFunc { return adapter.ServeHTTP } -// HandlerForChannel returns a handler that auto-subscribes all connections to channel. -// -// http.Handle("/stream/hashrate", adapter.HandlerForChannel("hashrate")) +// http.Handle("/stream/hashrate", adapter.HandlerForChannel("hashrate")) func (adapter *Adapter) HandlerForChannel(channel string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { adapter.serve(w, r, []string{channel}) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index a325655..92d13c7 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -23,10 +23,10 @@ const MaxFrameSize = 65535 const maxHandshakeFrameSize = 4 << 10 -// Config configures the TCP adapter. -// -// config := tcp.Config{Addr: ":9000", ConnAuthenticator: auth} -// adapter := tcp.New(config) +// cfg := tcp.Config{ +// Addr: ":9000", +// ConnAuthenticator: auth, +// } type Config struct { Addr string ConnAuthenticator stream.ConnAuthenticator @@ -34,7 +34,7 @@ type Config struct { TLS *tls.Config } -// Adapter is the raw TCP transport adapter. +// adapter := tcp.New(tcp.Config{Addr: ":9000", ConnAuthenticator: auth}) type Adapter struct { hub *stream.Hub config Config @@ -43,9 +43,7 @@ type Adapter struct { listener net.Listener } -// New creates a TCP adapter. -// -// adapter := tcp.New(tcp.Config{Addr: ":9000", ConnAuthenticator: auth}) +// adapter := tcp.New(tcp.Config{Addr: ":9000", ConnAuthenticator: auth}) func New(config Config) *Adapter { if config.HandshakeTimeout == 0 { config.HandshakeTimeout = 5 * time.Second @@ -53,16 +51,12 @@ func New(config Config) *Adapter { return &Adapter{config: config} } -// Mount wires the adapter to a hub. -// -// adapter.Mount(hub) +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } -// Listen starts the TCP accept loop. Blocks until ctx cancelled. -// -// go adapter.Listen(ctx) +// go adapter.Listen(ctx) func (adapter *Adapter) Listen(ctx context.Context) error { if adapter == nil { return core.E("stream.tcp", "nil adapter", nil) @@ -110,9 +104,7 @@ func (adapter *Adapter) Listen(ctx context.Context) error { } } -// Dial connects to a remote TCP stream endpoint. -// -// peer, err := adapter.Dial(ctx, hub) +// peer, err := adapter.Dial(ctx, hub) func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer, error) { if adapter == nil { return nil, core.E("stream.tcp", "nil adapter", nil) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 24da18a..bb0b0ea 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -19,9 +19,7 @@ import ( "dappco.re/go/stream" ) -// Config configures the WebSocket adapter. -// -// config := ws.Config{ +// cfg := ws.Config{ // Authenticator: stream.NewAPIKeyAuth(keys), // OnAuthFailure: func(r *http.Request, res stream.AuthResult) { // log.Printf("ws auth fail from %s", r.RemoteAddr) @@ -43,19 +41,15 @@ type Config struct { CheckOrigin func(r *http.Request) bool } -// Adapter is the WebSocket transport adapter for a stream.Hub. -// -// adapter := ws.New(ws.Config{...}) -// adapter.Mount(hub) -// http.Handle("/ws", adapter.Handler()) +// adapter := ws.New(ws.Config{Authenticator: auth}) +// adapter.Mount(hub) +// http.Handle("/ws", adapter.Handler()) type Adapter struct { hub *stream.Hub config Config } -// New creates a WebSocket adapter. -// -// adapter := ws.New(ws.Config{Authenticator: auth}) +// adapter := ws.New(ws.Config{Authenticator: auth}) func New(config Config) *Adapter { if config.ReadBufferSize == 0 { config.ReadBufferSize = 1024 @@ -66,19 +60,15 @@ func New(config Config) *Adapter { return &Adapter{config: config} } -// Mount wires the adapter to a hub. -// -// adapter.Mount(hub) +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } -// ServeHTTP upgrades the request to WebSocket and binds the connection to the mounted hub. -// -// http.Handle("/stream/ws", adapter.Handler()) +// http.Handle("/stream/ws", adapter.Handler()) // -// // Gin: -// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) +// Gin: +// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) func (adapter *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { adapter.serveHTTP(w, r, r.URL.Query()["channel"]) } @@ -190,11 +180,8 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe peer.Close() } -// Handler returns an http.HandlerFunc for WebSocket connections. -// -// http.Handle("/stream/ws", adapter.Handler()) -// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) -// +// http.Handle("/stream/ws", adapter.Handler()) +// r.GET("/stream/ws", gin.WrapF(adapter.Handler())) func (adapter *Adapter) Handler() http.HandlerFunc { return adapter.ServeHTTP } diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index a8deb7a..9377a52 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -37,14 +37,11 @@ const ( RolePuller ) -// Config configures the ZMQ adapter. -// -// config := zmq.Config{ +// cfg := zmq.Config{ // Mode: zmq.ModePubSub, // Endpoint: "tcp://127.0.0.1:5555", // Role: zmq.RoleSubscriber, // } -// adapter := zmq.New(config) type Config struct { Mode Mode Endpoint string @@ -60,7 +57,7 @@ type Config struct { HandshakeTimeout time.Duration } -// Adapter is the ZMQ transport adapter. +// adapter := zmq.New(zmq.Config{Mode: zmq.ModePubSub, Endpoint: "tcp://127.0.0.1:5555", Role: zmq.RoleSubscriber}) type Adapter struct { hub *stream.Hub config Config @@ -71,9 +68,7 @@ type Adapter struct { cancel context.CancelFunc } -// New creates a ZMQ adapter. -// -// adapter := zmq.New(zmq.Config{Mode: zmq.ModePubSub, Endpoint: "tcp://127.0.0.1:5555", Role: zmq.RoleSubscriber}) +// adapter := zmq.New(zmq.Config{Mode: zmq.ModePubSub, Endpoint: "tcp://127.0.0.1:5555", Role: zmq.RoleSubscriber}) func New(config Config) *Adapter { if config.HandshakeTimeout == 0 { config.HandshakeTimeout = 5 * time.Second @@ -81,16 +76,12 @@ func New(config Config) *Adapter { return &Adapter{config: config} } -// Mount wires the adapter to a hub. -// -// adapter.Mount(hub) +// adapter.Mount(hub) func (adapter *Adapter) Mount(hub *stream.Hub) { adapter.hub = hub } -// Start opens the ZMQ socket and begins receive/dispatch. Blocks until ctx cancelled. -// -// go adapter.Start(ctx) +// go adapter.Start(ctx) func (adapter *Adapter) Start(ctx context.Context) error { if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) @@ -185,9 +176,7 @@ func (adapter *Adapter) Start(ctx context.Context) error { } } -// Publish sends frame with topic (channel name) via the ZMQ socket. -// -// _ = adapter.Publish("block", templateBytes) +// _ = adapter.Publish("block", templateBytes) func (adapter *Adapter) Publish(channel string, frame []byte) error { if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) diff --git a/hub_config.go b/hub_config.go index b508327..94e11f7 100644 --- a/hub_config.go +++ b/hub_config.go @@ -4,38 +4,38 @@ package stream import "time" -// config := stream.HubConfig{ +// cfg := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, -// OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, +// OnConnect: func(peer *stream.Peer) { +// metrics.Inc("peers") +// }, // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { // return peer.Claims["role"] == "admin" || channel == "public" // }, // } type HubConfig struct { - // HeartbeatInterval is the server-side ping interval for WebSocket peers. - // Example: `HeartbeatInterval: 30 * time.Second`. - // Defaults to 30 seconds. Ignored by SSE and TCP adapters. + // HeartbeatInterval: 30 * time.Second keeps WebSocket clients alive. + // Ignored by SSE and TCP adapters. HeartbeatInterval time.Duration - // PongTimeout is the deadline after a ping before the WS connection is closed. - // Example: `PongTimeout: 60 * time.Second`. - // Must be greater than HeartbeatInterval. Defaults to 60 seconds. + // PongTimeout: 60 * time.Second closes stale WebSocket peers after a ping. + // Must be greater than HeartbeatInterval. PongTimeout time.Duration - // WriteTimeout is the per-write deadline for WS and TCP adapters. - // Example: `WriteTimeout: 10 * time.Second`. - // Defaults to 10 seconds. + // WriteTimeout: 10 * time.Second bounds each WebSocket or TCP write. WriteTimeout time.Duration - // OnConnect runs when a peer registers. + // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }. // // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, OnConnect func(peer *Peer) - // OnDisconnect runs when a peer unregisters. + // OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }. OnDisconnect func(peer *Peer) - // ChannelAuthoriser decides whether a peer may subscribe to a channel. + // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + // return peer.Claims["role"] == "admin" || channel == "public" + // }. // // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { // return peer.Claims["role"] == "admin" || channel == "public" @@ -46,7 +46,7 @@ type HubConfig struct { // DefaultHubConfig returns sensible defaults. // -// config := stream.DefaultHubConfig() +// cfg := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { return HubConfig{ HeartbeatInterval: 30 * time.Second, From 6327cc789e4396e89bb8a8cf51d1c88368d93f5e Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:21:02 +0000 Subject: [PATCH 055/140] style(stream): refine AX-facing examples Co-Authored-By: Virgil --- auth.go | 26 +++++++++++++++----------- hub_config.go | 38 +++++++++++++++++++------------------- message.go | 11 +++++------ 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/auth.go b/auth.go index 304c163..79bc4a0 100644 --- a/auth.go +++ b/auth.go @@ -16,18 +16,22 @@ type Authenticator interface { Authenticate(request *http.Request) AuthResult } -// result := stream.AuthResult{Valid: true, UserID: "user-42", Claims: map[string]any{"role": "admin"}} +// result := stream.AuthResult{ +// Valid: true, +// UserID: "user-42", +// Claims: map[string]any{"role": "admin"}, +// } type AuthResult struct { - // Valid indicates whether authentication succeeded. + // result := stream.AuthResult{Valid: true} Valid bool - // UserID is the authenticated user's identifier. + // result := stream.AuthResult{UserID: "user-42"} UserID string - // Claims holds arbitrary metadata (roles, scopes, tenant ID). + // result := stream.AuthResult{Claims: map[string]any{"role": "admin"}} Claims map[string]any - // Error holds the reason for failure, if any. + // result := stream.AuthResult{Error: ErrInvalidAPIKey} Error error } @@ -43,13 +47,13 @@ func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) A return authenticatorFunc(request) } -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) -// result := auth.Authenticate(r) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// result := auth.Authenticate(r) type APIKeyAuthenticator struct { Keys map[string]string } -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { if keys == nil { keys = map[string]string{} @@ -61,9 +65,9 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return &APIKeyAuthenticator{Keys: copied} } -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) -// request.Header.Set("Authorization", "Bearer sk-live") -// result := authenticator.Authenticate(request) +// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// request.Header.Set("Authorization", "Bearer sk-live") +// result := authenticator.Authenticate(request) func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) AuthResult { if authenticator == nil || request == nil { return AuthResult{Valid: false} diff --git a/hub_config.go b/hub_config.go index 94e11f7..1e302ba 100644 --- a/hub_config.go +++ b/hub_config.go @@ -6,40 +6,40 @@ import "time" // cfg := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, +// PongTimeout: 60 * time.Second, +// WriteTimeout: 10 * time.Second, // OnConnect: func(peer *stream.Peer) { // metrics.Inc("peers") // }, -// ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { -// return peer.Claims["role"] == "admin" || channel == "public" -// }, // } type HubConfig struct { - // HeartbeatInterval: 30 * time.Second keeps WebSocket clients alive. - // Ignored by SSE and TCP adapters. + // stream.NewHubWithConfig(stream.HubConfig{HeartbeatInterval: 30 * time.Second}) + // Keeps WebSocket peers alive. SSE and TCP adapters ignore it. HeartbeatInterval time.Duration - // PongTimeout: 60 * time.Second closes stale WebSocket peers after a ping. - // Must be greater than HeartbeatInterval. + // stream.NewHubWithConfig(stream.HubConfig{PongTimeout: 60 * time.Second}) + // Closes stale WebSocket peers after a ping. Keep it above HeartbeatInterval. PongTimeout time.Duration - // WriteTimeout: 10 * time.Second bounds each WebSocket or TCP write. + // stream.NewHubWithConfig(stream.HubConfig{WriteTimeout: 10 * time.Second}) + // Bounds each WebSocket or TCP write. WriteTimeout time.Duration - // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }. - // - // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, + // stream.NewHubWithConfig(stream.HubConfig{ + // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, + // }) OnConnect func(peer *Peer) - // OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }. + // stream.NewHubWithConfig(stream.HubConfig{ + // OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }, + // }) OnDisconnect func(peer *Peer) - // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { - // return peer.Claims["role"] == "admin" || channel == "public" - // }. - // - // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { - // return peer.Claims["role"] == "admin" || channel == "public" - // }, + // stream.NewHubWithConfig(stream.HubConfig{ + // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + // return peer.Claims["role"] == "admin" || channel == "public" + // }, + // }) // When nil, all subscriptions are allowed. ChannelAuthoriser func(peer *Peer, channel string) bool } diff --git a/message.go b/message.go index ff4ef90..6cd5321 100644 --- a/message.go +++ b/message.go @@ -4,9 +4,7 @@ package stream import "time" -// msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} -// frame, _ := core.JSONMarshal(msg) -// _ = frame +// messageType := stream.TypeEvent type MessageType string const ( @@ -21,9 +19,10 @@ const ( ) // msg := stream.Message{ -// Type: stream.TypeEvent, -// Channel: "hashrate", -// Data: map[string]any{"h": 1234567}, +// Type: stream.TypeEvent, +// Channel: "hashrate", +// Data: map[string]any{"h": 1234567}, +// Timestamp: time.Now().UTC(), // } // // frame, _ := core.JSONMarshal(msg) From cdfaff7284ade1f36db374ec143eb24ddb4b5c3a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:24:04 +0000 Subject: [PATCH 056/140] style(stream): align public comments with AX Co-Authored-By: Virgil --- auth.go | 1 - stream.go | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/auth.go b/auth.go index 79bc4a0..60d7c31 100644 --- a/auth.go +++ b/auth.go @@ -9,7 +9,6 @@ import ( ) // authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// request.Header.Set("Authorization", "Bearer sk-live") // return stream.AuthResult{Valid: true, UserID: "user-42"} // }) type Authenticator interface { diff --git a/stream.go b/stream.go index bc10ade..90d1627 100644 --- a/stream.go +++ b/stream.go @@ -24,11 +24,12 @@ import ( ) // Stream is the transport-agnostic event and data pipe. -// Consumers never import a specific adapter — they call Stream. // +// hub := stream.NewHub() // var s stream.Stream = hub -// s.Publish("hashrate", frame) -// s.Subscribe("block", handler) +// s.Publish("hashrate", []byte(`{"h":123456}`)) +// stop := s.Pipe(remoteHub) +// defer stop() type Stream interface { // Publish sends frame to all subscribers of channel. // Returns core.E if the hub is not running. @@ -48,10 +49,9 @@ type Stream interface { // hub.Broadcast([]byte(`{"type":"shutdown"}`)) Broadcast(frame []byte) error - // Pipe connects this stream to destination: every frame published here is forwarded to destination. - // Returns a stop function. + // Pipe forwards every published frame to destination. // - // stop := hub.Pipe(remoteHub) + // stop := localHub.Pipe(remoteHub) // defer stop() Pipe(destination Stream) func() From 1f428f61d006388ba26bda6c935fffaebd6eb221 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:26:27 +0000 Subject: [PATCH 057/140] fix(stream): make pipe stops idempotent Co-Authored-By: Virgil --- hub.go | 12 ++++++------ hub_test.go | 12 +++++++++--- stream.go | 36 ++++++++++++++++++++---------------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/hub.go b/hub.go index 7afd608..74f2372 100644 --- a/hub.go +++ b/hub.go @@ -218,7 +218,7 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() hub.handlers[channel][id] = handler hub.mu.Unlock() - return func() { + return onceFunc(func() { hub.mu.Lock() defer hub.mu.Unlock() if handlers := hub.handlers[channel]; handlers != nil { @@ -227,7 +227,7 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() delete(hub.handlers, channel) } } - }, nil + }), nil } // SubscribeE is a compatibility alias for SubscribeWithError. @@ -420,11 +420,11 @@ func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { hub.broadcastHandlers[id] = handler hub.mu.Unlock() - return func() { + return onceFunc(func() { hub.mu.Lock() defer hub.mu.Unlock() delete(hub.broadcastHandlers, id) - } + }) } // PeerCount returns the number of connected peers. @@ -755,11 +755,11 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { hub.publishers[id] = handler hub.mu.Unlock() - return func() { + return onceFunc(func() { hub.mu.Lock() defer hub.mu.Unlock() delete(hub.publishers, id) - } + }) } func (hub *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { diff --git a/hub_test.go b/hub_test.go index c613c83..71b5829 100644 --- a/hub_test.go +++ b/hub_test.go @@ -190,9 +190,15 @@ func TestHub_Pipe_Bad(t *testing.T) { }) defer unsubscribe() - stop() - // Idempotent teardown should be safe. - stop() + var stopWG sync.WaitGroup + for i := 0; i < 8; i++ { + stopWG.Add(1) + go func() { + defer stopWG.Done() + stop() + }() + } + stopWG.Wait() if err := sourceHub.Publish("block", []byte("template")); err != nil { t.Fatalf("Publish() error = %v", err) diff --git a/stream.go b/stream.go index 90d1627..7d4d4c7 100644 --- a/stream.go +++ b/stream.go @@ -238,14 +238,14 @@ func Pipe(src Stream, destination Stream) func() { } stops := make([]func(), 0, 2) if publisher, ok := src.(publishSubscriber); ok { - stops = append(stops, publisher.SubscribePublished(func(channel string, frame []byte) { + stops = append(stops, onceFunc(publisher.SubscribePublished(func(channel string, frame []byte) { _ = destination.Publish(channel, cloneFrame(frame)) - })) + }))) } if broadcaster, ok := src.(broadcastSubscriber); ok { - stops = append(stops, broadcaster.SubscribeBroadcast(func(frame []byte) { + stops = append(stops, onceFunc(broadcaster.SubscribeBroadcast(func(frame []byte) { _ = destination.Broadcast(cloneFrame(frame)) - })) + }))) } if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back @@ -253,19 +253,13 @@ func Pipe(src Stream, destination Stream) func() { stop := src.Subscribe("*", func(frame []byte) { _ = destination.Publish("*", cloneFrame(frame)) }) - var once sync.Once - return func() { - once.Do(stop) - } - } - var once sync.Once - return func() { - once.Do(func() { - for index := len(stops) - 1; index >= 0; index-- { - stops[index]() - } - }) + return onceFunc(stop) } + return onceFunc(func() { + for index := len(stops) - 1; index >= 0; index-- { + stops[index]() + } + }) } // Ensure Hub satisfies Stream at compile time. @@ -308,3 +302,13 @@ func cloneFrame(frame []byte) []byte { } return append([]byte(nil), frame...) } + +func onceFunc(fn func()) func() { + if fn == nil { + return func() {} + } + var once sync.Once + return func() { + once.Do(fn) + } +} From df12f2c9c1dfe43c70178c14eb7c66bc8afa483d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:29:22 +0000 Subject: [PATCH 058/140] style(stream): sharpen auth example Co-Authored-By: Virgil --- auth.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/auth.go b/auth.go index 60d7c31..bc8f900 100644 --- a/auth.go +++ b/auth.go @@ -47,7 +47,9 @@ func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) A } // auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) -// result := auth.Authenticate(r) +// request := httptest.NewRequest("GET", "/stream/ws", nil) +// request.Header.Set("Authorization", "Bearer sk-live") +// result := auth.Authenticate(request) type APIKeyAuthenticator struct { Keys map[string]string } From 61dc2d8ea8135f8c7847ee68406bff2ae18d2fe5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:33:32 +0000 Subject: [PATCH 059/140] Restore ws hub compatibility handlers --- ws/compat.go | 72 ++++++++++++++++++++++++++++++++++++++++++----- ws/compat_test.go | 38 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/ws/compat.go b/ws/compat.go index 1948d60..9ccd3b4 100644 --- a/ws/compat.go +++ b/ws/compat.go @@ -5,6 +5,10 @@ package ws import ( + "net/http" + "sync" + + "dappco.re/go/core" "dappco.re/go/stream" adapterredis "dappco.re/go/stream/adapter/redis" adapterws "dappco.re/go/stream/adapter/ws" @@ -19,9 +23,6 @@ type Frame = stream.Frame // Channel preserves the legacy channel name alias. type Channel = stream.Channel -// Hub preserves the legacy go-ws Hub type name. -type Hub = stream.Hub - // HubConfig preserves the legacy go-ws HubConfig type name. type HubConfig = stream.HubConfig @@ -121,9 +122,31 @@ type ReconnectConfig = adapterws.ReconnectConfig // RedisBridge preserves the legacy go-ws RedisBridge type name. type RedisBridge = adapterredis.Bridge +// Hub preserves the legacy go-ws Hub surface while embedding the new stream hub. +// +// hub := ws.NewHub() +// go hub.Run(ctx) +// http.Handle("/stream/ws", hub.Handler()) +type Hub struct { + *stream.Hub + + adapterOnce sync.Once + adapter *adapterws.Adapter +} + // NewRedisBridge creates the legacy Redis bridge wrapper. -func NewRedisBridge(hub *stream.Hub, config adapterredis.Config) (*RedisBridge, error) { - return adapterredis.NewBridge(hub, config) +func NewRedisBridge(hub any, config adapterredis.Config) (*RedisBridge, error) { + switch typedHub := hub.(type) { + case *Hub: + if typedHub == nil { + return adapterredis.NewBridge(nil, config) + } + return adapterredis.NewBridge(typedHub.Hub, config) + case *stream.Hub: + return adapterredis.NewBridge(typedHub, config) + default: + return nil, core.E("stream.ws", "unsupported hub type", nil) + } } // NewAPIKeyAuth creates the legacy-compatible API key authenticator wrapper. @@ -133,12 +156,12 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { // NewHub creates a legacy-compatible hub. func NewHub() *Hub { - return stream.NewHub() + return &Hub{Hub: stream.NewHub()} } // NewHubWithConfig creates a legacy-compatible hub with explicit configuration. func NewHubWithConfig(config HubConfig) *Hub { - return stream.NewHubWithConfig(config) + return &Hub{Hub: stream.NewHubWithConfig(config)} } // DefaultHubConfig returns the default hub configuration for legacy callers. @@ -165,3 +188,38 @@ func New(config Config) *Adapter { func NewReconnectingClient(config ReconnectConfig) *adapterws.ReconnectingClient { return adapterws.NewReconnectingClient(config) } + +// Handler preserves the old hub-bound WebSocket handler entrypoint. +// +// http.Handle("/stream/ws", hub.Handler()) +func (hub *Hub) Handler() http.HandlerFunc { + if hub == nil { + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "stream hub not mounted", http.StatusInternalServerError) + } + } + return hub.compatAdapter().Handler() +} + +// HandlerForChannel preserves the old dedicated-channel handler entrypoint. +// +// http.Handle("/stream/hashrate", hub.HandlerForChannel("hashrate")) +func (hub *Hub) HandlerForChannel(channel string) http.HandlerFunc { + if hub == nil { + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "stream hub not mounted", http.StatusInternalServerError) + } + } + return hub.compatAdapter().HandlerForChannel(channel) +} + +func (hub *Hub) compatAdapter() *adapterws.Adapter { + hub.adapterOnce.Do(func() { + adapter := adapterws.New(adapterws.Config{}) + adapter.Mount(hub.Hub) + hub.adapter = adapter + }) + return hub.adapter +} + +var _ Stream = (*Hub)(nil) diff --git a/ws/compat_test.go b/ws/compat_test.go index 1f8b885..9a2da1f 100644 --- a/ws/compat_test.go +++ b/ws/compat_test.go @@ -4,8 +4,11 @@ package ws import ( "context" + "net/http/httptest" "testing" "time" + + "github.com/gorilla/websocket" ) func TestCompat_LegacySurface_Good(t *testing.T) { @@ -104,6 +107,41 @@ func TestCompat_LegacySurface_Ugly(t *testing.T) { stop() } +func TestCompat_HubHandler_Good(t *testing.T) { + hub := NewHub() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + waitForRunningHub(t, hub) + + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + url := "ws" + server.URL[len("http"):] + "?channel=hashrate" + connection, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + payload := []byte(`{"type":"event","channel":"hashrate","data":{"h":123456},"timestamp":"2026-01-01T00:00:00Z"}`) + if err := hub.Publish("hashrate", payload); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + if err := connection.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline() error = %v", err) + } + _, frame, err := connection.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage() error = %v", err) + } + if string(frame) != string(payload) { + t.Fatalf("ReadMessage() frame = %q, want %q", string(frame), string(payload)) + } +} + func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) From 505ff76a4b1594a5cb57f6bd38d2361882c09ff6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:36:22 +0000 Subject: [PATCH 060/140] refactor(stream): clarify hub internals Co-Authored-By: Virgil --- hub.go | 58 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/hub.go b/hub.go index 74f2372..2b43959 100644 --- a/hub.go +++ b/hub.go @@ -22,14 +22,14 @@ import ( // http.Handle("/stream/ws", wsAdapter.Handler()) type Hub struct { peers map[*Peer]bool - broadcast chan broadcastDelivery + broadcastQueue chan broadcastDelivery deliver chan delivery register chan *Peer unregister chan *Peer channels map[string]map[*Peer]bool - handlers map[string]map[uint64]func([]byte) + channelHandlers map[string]map[uint64]func([]byte) broadcastHandlers map[uint64]func([]byte) - publishers map[uint64]func(string, []byte) + publishHandlers map[uint64]func(string, []byte) nextID uint64 config HubConfig done chan struct{} @@ -56,14 +56,14 @@ func NewHubWithConfig(config HubConfig) *Hub { config = normalizeHubConfig(config) return &Hub{ peers: map[*Peer]bool{}, - broadcast: make(chan broadcastDelivery, 256), + broadcastQueue: make(chan broadcastDelivery, 256), deliver: make(chan delivery, 256), register: make(chan *Peer, 256), unregister: make(chan *Peer, 256), channels: map[string]map[*Peer]bool{}, - handlers: map[string]map[uint64]func([]byte){}, + channelHandlers: map[string]map[uint64]func([]byte){}, broadcastHandlers: map[uint64]func([]byte){}, - publishers: map[uint64]func(string, []byte){}, + publishHandlers: map[uint64]func(string, []byte){}, config: config, done: make(chan struct{}), } @@ -127,7 +127,7 @@ func (hub *Hub) Run(ctx context.Context) { hub.addPeer(peer) case peer := <-hub.unregister: hub.removePeer(peer) - case item := <-hub.broadcast: + case item := <-hub.broadcastQueue: hub.broadcastToPeers(item.source, item.frame, item.notifyBroadcastSubscribers) case item := <-hub.deliver: hub.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) @@ -168,9 +168,9 @@ func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte hub.mu.RLock() running := hub.running peersToSend := hub.collectChannelPeersLocked(channel, source) - hasHandlers := len(hub.handlers[channel]) > 0 - hasWildcardHandlers := len(hub.handlers["*"]) > 0 && channel != "*" - hasPublishers := notifyPublishSubscribers && len(hub.publishers) > 0 + hasHandlers := len(hub.channelHandlers[channel]) > 0 + hasWildcardHandlers := len(hub.channelHandlers["*"]) > 0 && channel != "*" + hasPublishers := notifyPublishSubscribers && len(hub.publishHandlers) > 0 hub.mu.RUnlock() if !running { return ErrHubNotRunning @@ -204,27 +204,27 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() return func() {}, core.E("stream.hub", "nil handler", nil) } hub.mu.Lock() - if hub.handlers == nil { - hub.handlers = map[string]map[uint64]func([]byte){} + if hub.channelHandlers == nil { + hub.channelHandlers = map[string]map[uint64]func([]byte){} } if hub.channels == nil { hub.channels = map[string]map[*Peer]bool{} } hub.nextID++ id := hub.nextID - if hub.handlers[channel] == nil { - hub.handlers[channel] = map[uint64]func([]byte){} + if hub.channelHandlers[channel] == nil { + hub.channelHandlers[channel] = map[uint64]func([]byte){} } - hub.handlers[channel][id] = handler + hub.channelHandlers[channel][id] = handler hub.mu.Unlock() return onceFunc(func() { hub.mu.Lock() defer hub.mu.Unlock() - if handlers := hub.handlers[channel]; handlers != nil { + if handlers := hub.channelHandlers[channel]; handlers != nil { delete(handlers, id) if len(handlers) == 0 { - delete(hub.handlers, channel) + delete(hub.channelHandlers, channel) } } }), nil @@ -343,7 +343,7 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca return ErrHubNotRunning } select { - case hub.broadcast <- broadcastDelivery{ + case hub.broadcastQueue <- broadcastDelivery{ source: source, frame: append([]byte(nil), frame...), notifyBroadcastSubscribers: notifyBroadcastSubscribers, @@ -663,7 +663,7 @@ func (hub *Hub) broadcastToPeers(source *Peer, frame []byte, notifyBroadcastSubs } peers = append(peers, peer) } - handlers := cloneHandlers(hub.handlers["*"]) + handlers := cloneChannelHandlers(hub.channelHandlers["*"]) broadcastHandlers := cloneBroadcastHandlers(hub.broadcastHandlers) hub.mu.RUnlock() for _, peer := range peers { @@ -708,7 +708,7 @@ func (hub *Hub) enqueueBroadcast(item broadcastDelivery) { return } select { - case hub.broadcast <- item: + case hub.broadcastQueue <- item: case <-hub.done: } } @@ -728,9 +728,9 @@ func (hub *Hub) processDelivery(channel string, frame []byte, notifyPublishSubsc return } hub.mu.RLock() - handlers := cloneHandlers(hub.handlers[channel]) - wildcardHandlers := cloneHandlers(hub.handlers["*"]) - publishers := clonePublishHandlers(hub.publishers) + handlers := cloneChannelHandlers(hub.channelHandlers[channel]) + wildcardHandlers := cloneChannelHandlers(hub.channelHandlers["*"]) + publishHandlers := clonePublishHandlers(hub.publishHandlers) hub.mu.RUnlock() hub.invokeHandlers(handlers, frame) @@ -738,7 +738,7 @@ func (hub *Hub) processDelivery(channel string, frame []byte, notifyPublishSubsc hub.invokeHandlers(wildcardHandlers, frame) } if notifyPublishSubscribers { - hub.invokePublishHandlers(publishers, channel, frame) + hub.invokePublishHandlers(publishHandlers, channel, frame) } } @@ -747,18 +747,18 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { return func() {} } hub.mu.Lock() - if hub.publishers == nil { - hub.publishers = map[uint64]func(string, []byte){} + if hub.publishHandlers == nil { + hub.publishHandlers = map[uint64]func(string, []byte){} } hub.nextID++ id := hub.nextID - hub.publishers[id] = handler + hub.publishHandlers[id] = handler hub.mu.Unlock() return onceFunc(func() { hub.mu.Lock() defer hub.mu.Unlock() - delete(hub.publishers, id) + delete(hub.publishHandlers, id) }) } @@ -807,7 +807,7 @@ func (hub *Hub) collectChannelPeersLocked(channel string, source *Peer) []*Peer return peers } -func cloneHandlers(handlers map[uint64]func([]byte)) []func([]byte) { +func cloneChannelHandlers(handlers map[uint64]func([]byte)) []func([]byte) { if len(handlers) == 0 { return nil } From 5925a8e81a8b630323fb994fad0f54d7d123795d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:40:21 +0000 Subject: [PATCH 061/140] feat(stream): enforce channel authorisation at adapter edges Co-Authored-By: Virgil --- adapter/sse/sse.go | 11 ++++++ adapter/sse/sse_test.go | 28 +++++++++++++++ adapter/ws/ws.go | 36 ++++++++++++++++--- adapter/ws/ws_test.go | 78 +++++++++++++++++++++++++++++++++++++++++ hub.go | 22 ++++++++++++ hub_config.go | 7 +++- hub_test.go | 16 +++++++++ 7 files changed, 193 insertions(+), 5 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 2eb0aa4..bd68f7c 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -117,6 +117,17 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ close(done) }) }) + + for _, channel := range channels { + if channel == "" { + continue + } + if err := adapter.hub.CanSubscribePeer(peer, channel); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + _ = adapter.hub.AddPeer(peer) defer adapter.hub.RemovePeer(peer) diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 9f76fe5..b31f414 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -117,6 +117,34 @@ func TestAdapter_Handler_Bad(t *testing.T) { } } +func TestAdapter_Handler_ChannelAuthoriser_Bad(t *testing.T) { + hub := stream.NewHubWithConfig(stream.HubConfig{ + ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + return channel == "public" + }, + }) + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=private") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusForbidden { + t.Fatalf("StatusCode = %d, want %d", response.StatusCode, http.StatusForbidden) + } + waitForPeerCount(t, hub, 0) +} + func TestAdapter_Handler_Ugly(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index bb0b0ea..9e4eff9 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -100,6 +100,19 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } } + peer := stream.NewPeer("ws") + peer.UserID = authResult.UserID + peer.Claims = authResult.Claims + for _, channel := range channels { + if channel == "" { + continue + } + if err := adapter.hub.CanSubscribePeer(peer, channel); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + upgrader := websocket.Upgrader{ ReadBufferSize: adapter.config.ReadBufferSize, WriteBufferSize: adapter.config.WriteBufferSize, @@ -117,9 +130,6 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe return } - peer := stream.NewPeer("ws") - peer.UserID = authResult.UserID - peer.Claims = authResult.Claims peer.SetCloseHook(func() { _ = conn.Close() }) @@ -158,7 +168,14 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } switch message.Type { case stream.TypeSubscribe: - _ = adapter.hub.SubscribePeer(peer, message.Channel) + if err := adapter.hub.SubscribePeer(peer, message.Channel); err != nil { + _ = peer.Send(marshalMessage(stream.Message{ + Type: stream.TypeError, + Channel: message.Channel, + Data: errorPayload(err), + Timestamp: time.Now().UTC(), + })) + } case stream.TypeUnsubscribe: adapter.hub.UnsubscribePeer(peer, message.Channel) case stream.TypePing: @@ -216,3 +233,14 @@ func (adapter *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, write } } } + +func marshalMessage(message stream.Message) []byte { + return []byte(core.JSONMarshalString(message)) +} + +func errorPayload(err error) map[string]any { + if err == nil { + return nil + } + return map[string]any{"message": err.Error()} +} diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 4c7b7f0..6e175ec 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -123,6 +123,35 @@ func TestAdapter_Handler_Bad(t *testing.T) { } } +func TestAdapter_Handler_QueryChannelAuthoriser_Bad(t *testing.T) { + hub := stream.NewHubWithConfig(stream.HubConfig{ + ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + return channel == "public" + }, + }) + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + _, resp, err := websocket.DefaultDialer.Dial(websocketURL(server.URL)+"?channel=private", nil) + if err == nil { + t.Fatal("Dial() error = nil, want forbidden response") + } + if resp == nil { + t.Fatal("Dial() response = nil, want 403 response") + } + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("StatusCode = %d, want %d", resp.StatusCode, http.StatusForbidden) + } + waitForPeerCount(t, hub, 0) +} + func TestAdapter_Handler_Ugly(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) @@ -314,6 +343,55 @@ func TestAdapter_Handler_InboundPublish_NoSelfEcho_Good(t *testing.T) { _ = conn.SetReadDeadline(time.Time{}) } +func TestAdapter_Handler_SubscribeDenied_Bad(t *testing.T) { + hub := stream.NewHubWithConfig(stream.HubConfig{ + ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + return channel == "public" + }, + }) + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + conn := dialWebSocket(t, server.URL, nil) + defer conn.Close() + + if err := conn.WriteJSON(stream.Message{ + Type: stream.TypeSubscribe, + Channel: "private", + }); err != nil { + t.Fatalf("WriteJSON() error = %v", err) + } + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage() error = %v", err) + } + if messageType != websocket.TextMessage { + t.Fatalf("messageType = %d, want %d", messageType, websocket.TextMessage) + } + + var message stream.Message + if err := json.Unmarshal(payload, &message); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if message.Type != stream.TypeError { + t.Fatalf("message.Type = %q, want %q", message.Type, stream.TypeError) + } + if message.Channel != "private" { + t.Fatalf("message.Channel = %q, want %q", message.Channel, "private") + } + if hub.ChannelSubscriberCount("private") != 0 { + t.Fatalf("ChannelSubscriberCount(%q) = %d, want %d", "private", hub.ChannelSubscriberCount("private"), 0) + } +} + func TestAdapter_Handler_PeerClose_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) diff --git a/hub.go b/hub.go index 2b43959..f977026 100644 --- a/hub.go +++ b/hub.go @@ -281,6 +281,28 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { return nil } +// CanSubscribePeer reports whether peer may subscribe to channel. +// +// err := hub.CanSubscribePeer(peer, "hashrate") +// if err == stream.ErrAuthRejected { return } +func (hub *Hub) CanSubscribePeer(peer *Peer, channel string) error { + if hub == nil { + return core.E("stream.hub", "nil hub", nil) + } + if peer == nil { + return core.E("stream.hub", "nil peer", nil) + } + if channel == "" { + return ErrEmptyChannel + } + hub.mu.RLock() + defer hub.mu.RUnlock() + if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { + return ErrAuthRejected + } + return nil +} + // UnsubscribePeer removes peer from a named channel. // // hub.UnsubscribePeer(peer, "hashrate") diff --git a/hub_config.go b/hub_config.go index 1e302ba..a96f364 100644 --- a/hub_config.go +++ b/hub_config.go @@ -4,6 +4,11 @@ package stream import "time" +// authoriser := stream.ChannelAuthoriser(func(peer *stream.Peer, channel string) bool { +// return peer.Claims["role"] == "admin" || channel == "public" +// }) +type ChannelAuthoriser func(peer *Peer, channel string) bool + // cfg := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, // PongTimeout: 60 * time.Second, @@ -41,7 +46,7 @@ type HubConfig struct { // }, // }) // When nil, all subscriptions are allowed. - ChannelAuthoriser func(peer *Peer, channel string) bool + ChannelAuthoriser ChannelAuthoriser } // DefaultHubConfig returns sensible defaults. diff --git a/hub_test.go b/hub_test.go index 71b5829..5538fa6 100644 --- a/hub_test.go +++ b/hub_test.go @@ -539,6 +539,22 @@ func TestHub_SubscribeE_Ugly(t *testing.T) { t.Fatalf("SubscribeE panic handler count = %d, want 1", panicked) } +func TestHub_CanSubscribePeer_Bad(t *testing.T) { + hub := NewHubWithConfig(HubConfig{ + ChannelAuthoriser: func(peer *Peer, channel string) bool { + return channel == "public" + }, + }) + + peer := NewPeer("ws") + if err := hub.CanSubscribePeer(peer, "private"); err != ErrAuthRejected { + t.Fatalf("CanSubscribePeer() error = %v, want %v", err, ErrAuthRejected) + } + if err := hub.CanSubscribePeer(peer, "public"); err != nil { + t.Fatalf("CanSubscribePeer() error = %v, want nil", err) + } +} + func TestHub_SendToChannel_Wildcard_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) From f85991997d6e097f1726a1509cf47f3785647e4b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:43:13 +0000 Subject: [PATCH 062/140] fix(tcp): handle nil dial context --- adapter/tcp/tcp.go | 6 +++++ adapter/tcp/tcp_test.go | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 92d13c7..5e3fcef 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -109,6 +109,9 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer if adapter == nil { return nil, core.E("stream.tcp", "nil adapter", nil) } + if ctx == nil { + ctx = context.Background() + } if hub == nil { hub = adapter.hub } @@ -152,6 +155,9 @@ func (adapter *Adapter) listen() (net.Listener, error) { } func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { + if ctx == nil { + ctx = context.Background() + } dialer := &net.Dialer{} if adapter.config.TLS != nil { conn, err := dialer.DialContext(ctx, "tcp", adapter.config.Addr) diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 7d82e58..72a8b0f 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -257,6 +257,58 @@ func TestTCP_Listen_HandshakeTooLarge_Good(t *testing.T) { waitForPeerCount(t, hub, 0) } +func TestTCP_Dial_NilContext_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + defer listener.Close() + + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + _, _ = connection.Write(encodeFrame("block", []byte("template"))) + time.Sleep(50 * time.Millisecond) + }() + + adapter := New(Config{Addr: listener.Addr().String()}) + peer, err := adapter.Dial(nil, hub) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + if peer == nil { + t.Fatal("Dial() peer = nil") + } + defer peer.Close() + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for dialed frame") + } + + <-serverDone +} + func waitForListenerAddress(t *testing.T, adapter *Adapter) string { t.Helper() deadline := time.Now().Add(2 * time.Second) From 4b4f49ba6e5a03a97274b42f4a1bfe45d7e22b6d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:45:28 +0000 Subject: [PATCH 063/140] docs(stream): align public comments with AX Co-Authored-By: Virgil --- hub.go | 3 +-- stream.go | 16 +++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/hub.go b/hub.go index f977026..f20504d 100644 --- a/hub.go +++ b/hub.go @@ -11,8 +11,7 @@ import ( "dappco.re/go/core" ) -// Hub is the central channel-based broker. Transport adapters register peers into -// the hub; the hub serialises all state mutations through Go channels. +// Hub is the central channel-based broker. // // hub := stream.NewHub() // go hub.Run(ctx) diff --git a/stream.go b/stream.go index 7d4d4c7..5a6a31d 100644 --- a/stream.go +++ b/stream.go @@ -1,14 +1,12 @@ // SPDX-License-Identifier: EUPL-1.2 // Package stream is the transport-agnostic event and data pipe for the CoreGO -// ecosystem. It generalises WebSocket, SSE, Redis pub/sub, ZeroMQ, and raw TCP -// behind a single Stream interface. Consumers never import a specific transport — -// they call Stream. Transport adapters are wired at startup. +// ecosystem. // // hub := stream.NewHub() // go hub.Run(ctx) // hub.Publish("hashrate", []byte(`{"h":123456}`)) -// unsub := hub.Subscribe("block", func(f []byte) { handleBlock(f) }) +// unsub := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) // defer unsub() package stream @@ -68,7 +66,7 @@ type Frame = []byte // Channel is a named topic string used for pub/sub routing. type Channel = string -// Peer represents one connected endpoint. Created by a transport adapter. +// Peer represents one connected endpoint. // // peer := stream.NewPeer("ws") // peer.UserID = authResult.UserID @@ -219,13 +217,13 @@ type Envelope struct { Frame []byte } -// Pipe connects src to dst: published frames are forwarded with their channel, -// and broadcast frames are forwarded as broadcasts when the source exposes that hook. -// Returns a stop function. Safe to call from multiple goroutines. +// Pipe connects src to dst. // // stop := stream.Pipe(zmqHub, wsHub) -// Forward ZMQ frames to WebSocket clients. // defer stop() +// +// Published frames keep their channel. Broadcast frames stay broadcasts when the +// source exposes that hook. func Pipe(src Stream, destination Stream) func() { if src == nil || destination == nil || src == destination { return func() {} From fa5e3773fee61ae434bcc4989154dfc2ff3d0472 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:48:07 +0000 Subject: [PATCH 064/140] docs(stream): add usage examples for sentinel errors Co-Authored-By: Virgil --- errors.go | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/errors.go b/errors.go index 4eb3bc8..f0ef94e 100644 --- a/errors.go +++ b/errors.go @@ -10,25 +10,38 @@ import "dappco.re/go/core" // return // } var ( - // ErrMissingAuthHeader is returned when no Authorization header is present. + // if err := auth.Authenticate(request); err == stream.ErrMissingAuthHeader { + // http.Error(w, "missing auth", http.StatusUnauthorized) + // } ErrMissingAuthHeader = core.E("stream.auth", "missing Authorization header", nil) - // ErrMalformedAuthHeader is returned when the header is not "Bearer ". + // if err := auth.Authenticate(request); err == stream.ErrMalformedAuthHeader { + // http.Error(w, "bad auth header", http.StatusUnauthorized) + // } ErrMalformedAuthHeader = core.E("stream.auth", "malformed Authorization header", nil) - // ErrInvalidAPIKey is returned when the API key is not in the key map. + // if err := auth.Authenticate(request); err == stream.ErrInvalidAPIKey { + // http.Error(w, "unknown key", http.StatusUnauthorized) + // } ErrInvalidAPIKey = core.E("stream.auth", "invalid API key", nil) - // ErrHandshakeTimeout is returned when the TCP/ZMQ peer did not send a - // handshake within the configured deadline. + // if err := adapter.Listen(ctx); err == stream.ErrHandshakeTimeout { + // return + // } ErrHandshakeTimeout = core.E("stream.auth", "handshake timeout", nil) - // ErrAuthRejected is returned when ConnAuthenticator denies the handshake. + // if err := adapter.Listen(ctx); err == stream.ErrAuthRejected { + // return + // } ErrAuthRejected = core.E("stream.auth", "connection rejected by authenticator", nil) - // ErrHubNotRunning is returned when Publish or Broadcast is called before Run. + // if err := hub.Publish("hashrate", frame); err == stream.ErrHubNotRunning { + // go hub.Run(ctx) + // } ErrHubNotRunning = core.E("stream.hub", "hub not running", nil) - // ErrEmptyChannel is returned when Subscribe is called with an empty channel name. + // if _, err := hub.SubscribeE("", func([]byte) {}); err == stream.ErrEmptyChannel { + // return + // } ErrEmptyChannel = core.E("stream.hub", "empty channel", nil) ) From 709a1df306781126cebb838ac94c43f71adf717b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:50:44 +0000 Subject: [PATCH 065/140] style(stream): clarify hub publish queue naming Co-Authored-By: Virgil --- hub.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/hub.go b/hub.go index f20504d..1c83c0c 100644 --- a/hub.go +++ b/hub.go @@ -22,7 +22,7 @@ import ( type Hub struct { peers map[*Peer]bool broadcastQueue chan broadcastDelivery - deliver chan delivery + publishQueue chan publishDelivery register chan *Peer unregister chan *Peer channels map[string]map[*Peer]bool @@ -56,7 +56,7 @@ func NewHubWithConfig(config HubConfig) *Hub { return &Hub{ peers: map[*Peer]bool{}, broadcastQueue: make(chan broadcastDelivery, 256), - deliver: make(chan delivery, 256), + publishQueue: make(chan publishDelivery, 256), register: make(chan *Peer, 256), unregister: make(chan *Peer, 256), channels: map[string]map[*Peer]bool{}, @@ -128,8 +128,8 @@ func (hub *Hub) Run(ctx context.Context) { hub.removePeer(peer) case item := <-hub.broadcastQueue: hub.broadcastToPeers(item.source, item.frame, item.notifyBroadcastSubscribers) - case item := <-hub.deliver: - hub.processDelivery(item.channel, item.frame, item.notifyPublishSubscribers) + case item := <-hub.publishQueue: + hub.processPublishDelivery(item.channel, item.frame, item.notifyPublishSubscribers) } } } @@ -180,7 +180,7 @@ func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte for _, peer := range peersToSend { hub.sendToPeer(peer, channel, frame) } - hub.enqueueDelivery(channel, frame, notifyPublishSubscribers) + hub.enqueuePublishDelivery(channel, frame, notifyPublishSubscribers) return nil } @@ -696,7 +696,7 @@ func (hub *Hub) broadcastToPeers(source *Peer, frame []byte, notifyBroadcastSubs } } -type delivery struct { +type publishDelivery struct { channel string frame []byte notifyPublishSubscribers bool @@ -708,19 +708,19 @@ type broadcastDelivery struct { notifyBroadcastSubscribers bool } -func (hub *Hub) enqueueDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { +func (hub *Hub) enqueuePublishDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { if hub == nil { return } - item := delivery{ + item := publishDelivery{ channel: channel, frame: append([]byte(nil), frame...), notifyPublishSubscribers: notifyPublishSubscribers, } select { - case hub.deliver <- item: + case hub.publishQueue <- item: default: - go hub.enqueueDeliveryAsync(item) + go hub.enqueuePublishDeliveryAsync(item) } } @@ -734,17 +734,17 @@ func (hub *Hub) enqueueBroadcast(item broadcastDelivery) { } } -func (hub *Hub) enqueueDeliveryAsync(item delivery) { +func (hub *Hub) enqueuePublishDeliveryAsync(item publishDelivery) { if hub == nil { return } select { - case hub.deliver <- item: + case hub.publishQueue <- item: case <-hub.done: } } -func (hub *Hub) processDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { +func (hub *Hub) processPublishDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { if hub == nil { return } From ba8841da9dca3fca787bfc3101d1e4820ba3fea8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:53:30 +0000 Subject: [PATCH 066/140] style(stream): align message docs with AX Co-Authored-By: Virgil --- example_test.go | 21 +++++++++++++++++++++ message.go | 16 ++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/example_test.go b/example_test.go index 6af80f5..6199ad5 100644 --- a/example_test.go +++ b/example_test.go @@ -95,3 +95,24 @@ func ExampleNewAPIKeyAuth() { // Output: // true user-42 } + +func ExampleMessage() { + msg := stream.Message{ + Type: stream.TypeEvent, + Channel: "hashrate", + ProcessID: "agent-42", + Data: map[string]any{"h": 1234567}, + } + + fmt.Println(msg.Type, msg.Channel, msg.ProcessID, msg.Data) + + // Output: + // event hashrate agent-42 map[h:1234567] +} + +func ExampleMessageType() { + fmt.Println(stream.TypeSubscribe) + + // Output: + // subscribe +} diff --git a/message.go b/message.go index 6cd5321..935ecb7 100644 --- a/message.go +++ b/message.go @@ -8,14 +8,14 @@ import "time" type MessageType string const ( - TypeProcessOutput MessageType = "process_output" // stream a process line to clients - TypeProcessStatus MessageType = "process_status" // signal a process transition such as running or exited - TypeEvent MessageType = "event" // generic named event payload - TypeError MessageType = "error" // report an error envelope - TypePing MessageType = "ping" // client keepalive ping - TypePong MessageType = "pong" // server keepalive pong - TypeSubscribe MessageType = "subscribe" // request subscription to a channel - TypeUnsubscribe MessageType = "unsubscribe" // cancel a channel subscription + TypeProcessOutput MessageType = "process_output" // msg := stream.Message{Type: stream.TypeProcessOutput, ProcessID: "build-123"} + TypeProcessStatus MessageType = "process_status" // msg := stream.Message{Type: stream.TypeProcessStatus, ProcessID: "build-123"} + TypeEvent MessageType = "event" // msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} + TypeError MessageType = "error" // msg := stream.Message{Type: stream.TypeError, Data: "unauthorised"} + TypePing MessageType = "ping" // msg := stream.Message{Type: stream.TypePing, ProcessID: "client-1"} + TypePong MessageType = "pong" // reply := stream.Message{Type: stream.TypePong, ProcessID: "client-1"} + TypeSubscribe MessageType = "subscribe" // msg := stream.Message{Type: stream.TypeSubscribe, Channel: "block"} + TypeUnsubscribe MessageType = "unsubscribe" // msg := stream.Message{Type: stream.TypeUnsubscribe, Channel: "block"} ) // msg := stream.Message{ From b02dd1289bdef3365f7cea17a33f1de8335f7fff Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:56:28 +0000 Subject: [PATCH 067/140] style(stream): align public comments with AX Co-Authored-By: Virgil --- hub.go | 4 ++-- stream.go | 20 +++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/hub.go b/hub.go index 1c83c0c..7ee2142 100644 --- a/hub.go +++ b/hub.go @@ -391,8 +391,8 @@ func (hub *Hub) Pipe(destination Stream) func() { // Stats returns a snapshot of current hub state. // -// s := hub.Stats() -// core.Print("stream", "peers=%d channels=%d", s.Peers, s.Channels) +// stats := hub.Stats() +// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) func (hub *Hub) Stats() HubStats { if hub == nil { return HubStats{} diff --git a/stream.go b/stream.go index 5a6a31d..3e505a8 100644 --- a/stream.go +++ b/stream.go @@ -24,38 +24,40 @@ import ( // Stream is the transport-agnostic event and data pipe. // // hub := stream.NewHub() -// var s stream.Stream = hub -// s.Publish("hashrate", []byte(`{"h":123456}`)) -// stop := s.Pipe(remoteHub) +// var streamBus stream.Stream = hub +// streamBus.Publish("hashrate", []byte(`{"h":123456}`)) +// stop := streamBus.Pipe(remoteHub) // defer stop() type Stream interface { // Publish sends frame to all subscribers of channel. - // Returns core.E if the hub is not running. // // hub.Publish("hashrate", []byte(`{"h":123456}`)) + // Publish(channel string, frame []byte) error // Subscribe registers handler for all frames arriving on channel. - // Returns an unsubscribe function. Safe to call from multiple goroutines. // - // unsub := hub.Subscribe("block", func(f []byte) { ... }) - // defer unsub() + // unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) + // defer unsubscribe() + // Subscribe(channel string, handler func([]byte)) func() // Broadcast sends frame to every connected peer regardless of subscriptions. // // hub.Broadcast([]byte(`{"type":"shutdown"}`)) + // Broadcast(frame []byte) error // Pipe forwards every published frame to destination. // // stop := localHub.Pipe(remoteHub) // defer stop() + // Pipe(destination Stream) func() // Stats returns a snapshot of current hub state. // - // s := hub.Stats() + // stats := hub.Stats() Stats() HubStats } @@ -106,7 +108,7 @@ func NewPeer(transport string) *Peer { // Subscriptions returns a copy of this peer's current channel subscriptions. // -// channels := peer.Subscriptions() // ["hashrate", "block"] +// channels := peer.Subscriptions() // ["hashrate", "block"] func (peer *Peer) Subscriptions() []string { if peer == nil { return nil From 3ae382cd5046f93274768a220e09f737f5801e11 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:00:23 +0000 Subject: [PATCH 068/140] Add TCP reconnect state tracking --- adapter/tcp/reconnect.go | 43 ++++++++++++++++++- adapter/tcp/tcp_test.go | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 3ac4a21..7e76338 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -10,12 +10,16 @@ import ( "time" "dappco.re/go/core" + "dappco.re/go/stream" ) // ReconnectConfig configures the client-side reconnecting TCP connection. // // client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{ // Addr: "127.0.0.1:9000", +// OnReconnect: func(attempt int) { +// core.Print(nil, "tcp reconnect attempt=%d", attempt) +// }, // OnMessage: func(channel string, frame []byte) { // _ = channel // _ = frame @@ -30,6 +34,7 @@ type ReconnectConfig struct { TLS *tls.Config OnConnect func() OnDisconnect func() + OnReconnect func(attempt int) OnMessage func(channel string, frame []byte) } @@ -41,6 +46,7 @@ type ReconnectingTCP struct { mu sync.RWMutex conn net.Conn + state stream.ConnectionState closed bool } @@ -55,7 +61,10 @@ func NewReconnectingTCP(config ReconnectConfig) *ReconnectingTCP { if config.BackoffMultiplier <= 0 { config.BackoffMultiplier = 2 } - return &ReconnectingTCP{config: config} + return &ReconnectingTCP{ + config: config, + state: stream.StateDisconnected, + } } // err := client.Connect(ctx) @@ -74,12 +83,17 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { return nil } + client.setState(stream.StateConnecting) conn, err := client.dial(ctx) if err != nil { attempt++ + client.setState(stream.StateDisconnected) if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { return err } + if client.config.OnReconnect != nil { + client.config.OnReconnect(attempt) + } if err := sleepContext(ctx, backoff); err != nil { return err } @@ -97,6 +111,7 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { readErr := client.readLoop(ctx, conn) client.clearConn(conn) + client.setState(stream.StateDisconnected) _ = conn.Close() if client.config.OnDisconnect != nil { client.config.OnDisconnect() @@ -113,6 +128,9 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { return readErr } + if client.config.OnReconnect != nil { + client.config.OnReconnect(attempt) + } if err := sleepContext(ctx, backoff); err != nil { return err } @@ -136,6 +154,20 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { return writeFull(conn, encodeFrame(channel, frame)) } +// State reports whether the reconnecting client is disconnected, connecting, or connected. +// +// if client.State() == stream.StateConnected { +// _ = client.Send("vpn:peer-abc123", encryptedPacket) +// } +func (client *ReconnectingTCP) State() stream.ConnectionState { + if client == nil { + return stream.StateDisconnected + } + client.mu.RLock() + defer client.mu.RUnlock() + return client.state +} + // _ = client.Close() func (client *ReconnectingTCP) Close() error { if client == nil { @@ -145,6 +177,7 @@ func (client *ReconnectingTCP) Close() error { client.closed = true conn := client.conn client.conn = nil + client.state = stream.StateDisconnected client.mu.Unlock() if conn != nil { return conn.Close() @@ -189,6 +222,7 @@ func (client *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) erro func (client *ReconnectingTCP) setConn(conn net.Conn) { client.mu.Lock() client.conn = conn + client.state = stream.StateConnected client.mu.Unlock() } @@ -196,10 +230,17 @@ func (client *ReconnectingTCP) clearConn(conn net.Conn) { client.mu.Lock() if client.conn == conn { client.conn = nil + client.state = stream.StateDisconnected } client.mu.Unlock() } +func (client *ReconnectingTCP) setState(state stream.ConnectionState) { + client.mu.Lock() + client.state = state + client.mu.Unlock() +} + func (client *ReconnectingTCP) isClosed() bool { client.mu.RLock() defer client.mu.RUnlock() diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 72a8b0f..403d7f4 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -6,6 +6,7 @@ import ( "context" "io" "net" + "sync/atomic" "testing" "time" @@ -309,6 +310,95 @@ func TestTCP_Dial_NilContext_Good(t *testing.T) { <-serverDone } +func TestReconnectingTCP_State_Good(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + defer listener.Close() + + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + time.Sleep(100 * time.Millisecond) + }() + + client := NewReconnectingTCP(ReconnectConfig{ + Addr: listener.Addr().String(), + InitialBackoff: 10 * time.Millisecond, + MaxBackoff: 10 * time.Millisecond, + }) + if client.State() != stream.StateDisconnected { + t.Fatalf("State() = %v, want %v", client.State(), stream.StateDisconnected) + } + + connectContext, connectCancel := context.WithCancel(context.Background()) + defer connectCancel() + connectDone := make(chan error, 1) + go func() { + connectDone <- client.Connect(connectContext) + }() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if client.State() == stream.StateConnected { + break + } + time.Sleep(10 * time.Millisecond) + } + if client.State() != stream.StateConnected { + t.Fatalf("State() = %v, want %v", client.State(), stream.StateConnected) + } + + connectCancel() + select { + case err := <-connectDone: + if err != nil { + t.Fatalf("Connect() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Connect() to return") + } + + if err := client.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if client.State() != stream.StateDisconnected { + t.Fatalf("State() = %v, want %v", client.State(), stream.StateDisconnected) + } + + <-serverDone +} + +func TestReconnectingTCP_OnReconnect_Good(t *testing.T) { + var reconnectCount atomic.Int32 + client := NewReconnectingTCP(ReconnectConfig{ + Addr: "127.0.0.1:1", + InitialBackoff: 10 * time.Millisecond, + MaxBackoff: 10 * time.Millisecond, + MaxRetries: 1, + OnReconnect: func(attempt int) { + reconnectCount.Store(int32(attempt)) + }, + }) + + err := client.Connect(context.Background()) + if err == nil { + t.Fatal("Connect() error = nil, want dial error") + } + if reconnectCount.Load() != 1 { + t.Fatalf("OnReconnect attempt = %d, want %d", reconnectCount.Load(), 1) + } + if client.State() != stream.StateDisconnected { + t.Fatalf("State() = %v, want %v", client.State(), stream.StateDisconnected) + } +} + func waitForListenerAddress(t *testing.T, adapter *Adapter) string { t.Helper() deadline := time.Now().Add(2 * time.Second) From a5f59195a2bd5d380da53291115aadb406d70165 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:03:59 +0000 Subject: [PATCH 069/140] style(stream): align config comments with AX Co-Authored-By: Virgil --- adapter/sse/sse.go | 13 ++++++++++--- adapter/tcp/tcp.go | 13 ++++++++++--- adapter/ws/ws.go | 9 ++++----- hub_config.go | 20 +++++--------------- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index bd68f7c..b3f9ddc 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -21,10 +21,17 @@ import ( // RetryMs: 3000, // } type Config struct { - Authenticator stream.Authenticator - OnAuthFailure func(r *http.Request, result stream.AuthResult) + // sse.New(sse.Config{Authenticator: stream.NewAPIKeyAuth(keys)}) + Authenticator stream.Authenticator + + // sse.New(sse.Config{OnAuthFailure: func(r *http.Request, result stream.AuthResult) { ... }}) + OnAuthFailure func(r *http.Request, result stream.AuthResult) + + // sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) HeartbeatInterval time.Duration - RetryMs int + + // sse.New(sse.Config{RetryMs: 3000}) + RetryMs int } // adapter := sse.New(sse.Config{}) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 5e3fcef..098abdf 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -28,10 +28,17 @@ const maxHandshakeFrameSize = 4 << 10 // ConnAuthenticator: auth, // } type Config struct { - Addr string + // tcp.New(tcp.Config{Addr: ":9000"}) + Addr string + + // tcp.New(tcp.Config{ConnAuthenticator: auth}) ConnAuthenticator stream.ConnAuthenticator - HandshakeTimeout time.Duration - TLS *tls.Config + + // tcp.New(tcp.Config{HandshakeTimeout: 5 * time.Second}) + HandshakeTimeout time.Duration + + // tcp.New(tcp.Config{TLS: &tls.Config{}}) + TLS *tls.Config } // adapter := tcp.New(tcp.Config{Addr: ":9000", ConnAuthenticator: auth}) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 9e4eff9..3b3423a 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -26,18 +26,17 @@ import ( // }, // } type Config struct { - // Authenticator is called during HTTP upgrade. When nil, all connections accepted. + // ws.New(ws.Config{Authenticator: stream.NewAPIKeyAuth(keys)}) Authenticator stream.Authenticator - // OnAuthFailure is called when Authenticator rejects a connection. + // ws.New(ws.Config{OnAuthFailure: func(r *http.Request, result stream.AuthResult) { ... }}) OnAuthFailure func(r *http.Request, result stream.AuthResult) - // ReadBufferSize and WriteBufferSize are passed to the gorilla upgrader. - // Default: 1024 each. + // ws.New(ws.Config{ReadBufferSize: 1024, WriteBufferSize: 1024}) ReadBufferSize int WriteBufferSize int - // CheckOrigin overrides the upgrader's origin check. When nil, all origins accepted. + // ws.New(ws.Config{CheckOrigin: func(r *http.Request) bool { return true }}) CheckOrigin func(r *http.Request) bool } diff --git a/hub_config.go b/hub_config.go index a96f364..c748e1d 100644 --- a/hub_config.go +++ b/hub_config.go @@ -19,33 +19,23 @@ type ChannelAuthoriser func(peer *Peer, channel string) bool // } type HubConfig struct { // stream.NewHubWithConfig(stream.HubConfig{HeartbeatInterval: 30 * time.Second}) - // Keeps WebSocket peers alive. SSE and TCP adapters ignore it. HeartbeatInterval time.Duration // stream.NewHubWithConfig(stream.HubConfig{PongTimeout: 60 * time.Second}) - // Closes stale WebSocket peers after a ping. Keep it above HeartbeatInterval. PongTimeout time.Duration // stream.NewHubWithConfig(stream.HubConfig{WriteTimeout: 10 * time.Second}) - // Bounds each WebSocket or TCP write. WriteTimeout time.Duration - // stream.NewHubWithConfig(stream.HubConfig{ - // OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }, - // }) + // stream.NewHubWithConfig(stream.HubConfig{OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }}) OnConnect func(peer *Peer) - // stream.NewHubWithConfig(stream.HubConfig{ - // OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }, - // }) + // stream.NewHubWithConfig(stream.HubConfig{OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }}) OnDisconnect func(peer *Peer) - // stream.NewHubWithConfig(stream.HubConfig{ - // ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { - // return peer.Claims["role"] == "admin" || channel == "public" - // }, - // }) - // When nil, all subscriptions are allowed. + // stream.NewHubWithConfig(stream.HubConfig{ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + // return peer.Claims["role"] == "admin" || channel == "public" + // }}) ChannelAuthoriser ChannelAuthoriser } From b193fca17b78d57642cd771384072f80feb7a5d3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:06:38 +0000 Subject: [PATCH 070/140] style(stream): improve AX naming in pipe and message examples Co-Authored-By: Virgil --- message.go | 14 +++++++------- stream.go | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/message.go b/message.go index 935ecb7..d9a1dad 100644 --- a/message.go +++ b/message.go @@ -8,14 +8,14 @@ import "time" type MessageType string const ( - TypeProcessOutput MessageType = "process_output" // msg := stream.Message{Type: stream.TypeProcessOutput, ProcessID: "build-123"} - TypeProcessStatus MessageType = "process_status" // msg := stream.Message{Type: stream.TypeProcessStatus, ProcessID: "build-123"} - TypeEvent MessageType = "event" // msg := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} - TypeError MessageType = "error" // msg := stream.Message{Type: stream.TypeError, Data: "unauthorised"} - TypePing MessageType = "ping" // msg := stream.Message{Type: stream.TypePing, ProcessID: "client-1"} + TypeProcessOutput MessageType = "process_output" // message := stream.Message{Type: stream.TypeProcessOutput, ProcessID: "build-123"} + TypeProcessStatus MessageType = "process_status" // message := stream.Message{Type: stream.TypeProcessStatus, ProcessID: "build-123"} + TypeEvent MessageType = "event" // message := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} + TypeError MessageType = "error" // message := stream.Message{Type: stream.TypeError, Data: "unauthorised"} + TypePing MessageType = "ping" // message := stream.Message{Type: stream.TypePing, ProcessID: "client-1"} TypePong MessageType = "pong" // reply := stream.Message{Type: stream.TypePong, ProcessID: "client-1"} - TypeSubscribe MessageType = "subscribe" // msg := stream.Message{Type: stream.TypeSubscribe, Channel: "block"} - TypeUnsubscribe MessageType = "unsubscribe" // msg := stream.Message{Type: stream.TypeUnsubscribe, Channel: "block"} + TypeSubscribe MessageType = "subscribe" // message := stream.Message{Type: stream.TypeSubscribe, Channel: "block"} + TypeUnsubscribe MessageType = "unsubscribe" // message := stream.Message{Type: stream.TypeUnsubscribe, Channel: "block"} ) // msg := stream.Message{ diff --git a/stream.go b/stream.go index 3e505a8..96e2cb5 100644 --- a/stream.go +++ b/stream.go @@ -219,30 +219,30 @@ type Envelope struct { Frame []byte } -// Pipe connects src to dst. +// Pipe connects source to destination. // // stop := stream.Pipe(zmqHub, wsHub) // defer stop() // // Published frames keep their channel. Broadcast frames stay broadcasts when the // source exposes that hook. -func Pipe(src Stream, destination Stream) func() { - if src == nil || destination == nil || src == destination { +func Pipe(source Stream, destination Stream) func() { + if source == nil || destination == nil || source == destination { return func() {} } - type publishSubscriber interface { + type publishedFrameSource interface { SubscribePublished(handler func(string, []byte)) func() } - type broadcastSubscriber interface { + type broadcastFrameSource interface { SubscribeBroadcast(handler func([]byte)) func() } stops := make([]func(), 0, 2) - if publisher, ok := src.(publishSubscriber); ok { + if publisher, ok := source.(publishedFrameSource); ok { stops = append(stops, onceFunc(publisher.SubscribePublished(func(channel string, frame []byte) { _ = destination.Publish(channel, cloneFrame(frame)) }))) } - if broadcaster, ok := src.(broadcastSubscriber); ok { + if broadcaster, ok := source.(broadcastFrameSource); ok { stops = append(stops, onceFunc(broadcaster.SubscribeBroadcast(func(frame []byte) { _ = destination.Broadcast(cloneFrame(frame)) }))) @@ -250,7 +250,7 @@ func Pipe(src Stream, destination Stream) func() { if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back // to publishing on the wildcard channel. - stop := src.Subscribe("*", func(frame []byte) { + stop := source.Subscribe("*", func(frame []byte) { _ = destination.Publish("*", cloneFrame(frame)) }) return onceFunc(stop) From 14a33c2fb6cbfcc536f35487324a785964421fc9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:09:23 +0000 Subject: [PATCH 071/140] docs(stream): confirm RFC parity Co-Authored-By: Virgil From 0a520e713a7f757c8e2b5076dd1e30ed68c2131d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:12:27 +0000 Subject: [PATCH 072/140] style(stream): align auth examples with AX Co-Authored-By: Virgil --- auth.go | 4 ---- errors.go | 2 -- 2 files changed, 6 deletions(-) diff --git a/auth.go b/auth.go index bc8f900..6b6d55c 100644 --- a/auth.go +++ b/auth.go @@ -21,16 +21,12 @@ type Authenticator interface { // Claims: map[string]any{"role": "admin"}, // } type AuthResult struct { - // result := stream.AuthResult{Valid: true} Valid bool - // result := stream.AuthResult{UserID: "user-42"} UserID string - // result := stream.AuthResult{Claims: map[string]any{"role": "admin"}} Claims map[string]any - // result := stream.AuthResult{Error: ErrInvalidAPIKey} Error error } diff --git a/errors.go b/errors.go index f0ef94e..0876ee6 100644 --- a/errors.go +++ b/errors.go @@ -4,8 +4,6 @@ package stream import "dappco.re/go/core" -// Sentinel errors for the stream package. -// // if err := hub.Publish("hashrate", frame); err == ErrHubNotRunning { // return // } From 8ae82bcb7508960cdac1e2f6d25ce374b021bc61 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:14:44 +0000 Subject: [PATCH 073/140] style(stream): sharpen AX-facing public comments Co-Authored-By: Virgil --- hub.go | 12 +++++------- message.go | 2 +- stats.go | 8 +++----- stream.go | 8 +++----- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/hub.go b/hub.go index 7ee2142..730d74c 100644 --- a/hub.go +++ b/hub.go @@ -11,14 +11,12 @@ import ( "dappco.re/go/core" ) -// Hub is the central channel-based broker. +// hub := stream.NewHub() +// go hub.Run(ctx) // -// hub := stream.NewHub() -// go hub.Run(ctx) -// -// wsAdapter := ws.New(ws.Config{Authenticator: auth}) -// wsAdapter.Mount(hub) -// http.Handle("/stream/ws", wsAdapter.Handler()) +// wsAdapter := ws.New(ws.Config{Authenticator: auth}) +// wsAdapter.Mount(hub) +// http.Handle("/stream/ws", wsAdapter.Handler()) type Hub struct { peers map[*Peer]bool broadcastQueue chan broadcastDelivery diff --git a/message.go b/message.go index d9a1dad..fa61089 100644 --- a/message.go +++ b/message.go @@ -4,7 +4,7 @@ package stream import "time" -// messageType := stream.TypeEvent +// msgType := stream.TypeEvent type MessageType string const ( diff --git a/stats.go b/stats.go index 5269837..cedcc27 100644 --- a/stats.go +++ b/stats.go @@ -2,11 +2,9 @@ package stream -// HubStats captures a hub snapshot at a point in time. -// -// stats := hub.Stats() -// core.Print(nil, "peers=%d channels=%d", stats.Peers, stats.Channels) -// // Example: peers=12 channels=4 +// stats := hub.Stats() +// core.Print(nil, "peers=%d channels=%d", stats.Peers, stats.Channels) +// // Example: peers=12 channels=4 type HubStats struct { // Peers is the number of currently connected peers across all transports. // diff --git a/stream.go b/stream.go index 96e2cb5..5999e68 100644 --- a/stream.go +++ b/stream.go @@ -68,11 +68,9 @@ type Frame = []byte // Channel is a named topic string used for pub/sub routing. type Channel = string -// Peer represents one connected endpoint. -// -// peer := stream.NewPeer("ws") -// peer.UserID = authResult.UserID -// peer.Claims = authResult.Claims +// peer := stream.NewPeer("ws") +// peer.UserID = authResult.UserID +// peer.Claims = authResult.Claims type Peer struct { // ID is a random UUID assigned on creation. ID string From 36390630b2a6b626b51b354b88254344de959ea9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:18:09 +0000 Subject: [PATCH 074/140] style(stream): refine stats example Co-Authored-By: Virgil --- stats.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stats.go b/stats.go index cedcc27..d340202 100644 --- a/stats.go +++ b/stats.go @@ -3,7 +3,7 @@ package stream // stats := hub.Stats() -// core.Print(nil, "peers=%d channels=%d", stats.Peers, stats.Channels) +// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) // // Example: peers=12 channels=4 type HubStats struct { // Peers is the number of currently connected peers across all transports. From 77663fd9cc0fa1d6ef5cc9ff41cb71ea7431cecf Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:20:45 +0000 Subject: [PATCH 075/140] style(stream): sharpen hub config AX comments Co-Authored-By: Virgil --- hub_config.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/hub_config.go b/hub_config.go index c748e1d..b65261d 100644 --- a/hub_config.go +++ b/hub_config.go @@ -18,24 +18,24 @@ type ChannelAuthoriser func(peer *Peer, channel string) bool // }, // } type HubConfig struct { - // stream.NewHubWithConfig(stream.HubConfig{HeartbeatInterval: 30 * time.Second}) + // cfg := stream.HubConfig{HeartbeatInterval: 30 * time.Second} HeartbeatInterval time.Duration - // stream.NewHubWithConfig(stream.HubConfig{PongTimeout: 60 * time.Second}) + // cfg := stream.HubConfig{PongTimeout: 60 * time.Second} PongTimeout time.Duration - // stream.NewHubWithConfig(stream.HubConfig{WriteTimeout: 10 * time.Second}) + // cfg := stream.HubConfig{WriteTimeout: 10 * time.Second} WriteTimeout time.Duration - // stream.NewHubWithConfig(stream.HubConfig{OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }}) + // cfg := stream.HubConfig{OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }} OnConnect func(peer *Peer) - // stream.NewHubWithConfig(stream.HubConfig{OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }}) + // cfg := stream.HubConfig{OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }} OnDisconnect func(peer *Peer) - // stream.NewHubWithConfig(stream.HubConfig{ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + // cfg := stream.HubConfig{ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { // return peer.Claims["role"] == "admin" || channel == "public" - // }}) + // }} ChannelAuthoriser ChannelAuthoriser } From a56542057c78630e27f3998fc38f31587124b7e1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:23:26 +0000 Subject: [PATCH 076/140] style(stream): sharpen AX comments Co-Authored-By: Virgil --- hub.go | 36 +++++++++++++++--------------------- hub_config.go | 16 ++++++++-------- stream.go | 2 +- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/hub.go b/hub.go index 730d74c..2290995 100644 --- a/hub.go +++ b/hub.go @@ -35,7 +35,7 @@ type Hub struct { mu sync.RWMutex } -// NewHub creates a hub with default configuration. +// Create a hub with defaults. // // hub := stream.NewHub() // go hub.Run(ctx) @@ -43,11 +43,11 @@ func NewHub() *Hub { return NewHubWithConfig(DefaultHubConfig()) } -// NewHubWithConfig creates a hub with the given configuration. +// Create a hub with explicit configuration. // // hub := stream.NewHubWithConfig(stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, -// OnConnect: func(p *stream.Peer) { log.Println("connected", p.ID) }, +// OnConnect: func(peer *stream.Peer) { log.Println("connected", peer.ID) }, // }) func NewHubWithConfig(config HubConfig) *Hub { config = normalizeHubConfig(config) @@ -66,10 +66,10 @@ func NewHubWithConfig(config HubConfig) *Hub { } } -// Config returns a normalised copy of the hub configuration. +// Read the current hub configuration. // -// cfg := hub.Config() -// writeTimeout := cfg.WriteTimeout +// config := hub.Config() +// writeTimeout := config.WriteTimeout func (hub *Hub) Config() HubConfig { if hub == nil { return DefaultHubConfig() @@ -132,8 +132,7 @@ func (hub *Hub) Run(ctx context.Context) { } } -// SendToChannel delivers frame to all peers subscribed to channel. -// Returns nil if channel has no subscribers (not an error). +// Send a frame to one channel. // // hub.SendToChannel("process:abc123", frame) func (hub *Hub) SendToChannel(channel string, frame []byte) error { @@ -234,19 +233,16 @@ func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) return hub.SubscribeWithError(channel, handler) } -// Subscribe registers a handler function invoked for every frame arriving on channel. -// Returns an unsubscribe function. Multiple handlers per channel are allowed. -// Handlers run in the hub's goroutine — keep them non-blocking. +// Register a handler for one channel. // -// unsub := hub.Subscribe("block", func(f []byte) { ... }) -// defer unsub() +// unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) +// defer unsubscribe() func (hub *Hub) Subscribe(channel string, handler func([]byte)) func() { unsub, _ := hub.SubscribeWithError(channel, handler) return unsub } -// SubscribePeer adds peer to a named channel. Used by transport adapters when -// a peer requests channel subscription (WebSocket TypeSubscribe message, etc.). +// Add one peer to one channel. // // hub.SubscribePeer(peer, "hashrate") func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { @@ -300,7 +296,7 @@ func (hub *Hub) CanSubscribePeer(peer *Peer, channel string) error { return nil } -// UnsubscribePeer removes peer from a named channel. +// Remove one peer from one channel. // // hub.UnsubscribePeer(peer, "hashrate") func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { @@ -318,15 +314,14 @@ func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { } } -// Publish sends frame to all subscribers of channel. Satisfies Stream interface. +// Publish one frame to one channel. // // hub.Publish("hashrate", frame) func (hub *Hub) Publish(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } -// Broadcast sends frame to every connected peer regardless of subscriptions. -// Satisfies Stream interface. +// Broadcast one frame to every connected peer. // // hub.Broadcast([]byte(`{"type":"shutdown"}`)) func (hub *Hub) Broadcast(frame []byte) error { @@ -378,8 +373,7 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca return nil } -// Pipe connects this hub to destination: every frame published here is forwarded to destination. -// Returns a stop function. Satisfies Stream interface. +// Forward published frames to another stream. // // stop := hub.Pipe(remoteHub) // defer stop() diff --git a/hub_config.go b/hub_config.go index b65261d..f7adaa8 100644 --- a/hub_config.go +++ b/hub_config.go @@ -9,7 +9,7 @@ import "time" // }) type ChannelAuthoriser func(peer *Peer, channel string) bool -// cfg := stream.HubConfig{ +// config := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, // PongTimeout: 60 * time.Second, // WriteTimeout: 10 * time.Second, @@ -18,22 +18,22 @@ type ChannelAuthoriser func(peer *Peer, channel string) bool // }, // } type HubConfig struct { - // cfg := stream.HubConfig{HeartbeatInterval: 30 * time.Second} + // config := stream.HubConfig{HeartbeatInterval: 30 * time.Second} HeartbeatInterval time.Duration - // cfg := stream.HubConfig{PongTimeout: 60 * time.Second} + // config := stream.HubConfig{PongTimeout: 60 * time.Second} PongTimeout time.Duration - // cfg := stream.HubConfig{WriteTimeout: 10 * time.Second} + // config := stream.HubConfig{WriteTimeout: 10 * time.Second} WriteTimeout time.Duration - // cfg := stream.HubConfig{OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }} + // config := stream.HubConfig{OnConnect: func(peer *stream.Peer) { metrics.Inc("peers") }} OnConnect func(peer *Peer) - // cfg := stream.HubConfig{OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }} + // config := stream.HubConfig{OnDisconnect: func(peer *stream.Peer) { metrics.Dec("peers") }} OnDisconnect func(peer *Peer) - // cfg := stream.HubConfig{ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { + // config := stream.HubConfig{ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { // return peer.Claims["role"] == "admin" || channel == "public" // }} ChannelAuthoriser ChannelAuthoriser @@ -41,7 +41,7 @@ type HubConfig struct { // DefaultHubConfig returns sensible defaults. // -// cfg := stream.DefaultHubConfig() +// config := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { return HubConfig{ HeartbeatInterval: 30 * time.Second, diff --git a/stream.go b/stream.go index 5999e68..2e3386d 100644 --- a/stream.go +++ b/stream.go @@ -92,7 +92,7 @@ type Peer struct { closeOnce sync.Once } -// NewPeer creates a peer with a generated UUID and a buffered send queue. +// Create a peer with a generated ID and buffered send queue. // // peer := stream.NewPeer("ws") func NewPeer(transport string) *Peer { From 77c29e6a41ba460253726dae00dec46f35a46025 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:25:52 +0000 Subject: [PATCH 077/140] style(stream): add connection state stringer Co-Authored-By: Virgil --- stream.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/stream.go b/stream.go index 2e3386d..6595f93 100644 --- a/stream.go +++ b/stream.go @@ -210,6 +210,21 @@ const ( StateConnected ) +// String returns the stable label for this connection state. +// +// state := stream.StateConnected +// core.Print(nil, "connection state=%s", state.String()) +func (state ConnectionState) String() string { + switch state { + case StateConnecting: + return "connecting" + case StateConnected: + return "connected" + default: + return "disconnected" + } +} + // Envelope wraps a frame with metadata for cross-instance transport. type Envelope struct { SourceID string From 237069c12297a068932ee8d7669db0fa45af541f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:29:18 +0000 Subject: [PATCH 078/140] style(stream): sharpen package comments for AX Co-Authored-By: Virgil --- adapter/redis/redis.go | 10 +++++++--- adapter/sse/sse.go | 8 +++++--- adapter/tcp/tcp.go | 8 +++++--- adapter/ws/ws.go | 4 +--- adapter/zmq/zmq.go | 11 +++++++++-- stream.go | 3 +-- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 63c2b8a..6aa4002 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -1,8 +1,12 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package redis is the Redis pub/sub bridge for stream.Hub. -// Enables cross-instance coordination: multiple Hub instances on different nodes -// using the same Redis backend coordinate broadcasts and channel messages transparently. +// Package redis bridges a hub through Redis pub/sub. +// +// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) +// if err != nil { +// return err +// } +// go bridge.Start(ctx) package redis import ( diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index b3f9ddc..1936445 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -1,8 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package sse is the Server-Sent Events transport adapter for stream.Hub. -// Lightweight server-push over HTTP/1.1 - no upgrade required. -// Used by core/api for live stats, agent event streams, and /live_stats endpoints. +// Package sse streams hub frames over Server-Sent Events. +// +// adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) +// adapter.Mount(hub) +// http.Handle("/stream/events", adapter.Handler()) package sse import ( diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 098abdf..9a8acdf 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -1,8 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package tcp is the raw TCP transport adapter for stream.Hub. -// Length-prefixed framing over plain or TLS TCP. Used by go-p2p VPN tunnels -// and go-proxy stratum sessions where WebSocket overhead is undesirable. +// Package tcp carries hub frames over raw TCP. +// +// adapter := tcp.New(tcp.Config{Addr: ":9000"}) +// adapter.Mount(hub) +// go adapter.Listen(ctx) package tcp import ( diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 3b3423a..67e896a 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -1,8 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package ws is the WebSocket transport adapter for stream.Hub. -// It wires gorilla/websocket onto the hub, handling HTTP upgrade, -// per-client read/write pumps, and authentication. +// Package ws mounts gorilla/websocket on a stream hub. // // adapter := ws.New(ws.Config{Authenticator: auth}) // adapter.Mount(hub) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 9377a52..ab133c0 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -1,7 +1,14 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package zmq is the ZeroMQ transport adapter for stream.Hub. -// High-throughput IPC for daemon block notifications and inter-process job broadcasts. +// Package zmq wires a hub to ZeroMQ sockets. +// +// adapter := zmq.New(zmq.Config{ +// Mode: zmq.ModePubSub, +// Endpoint: "tcp://127.0.0.1:5555", +// Role: zmq.RoleSubscriber, +// }) +// adapter.Mount(hub) +// go adapter.Start(ctx) package zmq import ( diff --git a/stream.go b/stream.go index 6595f93..0feee8c 100644 --- a/stream.go +++ b/stream.go @@ -1,7 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream is the transport-agnostic event and data pipe for the CoreGO -// ecosystem. +// Package stream wires one hub to many transports. // // hub := stream.NewHub() // go hub.Run(ctx) From 5edc894ba07444d3791765504f1df54634d2a2ae Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:32:10 +0000 Subject: [PATCH 079/140] style(stream): sharpen hub comments for AX Co-Authored-By: Virgil --- hub.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/hub.go b/hub.go index 2290995..b3578ca 100644 --- a/hub.go +++ b/hub.go @@ -35,16 +35,12 @@ type Hub struct { mu sync.RWMutex } -// Create a hub with defaults. -// // hub := stream.NewHub() // go hub.Run(ctx) func NewHub() *Hub { return NewHubWithConfig(DefaultHubConfig()) } -// Create a hub with explicit configuration. -// // hub := stream.NewHubWithConfig(stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, // OnConnect: func(peer *stream.Peer) { log.Println("connected", peer.ID) }, @@ -66,8 +62,6 @@ func NewHubWithConfig(config HubConfig) *Hub { } } -// Read the current hub configuration. -// // config := hub.Config() // writeTimeout := config.WriteTimeout func (hub *Hub) Config() HubConfig { From 4b70283ed7000355e89112f02fb64bb26f0bd3f2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:35:43 +0000 Subject: [PATCH 080/140] style(stream): tighten message type examples for AX Co-Authored-By: Virgil --- message.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/message.go b/message.go index fa61089..281622f 100644 --- a/message.go +++ b/message.go @@ -8,14 +8,22 @@ import "time" type MessageType string const ( - TypeProcessOutput MessageType = "process_output" // message := stream.Message{Type: stream.TypeProcessOutput, ProcessID: "build-123"} - TypeProcessStatus MessageType = "process_status" // message := stream.Message{Type: stream.TypeProcessStatus, ProcessID: "build-123"} - TypeEvent MessageType = "event" // message := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} - TypeError MessageType = "error" // message := stream.Message{Type: stream.TypeError, Data: "unauthorised"} - TypePing MessageType = "ping" // message := stream.Message{Type: stream.TypePing, ProcessID: "client-1"} - TypePong MessageType = "pong" // reply := stream.Message{Type: stream.TypePong, ProcessID: "client-1"} - TypeSubscribe MessageType = "subscribe" // message := stream.Message{Type: stream.TypeSubscribe, Channel: "block"} - TypeUnsubscribe MessageType = "unsubscribe" // message := stream.Message{Type: stream.TypeUnsubscribe, Channel: "block"} + // message := stream.Message{Type: stream.TypeProcessOutput, ProcessID: "build-123"} + TypeProcessOutput MessageType = "process_output" + // message := stream.Message{Type: stream.TypeProcessStatus, ProcessID: "build-123"} + TypeProcessStatus MessageType = "process_status" + // message := stream.Message{Type: stream.TypeEvent, Channel: "hashrate"} + TypeEvent MessageType = "event" + // message := stream.Message{Type: stream.TypeError, Data: "unauthorised"} + TypeError MessageType = "error" + // message := stream.Message{Type: stream.TypePing, ProcessID: "client-1"} + TypePing MessageType = "ping" + // reply := stream.Message{Type: stream.TypePong, ProcessID: "client-1"} + TypePong MessageType = "pong" + // message := stream.Message{Type: stream.TypeSubscribe, Channel: "block"} + TypeSubscribe MessageType = "subscribe" + // message := stream.Message{Type: stream.TypeUnsubscribe, Channel: "block"} + TypeUnsubscribe MessageType = "unsubscribe" ) // msg := stream.Message{ From 6381c9defd340aac90cf9d51f18bd4d3e74694ed Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:38:24 +0000 Subject: [PATCH 081/140] style(stream): sharpen AX public comments Co-Authored-By: Virgil --- auth.go | 50 +++++++++++++++++++++++++------------------------- hub.go | 12 ++++++------ stream.go | 17 +++++++++-------- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/auth.go b/auth.go index 6b6d55c..0c9a76e 100644 --- a/auth.go +++ b/auth.go @@ -9,16 +9,16 @@ import ( ) // authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} +// return stream.AuthResult{Valid: true, UserID: "user-42"} // }) type Authenticator interface { Authenticate(request *http.Request) AuthResult } // result := stream.AuthResult{ -// Valid: true, -// UserID: "user-42", -// Claims: map[string]any{"role": "admin"}, +// Valid: true, +// UserID: "user-42", +// Claims: map[string]any{"role": "admin"}, // } type AuthResult struct { Valid bool @@ -31,7 +31,7 @@ type AuthResult struct { } // authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} +// return stream.AuthResult{Valid: true, UserID: "user-42"} // }) type AuthenticatorFunc func(request *http.Request) AuthResult @@ -81,12 +81,12 @@ func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) Au } // authenticator := &stream.BearerTokenAuth{ -// Validate: func(token string) stream.AuthResult { -// if token == "sk-live" { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// } -// return stream.AuthResult{Valid: false} -// }, +// Validate: func(token string) stream.AuthResult { +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} +// }, // } type BearerTokenAuth struct { Validate func(token string) AuthResult @@ -104,12 +104,12 @@ func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthRe } // authenticator := &stream.QueryTokenAuth{ -// Validate: func(token string) stream.AuthResult { -// if token == "sk-live" { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// } -// return stream.AuthResult{Valid: false} -// }, +// Validate: func(token string) stream.AuthResult { +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} +// }, // } type QueryTokenAuth struct { Validate func(token string) AuthResult @@ -127,20 +127,20 @@ func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthRes } // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if string(handshake) == "hello" { -// return stream.AuthResult{Valid: true, UserID: "peer-1"} -// } -// return stream.AuthResult{Valid: false} +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} // }) type ConnAuthenticator interface { AuthenticateConn(handshake []byte) AuthResult } // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if string(handshake) == "hello" { -// return stream.AuthResult{Valid: true, UserID: "peer-1"} -// } -// return stream.AuthResult{Valid: false} +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} // }) type ConnAuthenticatorFunc func(handshake []byte) AuthResult diff --git a/hub.go b/hub.go index b3578ca..c616103 100644 --- a/hub.go +++ b/hub.go @@ -35,15 +35,15 @@ type Hub struct { mu sync.RWMutex } -// hub := stream.NewHub() -// go hub.Run(ctx) +// hub := stream.NewHub() +// go hub.Run(ctx) func NewHub() *Hub { return NewHubWithConfig(DefaultHubConfig()) } // hub := stream.NewHubWithConfig(stream.HubConfig{ -// HeartbeatInterval: 30 * time.Second, -// OnConnect: func(peer *stream.Peer) { log.Println("connected", peer.ID) }, +// HeartbeatInterval: 30 * time.Second, +// OnConnect: func(peer *stream.Peer) { log.Println("connected", peer.ID) }, // }) func NewHubWithConfig(config HubConfig) *Hub { config = normalizeHubConfig(config) @@ -62,8 +62,8 @@ func NewHubWithConfig(config HubConfig) *Hub { } } -// config := hub.Config() -// writeTimeout := config.WriteTimeout +// config := hub.Config() +// writeTimeout := config.WriteTimeout func (hub *Hub) Config() HubConfig { if hub == nil { return DefaultHubConfig() diff --git a/stream.go b/stream.go index 0feee8c..c93219d 100644 --- a/stream.go +++ b/stream.go @@ -23,14 +23,14 @@ import ( // Stream is the transport-agnostic event and data pipe. // // hub := stream.NewHub() -// var streamBus stream.Stream = hub -// streamBus.Publish("hashrate", []byte(`{"h":123456}`)) -// stop := streamBus.Pipe(remoteHub) +// var bus stream.Stream = hub +// _ = bus.Publish("hashrate", []byte(`{"h":123456}`)) +// stop := bus.Pipe(remoteHub) // defer stop() type Stream interface { // Publish sends frame to all subscribers of channel. // - // hub.Publish("hashrate", []byte(`{"h":123456}`)) + // _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) // Publish(channel string, frame []byte) error @@ -43,7 +43,7 @@ type Stream interface { // Broadcast sends frame to every connected peer regardless of subscriptions. // - // hub.Broadcast([]byte(`{"type":"shutdown"}`)) + // _ = hub.Broadcast([]byte(`{"type":"shutdown"}`)) // Broadcast(frame []byte) error @@ -94,6 +94,7 @@ type Peer struct { // Create a peer with a generated ID and buffered send queue. // // peer := stream.NewPeer("ws") +// peer.UserID = "user-42" func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), @@ -195,11 +196,11 @@ func (peer *Peer) SendQueue() <-chan []byte { // // switch client.State() { // case stream.StateConnected: -// // send frames +// _ = client.Send(stream.Message{Type: stream.TypePing}) // case stream.StateConnecting: -// // wait for dial +// time.Sleep(100 * time.Millisecond) // default: -// // disconnected +// // disconnected // } type ConnectionState int From 93d51631cb3b920120db8dbe944e723fc4cd7b3d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:42:55 +0000 Subject: [PATCH 082/140] docs(ax): tighten API examples and names Co-Authored-By: Virgil --- adapter/redis/redis.go | 18 ++++++++++-------- adapter/tcp/reconnect.go | 6 +++--- adapter/ws/compat.go | 4 ++-- adapter/ws/reconnect.go | 6 +++--- hub.go | 15 +++++++-------- hub_config.go | 2 +- stream.go | 21 +++++++++++++-------- ws/compat.go | 4 ++-- 8 files changed, 41 insertions(+), 35 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 6aa4002..fd86ebb 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -21,7 +21,7 @@ import ( "dappco.re/go/stream" ) -// cfg := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} +// config := redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"} type Config struct { Addr string Password string @@ -51,7 +51,7 @@ type envelope struct { Frame []byte `json:"f"` } -// bridge, err := redis.NewBridge(hub, cfg) +// bridge, err := redis.NewBridge(hub, config) func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { if hub == nil { return nil, core.E("stream.redis", "nil hub", nil) @@ -222,7 +222,9 @@ func (bridge *Bridge) PublishBroadcast(frame []byte) error { return bridge.publish(bridge.broadcastChannel(), frame) } -// SourceID returns the random instance identifier. +// SourceID exposes the bridge instance identifier used for echo prevention. +// +// id := bridge.SourceID() func (bridge *Bridge) SourceID() string { if bridge == nil { return "" @@ -287,11 +289,11 @@ func (bridge *Bridge) channelFromRedis(channel string) string { return core.TrimPrefix(channel, bridge.config.Prefix+":channel:") } -func newRedisClient(cfg Config) *redis.Client { +func newRedisClient(config Config) *redis.Client { return redis.NewClient(&redis.Options{ - Addr: cfg.Addr, - Password: cfg.Password, - DB: cfg.DB, - TLSConfig: cfg.TLSConfig, + Addr: config.Addr, + Password: config.Password, + DB: config.DB, + TLSConfig: config.TLSConfig, }) } diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 7e76338..9052ebb 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -13,7 +13,7 @@ import ( "dappco.re/go/stream" ) -// ReconnectConfig configures the client-side reconnecting TCP connection. +// ReconnectConfig wires one reconnecting TCP client. // // client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{ // Addr: "127.0.0.1:9000", @@ -38,7 +38,7 @@ type ReconnectConfig struct { OnMessage func(channel string, frame []byte) } -// ReconnectingTCP connects to a TCP stream endpoint with automatic reconnection. +// ReconnectingTCP keeps one TCP session connected with backoff. // // client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{Addr: "10.69.69.165:9000"}) type ReconnectingTCP struct { @@ -154,7 +154,7 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { return writeFull(conn, encodeFrame(channel, frame)) } -// State reports whether the reconnecting client is disconnected, connecting, or connected. +// State exposes the reconnecting client's lifecycle state. // // if client.State() == stream.StateConnected { // _ = client.Send("vpn:peer-abc123", encryptedPacket) diff --git a/adapter/ws/compat.go b/adapter/ws/compat.go index e62520b..5c7e6b6 100644 --- a/adapter/ws/compat.go +++ b/adapter/ws/compat.go @@ -142,6 +142,6 @@ func NewPeer(transport string) *Peer { } // Pipe preserves the legacy stream pipe composition helper. -func Pipe(src Stream, dst Stream) func() { - return stream.Pipe(src, dst) +func Pipe(source Stream, destination Stream) func() { + return stream.Pipe(source, destination) } diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 719a23b..32514dd 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -14,7 +14,7 @@ import ( "dappco.re/go/stream" ) -// ReconnectConfig configures the client-side reconnecting WebSocket. +// ReconnectConfig wires one reconnecting WebSocket client. // // reconnectConfig := ws.ReconnectConfig{ // URL: "ws://127.0.0.1:8080/stream/ws", @@ -37,7 +37,7 @@ type ReconnectConfig struct { Headers http.Header } -// ReconnectingClient is a WebSocket client with automatic reconnection. +// ReconnectingClient keeps one WebSocket session connected with backoff. // // client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://127.0.0.1:8080/stream/ws"}) // _ = client.Connect(context.Background()) @@ -182,7 +182,7 @@ func (client *ReconnectingClient) Send(msg stream.Message) error { return client.conn.WriteMessage(websocket.TextMessage, payload.Value.([]byte)) } -// State returns the current connection state. +// State exposes the reconnecting client's lifecycle state. // // state := client.State() func (client *ReconnectingClient) State() stream.ConnectionState { diff --git a/hub.go b/hub.go index c616103..bc7a7db 100644 --- a/hub.go +++ b/hub.go @@ -74,7 +74,7 @@ func (hub *Hub) Config() HubConfig { return normalizeHubConfig(config) } -// Run starts the hub's select loop. Call in a goroutine. Exits when ctx is cancelled. +// Run owns the hub event loop until ctx is cancelled. // // go hub.Run(ctx) func (hub *Hub) Run(ctx context.Context) { @@ -375,7 +375,7 @@ func (hub *Hub) Pipe(destination Stream) func() { return Pipe(hub, destination) } -// Stats returns a snapshot of current hub state. +// Stats snapshots peers and per-channel subscriber counts. // // stats := hub.Stats() // core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) @@ -434,7 +434,7 @@ func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { }) } -// PeerCount returns the number of connected peers. +// PeerCount reads the current connected-peer total. // // n := hub.PeerCount() func (hub *Hub) PeerCount() int { @@ -446,7 +446,7 @@ func (hub *Hub) PeerCount() int { return len(hub.peers) } -// ChannelCount returns the number of active channels. +// ChannelCount reads how many named channels currently have subscribers. // // n := hub.ChannelCount() func (hub *Hub) ChannelCount() int { @@ -465,8 +465,7 @@ func (hub *Hub) ChannelCount() int { return count } -// ChannelSubscriberCount returns the subscriber count for a channel. -// Returns 0 if the channel has no subscribers. +// ChannelSubscriberCount reads one channel's current subscriber total. // // n := hub.ChannelSubscriberCount("hashrate") func (hub *Hub) ChannelSubscriberCount(channel string) int { @@ -478,7 +477,7 @@ func (hub *Hub) ChannelSubscriberCount(channel string) int { return len(hub.channels[channel]) } -// AllPeers returns an iterator for all connected peers. +// AllPeers iterates the current connected peers. // // for peer := range hub.AllPeers() { log.Println(peer.UserID) } func (hub *Hub) AllPeers() iter.Seq[*Peer] { @@ -500,7 +499,7 @@ func (hub *Hub) AllPeers() iter.Seq[*Peer] { } } -// AllChannels returns an iterator for all active channels. +// AllChannels iterates active channel names in sorted order. // // for ch := range hub.AllChannels() { log.Println(ch) } func (hub *Hub) AllChannels() iter.Seq[string] { diff --git a/hub_config.go b/hub_config.go index f7adaa8..1e13308 100644 --- a/hub_config.go +++ b/hub_config.go @@ -39,7 +39,7 @@ type HubConfig struct { ChannelAuthoriser ChannelAuthoriser } -// DefaultHubConfig returns sensible defaults. +// DefaultHubConfig starts from the library defaults used by adapters. // // config := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { diff --git a/stream.go b/stream.go index c93219d..5db4531 100644 --- a/stream.go +++ b/stream.go @@ -60,11 +60,14 @@ type Stream interface { Stats() HubStats } -// Frame is a raw byte payload delivered through the hub. -// Adapters and consumers define their own serialisation over Frame. +// Frame keeps transport payloads as raw bytes. +// +// frame := stream.Frame([]byte(`{"type":"event"}`)) type Frame = []byte -// Channel is a named topic string used for pub/sub routing. +// Channel keeps pub/sub routing keys explicit. +// +// channel := stream.Channel("hashrate") type Channel = string // peer := stream.NewPeer("ws") @@ -104,7 +107,7 @@ func NewPeer(transport string) *Peer { } } -// Subscriptions returns a copy of this peer's current channel subscriptions. +// Subscriptions snapshots the peer's active channels. // // channels := peer.Subscriptions() // ["hashrate", "block"] func (peer *Peer) Subscriptions() []string { @@ -121,7 +124,7 @@ func (peer *Peer) Subscriptions() []string { return channels } -// Send enqueues frame for delivery. Non-blocking: drops and returns false if buffer full. +// Send queues one outbound frame without blocking the caller. // // ok := peer.Send(frame) func (peer *Peer) Send(frame []byte) bool { @@ -180,7 +183,7 @@ func (peer *Peer) SetCloseHook(closeHook func()) { peer.closeHook = closeHook } -// SendQueue returns the peer's outgoing frame queue. +// SendQueue exposes the adapter-facing outbound queue. // // for frame := range peer.SendQueue() { handle(frame) } func (peer *Peer) SendQueue() <-chan []byte { @@ -210,7 +213,7 @@ const ( StateConnected ) -// String returns the stable label for this connection state. +// String keeps connection-state logs stable and grep-friendly. // // state := stream.StateConnected // core.Print(nil, "connection state=%s", state.String()) @@ -225,7 +228,9 @@ func (state ConnectionState) String() string { } } -// Envelope wraps a frame with metadata for cross-instance transport. +// Envelope keeps bridge metadata beside the raw frame. +// +// envelope := stream.Envelope{SourceID: "node-a", Channel: "block", Frame: []byte("template")} type Envelope struct { SourceID string Channel string diff --git a/ws/compat.go b/ws/compat.go index 9ccd3b4..6c0bdab 100644 --- a/ws/compat.go +++ b/ws/compat.go @@ -175,8 +175,8 @@ func NewPeer(transport string) *Peer { } // Pipe preserves the legacy stream pipe composition helper. -func Pipe(src Stream, dst Stream) func() { - return stream.Pipe(src, dst) +func Pipe(source Stream, destination Stream) func() { + return stream.Pipe(source, destination) } // New creates a legacy-compatible WebSocket adapter. From 8b0b7961b23d99d0f8e920391df348693f4f4866 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:46:04 +0000 Subject: [PATCH 083/140] docs(ax): sharpen adapter usage examples --- adapter/redis/redis.go | 16 ++++++++++++++++ adapter/zmq/zmq.go | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index fd86ebb..d2d5574 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -31,7 +31,11 @@ type Config struct { } // bridge, err := redis.NewBridge(hub, redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"}) +// if err != nil { +// return err +// } // go bridge.Start(ctx) +// defer bridge.Stop() type Bridge struct { hub *stream.Hub config Config @@ -79,6 +83,9 @@ func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { } // go bridge.Start(ctx) +// +// The bridge installs publish and broadcast hooks on the hub, then relays those +// frames through Redis pub/sub until the context is cancelled or Stop is called. func (bridge *Bridge) Start(ctx context.Context) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -165,6 +172,9 @@ func (bridge *Bridge) Start(ctx context.Context) error { } // defer bridge.Stop() +// +// Stop cancels the running bridge, removes the Redis hooks, and closes the +// underlying client and pub/sub session. func (bridge *Bridge) Stop() error { if bridge == nil { return nil @@ -202,6 +212,9 @@ func (bridge *Bridge) Stop() error { } // _ = bridge.PublishToChannel("block", templateBytes) +// +// PublishToChannel preserves the channel name so all subscribers on other +// instances receive the frame on the same logical route. func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -214,6 +227,9 @@ func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { } // _ = bridge.PublishBroadcast(shutdownFrame) +// +// PublishBroadcast delivers a frame to every bridge instance without channel +// filtering. func (bridge *Bridge) PublishBroadcast(frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index ab133c0..9508816 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -9,6 +9,7 @@ // }) // adapter.Mount(hub) // go adapter.Start(ctx) +// defer adapter.Stop() package zmq import ( @@ -89,6 +90,9 @@ func (adapter *Adapter) Mount(hub *stream.Hub) { } // go adapter.Start(ctx) +// +// Start connects the socket, validates the optional handshake, and forwards +// received frames into the mounted hub until the context is cancelled. func (adapter *Adapter) Start(ctx context.Context) error { if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) @@ -204,6 +208,8 @@ func (adapter *Adapter) Publish(channel string, frame []byte) error { } // Stop shuts down the adapter. +// +// defer adapter.Stop() func (adapter *Adapter) Stop() error { if adapter == nil { return nil From 626c2a77dd1080effdb533eb95da92c82aa1a81a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:50:07 +0000 Subject: [PATCH 084/140] style(stream): sharpen public examples Co-Authored-By: Virgil --- hub.go | 6 +++--- stream.go | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/hub.go b/hub.go index bc7a7db..2d47751 100644 --- a/hub.go +++ b/hub.go @@ -128,7 +128,7 @@ func (hub *Hub) Run(ctx context.Context) { // Send a frame to one channel. // -// hub.SendToChannel("process:abc123", frame) +// _ = hub.SendToChannel("process:abc123", frame) func (hub *Hub) SendToChannel(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } @@ -310,14 +310,14 @@ func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { // Publish one frame to one channel. // -// hub.Publish("hashrate", frame) +// _ = hub.Publish("hashrate", frame) func (hub *Hub) Publish(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } // Broadcast one frame to every connected peer. // -// hub.Broadcast([]byte(`{"type":"shutdown"}`)) +// _ = hub.Broadcast([]byte(`{"type":"shutdown"}`)) func (hub *Hub) Broadcast(frame []byte) error { return hub.broadcastFrame(frame, true) } diff --git a/stream.go b/stream.go index 5db4531..a00cd2c 100644 --- a/stream.go +++ b/stream.go @@ -230,7 +230,11 @@ func (state ConnectionState) String() string { // Envelope keeps bridge metadata beside the raw frame. // -// envelope := stream.Envelope{SourceID: "node-a", Channel: "block", Frame: []byte("template")} +// envelope := stream.Envelope{ +// SourceID: "node-a", +// Channel: "block", +// Frame: []byte("template"), +// } type Envelope struct { SourceID string Channel string From c351b66bc3912f69764d49cd92de373e32233298 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:52:34 +0000 Subject: [PATCH 085/140] AX polish transport examples --- adapter/sse/sse.go | 2 +- adapter/tcp/reconnect.go | 5 +++-- adapter/tcp/tcp.go | 2 +- adapter/ws/reconnect.go | 4 ++-- adapter/ws/ws.go | 2 +- adapter/zmq/zmq.go | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 1936445..7ee9f89 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -17,7 +17,7 @@ import ( "dappco.re/go/stream" ) -// cfg := sse.Config{ +// config := sse.Config{ // Authenticator: stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}), // HeartbeatInterval: 15 * time.Second, // RetryMs: 3000, diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 9052ebb..bb746b4 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -15,7 +15,7 @@ import ( // ReconnectConfig wires one reconnecting TCP client. // -// client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{ +// config := tcp.ReconnectConfig{ // Addr: "127.0.0.1:9000", // OnReconnect: func(attempt int) { // core.Print(nil, "tcp reconnect attempt=%d", attempt) @@ -24,7 +24,8 @@ import ( // _ = channel // _ = frame // }, -// }) +// } +// client := tcp.NewReconnectingTCP(config) type ReconnectConfig struct { Addr string InitialBackoff time.Duration diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 9a8acdf..caa503a 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -25,7 +25,7 @@ const MaxFrameSize = 65535 const maxHandshakeFrameSize = 4 << 10 -// cfg := tcp.Config{ +// config := tcp.Config{ // Addr: ":9000", // ConnAuthenticator: auth, // } diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 32514dd..c9091e1 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -16,13 +16,13 @@ import ( // ReconnectConfig wires one reconnecting WebSocket client. // -// reconnectConfig := ws.ReconnectConfig{ +// config := ws.ReconnectConfig{ // URL: "ws://127.0.0.1:8080/stream/ws", // OnMessage: func(message stream.Message) { // _ = message.Channel // }, // } -// client := ws.NewReconnectingClient(reconnectConfig) +// client := ws.NewReconnectingClient(config) type ReconnectConfig struct { URL string InitialBackoff time.Duration diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 67e896a..d5759fe 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -17,7 +17,7 @@ import ( "dappco.re/go/stream" ) -// cfg := ws.Config{ +// config := ws.Config{ // Authenticator: stream.NewAPIKeyAuth(keys), // OnAuthFailure: func(r *http.Request, res stream.AuthResult) { // log.Printf("ws auth fail from %s", r.RemoteAddr) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 9508816..e8923ab 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -45,7 +45,7 @@ const ( RolePuller ) -// cfg := zmq.Config{ +// config := zmq.Config{ // Mode: zmq.ModePubSub, // Endpoint: "tcp://127.0.0.1:5555", // Role: zmq.RoleSubscriber, From 7abea98c1663726b746c4e5978c0e4b7e79d6db0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:56:42 +0000 Subject: [PATCH 086/140] fix(stream): close transports on context cancellation Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 4 ++++ adapter/tcp/tcp.go | 8 ++++++++ adapter/tcp/tcp_test.go | 42 ++++++++++++++++++++++++++++++++++++++++ adapter/ws/reconnect.go | 4 ++++ adapter/ws/ws.go | 5 +++++ 5 files changed, 63 insertions(+) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index bb746b4..d55bacf 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -103,6 +103,9 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { } client.setConn(conn) + stopClose := context.AfterFunc(ctx, func() { + _ = conn.Close() + }) backoff = client.config.InitialBackoff attempt = 0 if client.config.OnConnect != nil { @@ -110,6 +113,7 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { } readErr := client.readLoop(ctx, conn) + stopClose() client.clearConn(conn) client.setState(stream.StateDisconnected) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index caa503a..ea7618e 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -185,6 +185,10 @@ func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { defer conn.Close() + stopClose := context.AfterFunc(ctx, func() { + _ = conn.Close() + }) + defer stopClose() channel, frame, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) if err != nil { @@ -238,6 +242,10 @@ func dispatchFrame(hub *stream.Hub, peer *stream.Peer, channel string, frame []b func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer, hub *stream.Hub) { defer conn.Close() + stopClose := context.AfterFunc(ctx, func() { + _ = conn.Close() + }) + defer stopClose() go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { channel, frame, err := readFrame(conn, 0, MaxFrameSize) diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 403d7f4..3bcc220 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -160,6 +160,48 @@ func TestTCP_Listen_NoSelfEcho_Good(t *testing.T) { } } +func TestTCP_Listen_ContextCancel_ClosesPeer_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + + if _, err := connection.Write(encodeFrame("", []byte("hello"))); err != nil { + t.Fatalf("Write() error = %v", err) + } + + waitForPeerCount(t, hub, 1) + + listenCancel() + + channel, frame, err := readFrame(connection, 2*time.Second, MaxFrameSize) + if err == nil { + t.Fatalf("readFrame() = (%q, %q, nil), want connection close", channel, string(frame)) + } + if err == stream.ErrHandshakeTimeout { + t.Fatalf("readFrame() error = %v, want connection close", err) + } + + waitForPeerCount(t, hub, 0) +} + func TestTCP_Listen_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index c9091e1..0fef973 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -110,6 +110,9 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { client.conn = conn client.state = stream.StateConnected client.mu.Unlock() + stopClose := context.AfterFunc(ctx, func() { + _ = conn.Close() + }) backoff = client.config.InitialBackoff attempt = 0 if client.config.OnConnect != nil { @@ -117,6 +120,7 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { } readErr := client.readLoop(ctx, conn) + stopClose() client.mu.Lock() if client.conn == conn { diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index d5759fe..680a8c3 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -8,6 +8,7 @@ package ws import ( + "context" "net/http" "time" @@ -139,6 +140,10 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe _ = adapter.hub.SubscribePeer(peer, channel) } defer conn.Close() + stopClose := context.AfterFunc(r.Context(), func() { + _ = conn.Close() + }) + defer stopClose() hubConfig := adapter.hub.Config() if hubConfig.PongTimeout > 0 { From 8ef64e44cba900126a01e4e61a6eeff6a1b64089 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 00:59:55 +0000 Subject: [PATCH 087/140] refactor(stream): name queue defaults Co-Authored-By: Virgil --- hub.go | 14 ++++++++------ stream.go | 4 +++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/hub.go b/hub.go index 2d47751..04778df 100644 --- a/hub.go +++ b/hub.go @@ -11,6 +11,8 @@ import ( "dappco.re/go/core" ) +const defaultHubQueueSize = 256 + // hub := stream.NewHub() // go hub.Run(ctx) // @@ -49,10 +51,10 @@ func NewHubWithConfig(config HubConfig) *Hub { config = normalizeHubConfig(config) return &Hub{ peers: map[*Peer]bool{}, - broadcastQueue: make(chan broadcastDelivery, 256), - publishQueue: make(chan publishDelivery, 256), - register: make(chan *Peer, 256), - unregister: make(chan *Peer, 256), + broadcastQueue: make(chan broadcastDelivery, defaultHubQueueSize), + publishQueue: make(chan publishDelivery, defaultHubQueueSize), + register: make(chan *Peer, defaultHubQueueSize), + unregister: make(chan *Peer, defaultHubQueueSize), channels: map[string]map[*Peer]bool{}, channelHandlers: map[string]map[uint64]func([]byte){}, broadcastHandlers: map[uint64]func([]byte){}, @@ -255,7 +257,7 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { return ErrAuthRejected } if peer.send == nil { - peer.send = make(chan []byte, 256) + peer.send = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -538,7 +540,7 @@ func (hub *Hub) AddPeer(peer *Peer) error { return core.E("stream.hub", "nil peer", nil) } if peer.send == nil { - peer.send = make(chan []byte, 256) + peer.send = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} diff --git a/stream.go b/stream.go index a00cd2c..dbf0ea0 100644 --- a/stream.go +++ b/stream.go @@ -20,6 +20,8 @@ import ( "time" ) +const defaultPeerSendBufferSize = 256 + // Stream is the transport-agnostic event and data pipe. // // hub := stream.NewHub() @@ -102,7 +104,7 @@ func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), Transport: transport, - send: make(chan []byte, 256), + send: make(chan []byte, defaultPeerSendBufferSize), subscriptions: map[string]bool{}, } } From 80d266076138fefcda33e217cbd1e57eb300a0e7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:03:11 +0000 Subject: [PATCH 088/140] style(stream): sharpen hub AX comments Co-Authored-By: Virgil --- hub.go | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/hub.go b/hub.go index 04778df..db7d220 100644 --- a/hub.go +++ b/hub.go @@ -128,23 +128,18 @@ func (hub *Hub) Run(ctx context.Context) { } } -// Send a frame to one channel. -// -// _ = hub.SendToChannel("process:abc123", frame) +// _ = hub.SendToChannel("process:abc123", frame) +// _ = hub.SendToChannel("block", []byte("template")) func (hub *Hub) SendToChannel(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } -// PublishFromPeer delivers a channel frame while excluding the source peer from fan-out. -// -// _ = hub.PublishFromPeer(peer, "block", frame) +// _ = hub.PublishFromPeer(peer, "block", frame) func (hub *Hub) PublishFromPeer(source *Peer, channel string, frame []byte) error { return hub.sendToChannelFromPeer(source, channel, frame, true) } -// PublishFromBridge delivers frame to subscribers without notifying publish hooks. -// -// _ = hub.PublishFromBridge("block", frame) +// _ = hub.PublishFromBridge("block", frame) func (hub *Hub) PublishFromBridge(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, false) } @@ -324,16 +319,12 @@ func (hub *Hub) Broadcast(frame []byte) error { return hub.broadcastFrame(frame, true) } -// BroadcastFromPeer delivers a broadcast frame while excluding the source peer from fan-out. -// -// _ = hub.BroadcastFromPeer(peer, []byte("shutdown")) +// _ = hub.BroadcastFromPeer(peer, []byte("shutdown")) func (hub *Hub) BroadcastFromPeer(source *Peer, frame []byte) error { return hub.broadcastFrameFromPeer(source, frame, true) } -// BroadcastFromBridge delivers frame to peers without notifying broadcast hooks. -// -// _ = hub.BroadcastFromBridge([]byte("shutdown")) +// _ = hub.BroadcastFromBridge([]byte("shutdown")) func (hub *Hub) BroadcastFromBridge(frame []byte) error { return hub.broadcastFrame(frame, false) } From 8477c6bb64fec64df4a2d2ccea7595b182627d90 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:07:38 +0000 Subject: [PATCH 089/140] feat(tcp): add client handshake support Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 28 ++++++++ adapter/tcp/tcp.go | 21 ++++++ adapter/tcp/tcp_test.go | 140 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index d55bacf..faa85b9 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -28,6 +28,8 @@ import ( // client := tcp.NewReconnectingTCP(config) type ReconnectConfig struct { Addr string + HandshakeFrame []byte + HandshakeChannel string InitialBackoff time.Duration MaxBackoff time.Duration BackoffMultiplier float64 @@ -101,6 +103,22 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { backoff = nextTCPBackoff(backoff, client.config.BackoffMultiplier, client.config.MaxBackoff) continue } + if err := client.writeHandshake(conn); err != nil { + _ = conn.Close() + attempt++ + client.setState(stream.StateDisconnected) + if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { + return err + } + if client.config.OnReconnect != nil { + client.config.OnReconnect(attempt) + } + if err := sleepContext(ctx, backoff); err != nil { + return err + } + backoff = nextTCPBackoff(backoff, client.config.BackoffMultiplier, client.config.MaxBackoff) + continue + } client.setConn(conn) stopClose := context.AfterFunc(ctx, func() { @@ -276,3 +294,13 @@ func sleepContext(ctx context.Context, duration time.Duration) error { return nil } } + +func (client *ReconnectingTCP) writeHandshake(conn net.Conn) error { + if conn == nil { + return core.E("stream.tcp", "nil connection", nil) + } + if len(client.config.HandshakeFrame) == 0 && client.config.HandshakeChannel == "" { + return nil + } + return writeFull(conn, encodeFrame(client.config.HandshakeChannel, client.config.HandshakeFrame)) +} diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index ea7618e..fc2aad0 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -36,6 +36,12 @@ type Config struct { // tcp.New(tcp.Config{ConnAuthenticator: auth}) ConnAuthenticator stream.ConnAuthenticator + // tcp.New(tcp.Config{HandshakeFrame: []byte("trusted")}) + HandshakeFrame []byte + + // tcp.New(tcp.Config{HandshakeChannel: "auth"}) + HandshakeChannel string + // tcp.New(tcp.Config{HandshakeTimeout: 5 * time.Second}) HandshakeTimeout time.Duration @@ -131,6 +137,10 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer if err != nil { return nil, err } + if err := adapter.writeHandshake(conn); err != nil { + _ = conn.Close() + return nil, err + } peer := stream.NewPeer("tcp") peer.SetCloseHook(func() { _ = conn.Close() @@ -332,6 +342,17 @@ func writeFull(conn net.Conn, payload []byte) error { } return nil } + +func (adapter *Adapter) writeHandshake(conn net.Conn) error { + if conn == nil { + return core.E("stream.tcp", "nil connection", nil) + } + if len(adapter.config.HandshakeFrame) == 0 && adapter.config.HandshakeChannel == "" { + return nil + } + return writeFull(conn, encodeFrame(adapter.config.HandshakeChannel, adapter.config.HandshakeFrame)) +} + func isClosedNetworkError(err error) bool { if err == nil { return false diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 3bcc220..0f1f1e2 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -352,6 +352,67 @@ func TestTCP_Dial_NilContext_Good(t *testing.T) { <-serverDone } +func TestTCP_Dial_Handshake_Good(t *testing.T) { + serverHub := stream.NewHub() + serverHubContext, serverHubCancel := context.WithCancel(context.Background()) + defer serverHubCancel() + go serverHub.Run(serverHubContext) + + serverAdapter := New(Config{ + Addr: "127.0.0.1:0", + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + if string(handshake) != "trusted" { + return stream.AuthResult{Valid: false} + } + return stream.AuthResult{Valid: true, UserID: "peer-1"} + }), + }) + serverAdapter.Mount(serverHub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = serverAdapter.Listen(listenContext) + }() + + clientHub := stream.NewHub() + clientHubContext, clientHubCancel := context.WithCancel(context.Background()) + defer clientHubCancel() + go clientHub.Run(clientHubContext) + + clientAdapter := New(Config{ + Addr: waitForListenerAddress(t, serverAdapter), + HandshakeFrame: []byte("trusted"), + }) + + received := make(chan []byte, 1) + unsubscribe := clientHub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + peer, err := clientAdapter.Dial(context.Background(), clientHub) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer peer.Close() + + waitForPeerCount(t, serverHub, 1) + + if err := serverHub.Publish("block", []byte("template")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for dialed handshake frame") + } +} + func TestReconnectingTCP_State_Good(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -441,6 +502,85 @@ func TestReconnectingTCP_OnReconnect_Good(t *testing.T) { } } +func TestReconnectingTCP_Connect_Handshake_Good(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + defer listener.Close() + + received := make(chan []byte, 1) + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + + channel, frame, readErr := readFrame(connection, time.Second, MaxFrameSize) + if readErr != nil { + return + } + if channel != "auth" { + return + } + received <- append([]byte(nil), frame...) + _ = writeFull(connection, encodeFrame("block", []byte("template"))) + }() + + clientMessages := make(chan []byte, 1) + client := NewReconnectingTCP(ReconnectConfig{ + Addr: listener.Addr().String(), + HandshakeChannel: "auth", + HandshakeFrame: []byte("trusted"), + InitialBackoff: 10 * time.Millisecond, + MaxBackoff: 10 * time.Millisecond, + OnMessage: func(channel string, frame []byte) { + if channel == "block" { + clientMessages <- append([]byte(nil), frame...) + } + }, + }) + + connectContext, connectCancel := context.WithCancel(context.Background()) + connectDone := make(chan error, 1) + go func() { + connectDone <- client.Connect(connectContext) + }() + + select { + case frame := <-received: + if string(frame) != "trusted" { + t.Fatalf("handshake frame = %q, want %q", string(frame), "trusted") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for handshake frame") + } + + select { + case frame := <-clientMessages: + if string(frame) != "template" { + t.Fatalf("received frame = %q, want %q", string(frame), "template") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for reconnecting client frame") + } + + connectCancel() + select { + case err := <-connectDone: + if err != nil && err != context.Canceled { + t.Fatalf("Connect() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Connect() to return") + } + + <-serverDone +} + func waitForListenerAddress(t *testing.T, adapter *Adapter) string { t.Helper() deadline := time.Now().Add(2 * time.Second) From ec3a152da385d0771510ea286c92a34e8232238f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:10:13 +0000 Subject: [PATCH 090/140] style(stream): sharpen hub AX comments Co-Authored-By: Virgil --- hub.go | 86 ++++++++++++++-------------------------------------------- 1 file changed, 21 insertions(+), 65 deletions(-) diff --git a/hub.go b/hub.go index db7d220..2e658eb 100644 --- a/hub.go +++ b/hub.go @@ -76,9 +76,7 @@ func (hub *Hub) Config() HubConfig { return normalizeHubConfig(config) } -// Run owns the hub event loop until ctx is cancelled. -// -// go hub.Run(ctx) +// go hub.Run(ctx) func (hub *Hub) Run(ctx context.Context) { if hub == nil { return @@ -128,18 +126,17 @@ func (hub *Hub) Run(ctx context.Context) { } } -// _ = hub.SendToChannel("process:abc123", frame) -// _ = hub.SendToChannel("block", []byte("template")) +// _ = hub.SendToChannel("hashrate", []byte(`{"h":123456}`)) func (hub *Hub) SendToChannel(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } -// _ = hub.PublishFromPeer(peer, "block", frame) +// _ = hub.PublishFromPeer(peer, "block", []byte("template")) func (hub *Hub) PublishFromPeer(source *Peer, channel string, frame []byte) error { return hub.sendToChannelFromPeer(source, channel, frame, true) } -// _ = hub.PublishFromBridge("block", frame) +// _ = hub.PublishFromBridge("block", []byte("template")) func (hub *Hub) PublishFromBridge(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, false) } @@ -233,9 +230,7 @@ func (hub *Hub) Subscribe(channel string, handler func([]byte)) func() { return unsub } -// Add one peer to one channel. -// -// hub.SubscribePeer(peer, "hashrate") +// _ = hub.SubscribePeer(peer, "hashrate") func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { if hub == nil { return core.E("stream.hub", "nil hub", nil) @@ -265,10 +260,7 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { return nil } -// CanSubscribePeer reports whether peer may subscribe to channel. -// -// err := hub.CanSubscribePeer(peer, "hashrate") -// if err == stream.ErrAuthRejected { return } +// err := hub.CanSubscribePeer(peer, "hashrate") func (hub *Hub) CanSubscribePeer(peer *Peer, channel string) error { if hub == nil { return core.E("stream.hub", "nil hub", nil) @@ -287,9 +279,7 @@ func (hub *Hub) CanSubscribePeer(peer *Peer, channel string) error { return nil } -// Remove one peer from one channel. -// -// hub.UnsubscribePeer(peer, "hashrate") +// hub.UnsubscribePeer(peer, "hashrate") func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { if hub == nil || peer == nil || channel == "" { return @@ -305,16 +295,12 @@ func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { } } -// Publish one frame to one channel. -// -// _ = hub.Publish("hashrate", frame) +// _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) func (hub *Hub) Publish(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } -// Broadcast one frame to every connected peer. -// -// _ = hub.Broadcast([]byte(`{"type":"shutdown"}`)) +// _ = hub.Broadcast([]byte(`{"type":"shutdown"}`)) func (hub *Hub) Broadcast(frame []byte) error { return hub.broadcastFrame(frame, true) } @@ -360,18 +346,12 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca return nil } -// Forward published frames to another stream. -// -// stop := hub.Pipe(remoteHub) -// defer stop() +// stop := hub.Pipe(remoteHub) func (hub *Hub) Pipe(destination Stream) func() { return Pipe(hub, destination) } -// Stats snapshots peers and per-channel subscriber counts. -// -// stats := hub.Stats() -// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) +// stats := hub.Stats() func (hub *Hub) Stats() HubStats { if hub == nil { return HubStats{} @@ -392,21 +372,12 @@ func (hub *Hub) Stats() HubStats { } } -// SubscribePublished registers a handler invoked for each published channel frame. -// -// stop := hub.SubscribePublished(func(channel string, frame []byte) { -// _ = channel -// _ = frame -// }) +// stop := hub.SubscribePublished(func(channel string, frame []byte) { _ = channel }) func (hub *Hub) SubscribePublished(handler func(string, []byte)) func() { return hub.subscribePublished(handler) } -// SubscribeBroadcast registers a handler invoked for each broadcast frame. -// -// stop := hub.SubscribeBroadcast(func(frame []byte) { -// _ = frame -// }) +// stop := hub.SubscribeBroadcast(func(frame []byte) { _ = frame }) func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { if hub == nil || handler == nil { return func() {} @@ -427,9 +398,7 @@ func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { }) } -// PeerCount reads the current connected-peer total. -// -// n := hub.PeerCount() +// n := hub.PeerCount() func (hub *Hub) PeerCount() int { if hub == nil { return 0 @@ -439,9 +408,7 @@ func (hub *Hub) PeerCount() int { return len(hub.peers) } -// ChannelCount reads how many named channels currently have subscribers. -// -// n := hub.ChannelCount() +// n := hub.ChannelCount() func (hub *Hub) ChannelCount() int { if hub == nil { return 0 @@ -458,9 +425,7 @@ func (hub *Hub) ChannelCount() int { return count } -// ChannelSubscriberCount reads one channel's current subscriber total. -// -// n := hub.ChannelSubscriberCount("hashrate") +// n := hub.ChannelSubscriberCount("hashrate") func (hub *Hub) ChannelSubscriberCount(channel string) int { if hub == nil { return 0 @@ -470,9 +435,7 @@ func (hub *Hub) ChannelSubscriberCount(channel string) int { return len(hub.channels[channel]) } -// AllPeers iterates the current connected peers. -// -// for peer := range hub.AllPeers() { log.Println(peer.UserID) } +// for peer := range hub.AllPeers() { _ = peer.UserID } func (hub *Hub) AllPeers() iter.Seq[*Peer] { if hub == nil { return func(yield func(*Peer) bool) {} @@ -492,9 +455,7 @@ func (hub *Hub) AllPeers() iter.Seq[*Peer] { } } -// AllChannels iterates active channel names in sorted order. -// -// for ch := range hub.AllChannels() { log.Println(ch) } +// for channel := range hub.AllChannels() { _ = channel } func (hub *Hub) AllChannels() iter.Seq[string] { if hub == nil { return func(yield func(string) bool) {} @@ -518,11 +479,8 @@ func (hub *Hub) AllChannels() iter.Seq[string] { } } -// AddPeer registers a peer with the hub and invokes OnConnect. -// -// peer := stream.NewPeer("ws") -// peer.UserID = "user-42" -// _ = hub.AddPeer(peer) +// peer := stream.NewPeer("ws") +// _ = hub.AddPeer(peer) func (hub *Hub) AddPeer(peer *Peer) error { if hub == nil { return core.E("stream.hub", "nil hub", nil) @@ -550,9 +508,7 @@ func (hub *Hub) AddPeer(peer *Peer) error { return nil } -// RemovePeer unregisters a peer from the hub and invokes OnDisconnect. -// -// hub.RemovePeer(peer) +// hub.RemovePeer(peer) func (hub *Hub) RemovePeer(peer *Peer) { if hub == nil || peer == nil { return From 3b25afca725d7302a1cfa3585569fed1cf947b38 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:13:35 +0000 Subject: [PATCH 091/140] style(ax): sharpen public API usage comments Co-Authored-By: Virgil --- adapter/redis/redis.go | 24 ++++++------------------ adapter/tcp/reconnect.go | 15 ++++----------- adapter/ws/reconnect.go | 19 ++++++------------- adapter/zmq/zmq.go | 11 +++-------- auth.go | 4 ++++ message.go | 2 +- stats.go | 13 +++---------- 7 files changed, 27 insertions(+), 61 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index d2d5574..b398493 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -31,9 +31,11 @@ type Config struct { } // bridge, err := redis.NewBridge(hub, redis.Config{Addr: "127.0.0.1:6379", Prefix: "pool"}) -// if err != nil { -// return err -// } +// +// if err != nil { +// return err +// } +// // go bridge.Start(ctx) // defer bridge.Stop() type Bridge struct { @@ -83,9 +85,6 @@ func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { } // go bridge.Start(ctx) -// -// The bridge installs publish and broadcast hooks on the hub, then relays those -// frames through Redis pub/sub until the context is cancelled or Stop is called. func (bridge *Bridge) Start(ctx context.Context) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -172,9 +171,6 @@ func (bridge *Bridge) Start(ctx context.Context) error { } // defer bridge.Stop() -// -// Stop cancels the running bridge, removes the Redis hooks, and closes the -// underlying client and pub/sub session. func (bridge *Bridge) Stop() error { if bridge == nil { return nil @@ -212,9 +208,6 @@ func (bridge *Bridge) Stop() error { } // _ = bridge.PublishToChannel("block", templateBytes) -// -// PublishToChannel preserves the channel name so all subscribers on other -// instances receive the frame on the same logical route. func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -227,9 +220,6 @@ func (bridge *Bridge) PublishToChannel(channel string, frame []byte) error { } // _ = bridge.PublishBroadcast(shutdownFrame) -// -// PublishBroadcast delivers a frame to every bridge instance without channel -// filtering. func (bridge *Bridge) PublishBroadcast(frame []byte) error { if bridge == nil { return core.E("stream.redis", "nil bridge", nil) @@ -238,9 +228,7 @@ func (bridge *Bridge) PublishBroadcast(frame []byte) error { return bridge.publish(bridge.broadcastChannel(), frame) } -// SourceID exposes the bridge instance identifier used for echo prevention. -// -// id := bridge.SourceID() +// id := bridge.SourceID() func (bridge *Bridge) SourceID() string { if bridge == nil { return "" diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index faa85b9..7a0452b 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -13,8 +13,6 @@ import ( "dappco.re/go/stream" ) -// ReconnectConfig wires one reconnecting TCP client. -// // config := tcp.ReconnectConfig{ // Addr: "127.0.0.1:9000", // OnReconnect: func(attempt int) { @@ -25,7 +23,8 @@ import ( // _ = frame // }, // } -// client := tcp.NewReconnectingTCP(config) +// +// client := tcp.NewReconnectingTCP(config) type ReconnectConfig struct { Addr string HandshakeFrame []byte @@ -41,9 +40,7 @@ type ReconnectConfig struct { OnMessage func(channel string, frame []byte) } -// ReconnectingTCP keeps one TCP session connected with backoff. -// -// client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{Addr: "10.69.69.165:9000"}) +// client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{Addr: "10.69.69.165:9000"}) type ReconnectingTCP struct { config ReconnectConfig @@ -161,9 +158,7 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { } } -// Send transmits frame on channel through the TCP connection. -// -// _ = client.Send("vpn:peer-abc123", encryptedPacket) +// _ = client.Send("vpn:peer-abc123", encryptedPacket) func (client *ReconnectingTCP) Send(channel string, frame []byte) error { if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) @@ -177,8 +172,6 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { return writeFull(conn, encodeFrame(channel, frame)) } -// State exposes the reconnecting client's lifecycle state. -// // if client.State() == stream.StateConnected { // _ = client.Send("vpn:peer-abc123", encryptedPacket) // } diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 0fef973..488743c 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -14,15 +14,14 @@ import ( "dappco.re/go/stream" ) -// ReconnectConfig wires one reconnecting WebSocket client. -// // config := ws.ReconnectConfig{ // URL: "ws://127.0.0.1:8080/stream/ws", // OnMessage: func(message stream.Message) { // _ = message.Channel // }, // } -// client := ws.NewReconnectingClient(config) +// +// client := ws.NewReconnectingClient(config) type ReconnectConfig struct { URL string InitialBackoff time.Duration @@ -37,10 +36,8 @@ type ReconnectConfig struct { Headers http.Header } -// ReconnectingClient keeps one WebSocket session connected with backoff. -// -// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://127.0.0.1:8080/stream/ws"}) -// _ = client.Connect(context.Background()) +// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://127.0.0.1:8080/stream/ws"}) +// _ = client.Connect(context.Background()) type ReconnectingClient struct { config ReconnectConfig state stream.ConnectionState @@ -154,9 +151,7 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { } } -// Send marshals and sends a message through the WebSocket connection. -// -// _ = client.Send(stream.Message{Type: stream.TypeEvent, Channel: "hashrate", Data: map[string]any{"h": 1234567}}) +// _ = client.Send(stream.Message{Type: stream.TypeEvent, Channel: "hashrate", Data: map[string]any{"h": 1234567}}) func (client *ReconnectingClient) Send(msg stream.Message) error { if client == nil { return core.E("stream.ws", "nil reconnecting client", nil) @@ -186,9 +181,7 @@ func (client *ReconnectingClient) Send(msg stream.Message) error { return client.conn.WriteMessage(websocket.TextMessage, payload.Value.([]byte)) } -// State exposes the reconnecting client's lifecycle state. -// -// state := client.State() +// state := client.State() func (client *ReconnectingClient) State() stream.ConnectionState { if client == nil { return stream.StateDisconnected diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index e8923ab..ea883d2 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -27,7 +27,7 @@ import ( const maxHandshakeFrameSize = 4 << 10 -// Mode selects the ZMQ socket pattern. +// mode := zmq.ModePubSub type Mode int const ( @@ -35,7 +35,7 @@ const ( ModePushPull ) -// Role is the ZMQ socket role. +// role := zmq.RoleSubscriber type Role int const ( @@ -90,9 +90,6 @@ func (adapter *Adapter) Mount(hub *stream.Hub) { } // go adapter.Start(ctx) -// -// Start connects the socket, validates the optional handshake, and forwards -// received frames into the mounted hub until the context is cancelled. func (adapter *Adapter) Start(ctx context.Context) error { if adapter == nil { return core.E("stream.zmq", "nil adapter", nil) @@ -207,9 +204,7 @@ func (adapter *Adapter) Publish(channel string, frame []byte) error { return socket.Send(zmq4.NewMsg(encodeMessage(channel, frame))) } -// Stop shuts down the adapter. -// -// defer adapter.Stop() +// defer adapter.Stop() func (adapter *Adapter) Stop() error { if adapter == nil { return nil diff --git a/auth.go b/auth.go index 0c9a76e..40777c9 100644 --- a/auth.go +++ b/auth.go @@ -35,6 +35,7 @@ type AuthResult struct { // }) type AuthenticatorFunc func(request *http.Request) AuthResult +// result := authenticatorFunc.Authenticate(request) func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) AuthResult { if authenticatorFunc == nil || request == nil { return AuthResult{Valid: false} @@ -92,6 +93,7 @@ type BearerTokenAuth struct { Validate func(token string) AuthResult } +// result := authenticator.Authenticate(request) func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthResult { if authenticator == nil || authenticator.Validate == nil || request == nil { return AuthResult{Valid: false} @@ -115,6 +117,7 @@ type QueryTokenAuth struct { Validate func(token string) AuthResult } +// result := authenticator.Authenticate(request) func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthResult { if authenticator == nil || authenticator.Validate == nil || request == nil { return AuthResult{Valid: false} @@ -144,6 +147,7 @@ type ConnAuthenticator interface { // }) type ConnAuthenticatorFunc func(handshake []byte) AuthResult +// result := auth.AuthenticateConn([]byte("hello")) func (connAuthenticatorFunc ConnAuthenticatorFunc) AuthenticateConn(handshake []byte) AuthResult { if connAuthenticatorFunc == nil { return AuthResult{Valid: false} diff --git a/message.go b/message.go index 281622f..6f9a5b5 100644 --- a/message.go +++ b/message.go @@ -4,7 +4,7 @@ package stream import "time" -// msgType := stream.TypeEvent +// messageType := stream.TypeEvent type MessageType string const ( diff --git a/stats.go b/stats.go index d340202..4671ef9 100644 --- a/stats.go +++ b/stats.go @@ -4,20 +4,13 @@ package stream // stats := hub.Stats() // core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) -// // Example: peers=12 channels=4 type HubStats struct { - // Peers is the number of currently connected peers across all transports. - // - // Example: peers=12 + // core.Print("stream", "peers=%d", stats.Peers) Peers int `json:"peers"` - // Channels is the number of active named channels with at least one subscriber. - // - // Example: channels=4 + // core.Print("stream", "channels=%d", stats.Channels) Channels int `json:"channels"` - // SubscriberCount maps channel name to subscriber count. - // - // Example: {"hashrate": 3, "block": 2} + // count := stats.SubscriberCount["hashrate"] SubscriberCount map[string]int `json:"subscriber_count"` } From b98af2b651a7c94dc25c30849726bdbe8e5ccc3d Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:17:03 +0000 Subject: [PATCH 092/140] style(ax): add composition usage examples Co-Authored-By: Virgil --- hub.go | 9 +++++++-- stream.go | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/hub.go b/hub.go index 2e658eb..59fa4de 100644 --- a/hub.go +++ b/hub.go @@ -372,12 +372,17 @@ func (hub *Hub) Stats() HubStats { } } -// stop := hub.SubscribePublished(func(channel string, frame []byte) { _ = channel }) +// stop := hub.SubscribePublished(func(channel string, frame []byte) { +// _ = channel +// _ = frame +// }) func (hub *Hub) SubscribePublished(handler func(string, []byte)) func() { return hub.subscribePublished(handler) } -// stop := hub.SubscribeBroadcast(func(frame []byte) { _ = frame }) +// stop := hub.SubscribeBroadcast(func(frame []byte) { +// _ = frame +// }) func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { if hub == nil || handler == nil { return func() {} diff --git a/stream.go b/stream.go index dbf0ea0..7cf8012 100644 --- a/stream.go +++ b/stream.go @@ -187,7 +187,11 @@ func (peer *Peer) SetCloseHook(closeHook func()) { // SendQueue exposes the adapter-facing outbound queue. // -// for frame := range peer.SendQueue() { handle(frame) } +// go func() { +// for frame := range peer.SendQueue() { +// _ = frame +// } +// }() func (peer *Peer) SendQueue() <-chan []byte { if peer == nil { return nil From 746989918e12faa482ffe2d63881d7d9424dec2a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:19:37 +0000 Subject: [PATCH 093/140] style(stream): sort peer iteration deterministically Co-Authored-By: Virgil --- hub.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/hub.go b/hub.go index 59fa4de..32c37ba 100644 --- a/hub.go +++ b/hub.go @@ -451,6 +451,15 @@ func (hub *Hub) AllPeers() iter.Seq[*Peer] { peers = append(peers, peer) } hub.mu.RUnlock() + sort.SliceStable(peers, func(left, right int) bool { + if peers[left] == nil { + return false + } + if peers[right] == nil { + return true + } + return peers[left].ID < peers[right].ID + }) return func(yield func(*Peer) bool) { for _, peer := range peers { if !yield(peer) { @@ -764,6 +773,15 @@ func (hub *Hub) collectChannelPeersLocked(channel string, source *Peer) []*Peer for peer := range combined { peers = append(peers, peer) } + sort.SliceStable(peers, func(left, right int) bool { + if peers[left] == nil { + return false + } + if peers[right] == nil { + return true + } + return peers[left].ID < peers[right].ID + }) return peers } From 954e6929bcf54ee3e3a9c502bfb290a242c4c320 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:24:19 +0000 Subject: [PATCH 094/140] feat(stream): add hub running accessor Co-Authored-By: Virgil --- hub.go | 12 ++++++++++- hub_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ stream.go | 4 ++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/hub.go b/hub.go index 32c37ba..9fde85e 100644 --- a/hub.go +++ b/hub.go @@ -76,6 +76,16 @@ func (hub *Hub) Config() HubConfig { return normalizeHubConfig(config) } +// running := hub.Running() +func (hub *Hub) Running() bool { + if hub == nil { + return false + } + hub.mu.RLock() + defer hub.mu.RUnlock() + return hub.running +} + // go hub.Run(ctx) func (hub *Hub) Run(ctx context.Context) { if hub == nil { @@ -221,7 +231,7 @@ func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) return hub.SubscribeWithError(channel, handler) } -// Register a handler for one channel. +// Subscribe a handler for one channel. // // unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) // defer unsubscribe() diff --git a/hub_test.go b/hub_test.go index 5538fa6..43f1f04 100644 --- a/hub_test.go +++ b/hub_test.go @@ -332,6 +332,67 @@ func TestHub_Publish_Ugly(t *testing.T) { } } +func TestHub_Running_Good(t *testing.T) { + hub := NewHub() + if hub.Running() { + t.Fatal("Running() = true before Run()") + } + + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + if !hub.Running() { + t.Fatal("Running() = false while Run() is active") + } + + hubCancel() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if !hub.Running() { + return + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatal("Running() stayed true after context cancellation") +} + +func TestHub_Running_Bad(t *testing.T) { + var hub *Hub + if hub.Running() { + t.Fatal("nil hub Running() = true, want false") + } +} + +func TestHub_Running_Ugly(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + observed := make(chan bool, 1) + go func() { + observed <- hub.Running() + }() + + select { + case running := <-observed: + if !running { + t.Fatal("Running() = false while hub is active") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for concurrent Running() read") + } + + hubCancel() +} + func TestHub_Broadcast_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) diff --git a/stream.go b/stream.go index 7cf8012..c253daa 100644 --- a/stream.go +++ b/stream.go @@ -5,8 +5,8 @@ // hub := stream.NewHub() // go hub.Run(ctx) // hub.Publish("hashrate", []byte(`{"h":123456}`)) -// unsub := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) -// defer unsub() +// unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) +// defer unsubscribe() package stream import ( From 9590535e1a9a3e574903c010484f8bdadb76fd71 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:27:03 +0000 Subject: [PATCH 095/140] style(stream): sharpen peer usage comments Co-Authored-By: Virgil --- stream.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/stream.go b/stream.go index c253daa..cb2185d 100644 --- a/stream.go +++ b/stream.go @@ -96,10 +96,8 @@ type Peer struct { closeOnce sync.Once } -// Create a peer with a generated ID and buffered send queue. -// -// peer := stream.NewPeer("ws") -// peer.UserID = "user-42" +// peer := stream.NewPeer("ws") +// peer.UserID = "user-42" func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), @@ -109,9 +107,7 @@ func NewPeer(transport string) *Peer { } } -// Subscriptions snapshots the peer's active channels. -// -// channels := peer.Subscriptions() // ["hashrate", "block"] +// channels := peer.Subscriptions() // ["hashrate", "block"] func (peer *Peer) Subscriptions() []string { if peer == nil { return nil @@ -126,9 +122,7 @@ func (peer *Peer) Subscriptions() []string { return channels } -// Send queues one outbound frame without blocking the caller. -// -// ok := peer.Send(frame) +// ok := peer.Send([]byte("template")) func (peer *Peer) Send(frame []byte) bool { if peer == nil { return false @@ -150,10 +144,8 @@ func (peer *Peer) Send(frame []byte) bool { } } -// Close signals the transport adapter to shut down this connection. -// -// peer.SetCloseHook(func() { _ = conn.Close() }) -// peer.Close() +// peer.SetCloseHook(func() { _ = conn.Close() }) +// peer.Close() func (peer *Peer) Close() { if peer == nil { return From cd7ef3d5bd3b4cc5f0bbf2772d714888d1a149b4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:31:27 +0000 Subject: [PATCH 096/140] Register ZMQ peers with the hub --- adapter/zmq/zmq.go | 29 +++++++++++++++- adapter/zmq/zmq_test.go | 74 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index ea883d2..249c88d 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -142,10 +142,15 @@ func (adapter *Adapter) Start(ctx context.Context) error { }() if !adapter.isReceiver() { + peer := adapter.registerPeer(socket, stream.AuthResult{}) + if peer != nil { + defer adapter.hub.RemovePeer(peer) + } <-runContext.Done() return nil } + authResult := stream.AuthResult{Valid: true} if adapter.config.ConnAuthenticator != nil { handshake, err := adapter.recvWithTimeout(runContext, socket, adapter.config.HandshakeTimeout) if err != nil { @@ -157,11 +162,15 @@ func (adapter *Adapter) Start(ctx context.Context) error { if len(handshake.Bytes()) > maxHandshakeFrameSize { return stream.ErrAuthRejected } - authResult := adapter.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) + authResult = adapter.config.ConnAuthenticator.AuthenticateConn(handshake.Bytes()) if !authResult.Valid { return stream.ErrAuthRejected } } + peer := adapter.registerPeer(socket, authResult) + if peer != nil { + defer adapter.hub.RemovePeer(peer) + } for { message, err := socket.Recv() @@ -184,6 +193,24 @@ func (adapter *Adapter) Start(ctx context.Context) error { } } +func (adapter *Adapter) registerPeer(socket zmq4.Socket, authResult stream.AuthResult) *stream.Peer { + if adapter == nil || adapter.hub == nil { + return nil + } + peer := stream.NewPeer("zmq") + peer.UserID = authResult.UserID + peer.Claims = authResult.Claims + if socket != nil { + peer.SetCloseHook(func() { + _ = socket.Close() + }) + } + if err := adapter.hub.AddPeer(peer); err != nil { + return nil + } + return peer +} + // _ = adapter.Publish("block", templateBytes) func (adapter *Adapter) Publish(channel string, frame []byte) error { if adapter == nil { diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go index af90d8e..cfd56ad 100644 --- a/adapter/zmq/zmq_test.go +++ b/adapter/zmq/zmq_test.go @@ -204,6 +204,80 @@ func TestAdapter_Start_Auth_Good(t *testing.T) { t.Fatal("timed out waiting for authenticated zmq frame") } +func TestAdapter_Start_RegistersPeer_Good(t *testing.T) { + connected := make(chan *stream.Peer, 1) + hub := stream.NewHubWithConfig(stream.HubConfig{ + OnConnect: func(peer *stream.Peer) { + select { + case connected <- peer: + default: + } + }, + }) + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + endpoint := randomTCPEndpoint(t) + subscriber := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RoleSubscriber, + Topics: []string{"block"}, + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + if string(handshake) != "block\x00hello" { + return stream.AuthResult{Valid: false} + } + return stream.AuthResult{ + Valid: true, + UserID: "node-42", + Claims: map[string]any{"role": "worker"}, + } + }), + }) + subscriber.Mount(hub) + + publisher := New(Config{ + Mode: ModePubSub, + Endpoint: endpoint, + Role: RolePublisher, + }) + publisher.Mount(stream.NewHub()) + + runContext, runCancel := context.WithCancel(context.Background()) + defer runCancel() + go func() { _ = subscriber.Start(runContext) }() + go func() { _ = publisher.Start(runContext) }() + waitForAdapterRunning(t, subscriber) + waitForAdapterRunning(t, publisher) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := publisher.Publish("block", []byte("hello")); err != nil { + t.Fatalf("handshake Publish() error = %v", err) + } + select { + case peer := <-connected: + if peer.Transport != "zmq" { + t.Fatalf("connected peer transport = %q, want %q", peer.Transport, "zmq") + } + if peer.UserID != "node-42" { + t.Fatalf("connected peer userID = %q, want %q", peer.UserID, "node-42") + } + if role, _ := peer.Claims["role"].(string); role != "worker" { + t.Fatalf("connected peer role = %q, want %q", role, "worker") + } + if peers := hub.PeerCount(); peers != 1 { + t.Fatalf("PeerCount() = %d, want %d", peers, 1) + } + return + case <-time.After(100 * time.Millisecond): + } + } + + t.Fatal("timed out waiting for zmq peer registration") +} + func TestAdapter_Start_Auth_Ugly(t *testing.T) { endpoint := randomTCPEndpoint(t) hub := stream.NewHub() From 0d3cfa113adb3c13bb74c38ec1835d15185644d0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:35:28 +0000 Subject: [PATCH 097/140] docs(stream): align public examples with AX Co-Authored-By: Virgil --- auth.go | 66 +++++++++++++++++++++++++-------------------------- hub_config.go | 20 +++++++--------- message.go | 12 +++++----- stream.go | 44 +++++++++++++--------------------- 4 files changed, 65 insertions(+), 77 deletions(-) diff --git a/auth.go b/auth.go index 40777c9..bcf0ce0 100644 --- a/auth.go +++ b/auth.go @@ -8,18 +8,18 @@ import ( "dappco.re/go/core" ) -// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// }) +// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// }) type Authenticator interface { Authenticate(request *http.Request) AuthResult } -// result := stream.AuthResult{ -// Valid: true, -// UserID: "user-42", -// Claims: map[string]any{"role": "admin"}, -// } +// result := stream.AuthResult{ +// Valid: true, +// UserID: "user-42", +// Claims: map[string]any{"role": "admin"}, +// } type AuthResult struct { Valid bool @@ -30,9 +30,9 @@ type AuthResult struct { Error error } -// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// }) +// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// }) type AuthenticatorFunc func(request *http.Request) AuthResult // result := authenticatorFunc.Authenticate(request) @@ -87,8 +87,8 @@ func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) Au // return stream.AuthResult{Valid: true, UserID: "user-42"} // } // return stream.AuthResult{Valid: false} -// }, -// } +// }, +// } type BearerTokenAuth struct { Validate func(token string) AuthResult } @@ -105,14 +105,14 @@ func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthRe return authenticator.Validate(token) } -// authenticator := &stream.QueryTokenAuth{ -// Validate: func(token string) stream.AuthResult { -// if token == "sk-live" { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// } -// return stream.AuthResult{Valid: false} -// }, -// } +// authenticator := &stream.QueryTokenAuth{ +// Validate: func(token string) stream.AuthResult { +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} +// }, +// } type QueryTokenAuth struct { Validate func(token string) AuthResult } @@ -129,22 +129,22 @@ func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthRes return authenticator.Validate(token) } -// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if string(handshake) == "hello" { -// return stream.AuthResult{Valid: true, UserID: "peer-1"} -// } -// return stream.AuthResult{Valid: false} -// }) +// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} +// }) type ConnAuthenticator interface { AuthenticateConn(handshake []byte) AuthResult } -// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if string(handshake) == "hello" { -// return stream.AuthResult{Valid: true, UserID: "peer-1"} -// } -// return stream.AuthResult{Valid: false} -// }) +// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} +// }) type ConnAuthenticatorFunc func(handshake []byte) AuthResult // result := auth.AuthenticateConn([]byte("hello")) diff --git a/hub_config.go b/hub_config.go index 1e13308..7bf0884 100644 --- a/hub_config.go +++ b/hub_config.go @@ -9,14 +9,14 @@ import "time" // }) type ChannelAuthoriser func(peer *Peer, channel string) bool -// config := stream.HubConfig{ -// HeartbeatInterval: 30 * time.Second, -// PongTimeout: 60 * time.Second, -// WriteTimeout: 10 * time.Second, -// OnConnect: func(peer *stream.Peer) { -// metrics.Inc("peers") -// }, -// } +// config := stream.HubConfig{ +// HeartbeatInterval: 30 * time.Second, +// PongTimeout: 60 * time.Second, +// WriteTimeout: 10 * time.Second, +// OnConnect: func(peer *stream.Peer) { +// metrics.Inc("peers") +// }, +// } type HubConfig struct { // config := stream.HubConfig{HeartbeatInterval: 30 * time.Second} HeartbeatInterval time.Duration @@ -39,9 +39,7 @@ type HubConfig struct { ChannelAuthoriser ChannelAuthoriser } -// DefaultHubConfig starts from the library defaults used by adapters. -// -// config := stream.DefaultHubConfig() +// config := stream.DefaultHubConfig() func DefaultHubConfig() HubConfig { return HubConfig{ HeartbeatInterval: 30 * time.Second, diff --git a/message.go b/message.go index 6f9a5b5..b183229 100644 --- a/message.go +++ b/message.go @@ -26,12 +26,12 @@ const ( TypeUnsubscribe MessageType = "unsubscribe" ) -// msg := stream.Message{ -// Type: stream.TypeEvent, -// Channel: "hashrate", -// Data: map[string]any{"h": 1234567}, -// Timestamp: time.Now().UTC(), -// } +// msg := stream.Message{ +// Type: stream.TypeEvent, +// Channel: "hashrate", +// Data: map[string]any{"h": 1234567}, +// Timestamp: time.Now().UTC(), +// } // // frame, _ := core.JSONMarshal(msg) // _ = frame diff --git a/stream.go b/stream.go index cb2185d..4f11874 100644 --- a/stream.go +++ b/stream.go @@ -62,14 +62,10 @@ type Stream interface { Stats() HubStats } -// Frame keeps transport payloads as raw bytes. -// -// frame := stream.Frame([]byte(`{"type":"event"}`)) +// frame := stream.Frame([]byte(`{"type":"event"}`)) type Frame = []byte -// Channel keeps pub/sub routing keys explicit. -// -// channel := stream.Channel("hashrate") +// channel := stream.Channel("hashrate") type Channel = string // peer := stream.NewPeer("ws") @@ -193,16 +189,14 @@ func (peer *Peer) SendQueue() <-chan []byte { return peer.send } -// ConnectionState describes a reconnecting client's lifecycle. -// -// switch client.State() { -// case stream.StateConnected: -// _ = client.Send(stream.Message{Type: stream.TypePing}) -// case stream.StateConnecting: -// time.Sleep(100 * time.Millisecond) -// default: -// // disconnected -// } +// switch client.State() { +// case stream.StateConnected: +// _ = client.Send(stream.Message{Type: stream.TypePing}) +// case stream.StateConnecting: +// time.Sleep(100 * time.Millisecond) +// default: +// // disconnected +// } type ConnectionState int const ( @@ -211,10 +205,8 @@ const ( StateConnected ) -// String keeps connection-state logs stable and grep-friendly. -// -// state := stream.StateConnected -// core.Print(nil, "connection state=%s", state.String()) +// state := stream.StateConnected +// core.Print(nil, "connection state=%s", state.String()) func (state ConnectionState) String() string { switch state { case StateConnecting: @@ -226,13 +218,11 @@ func (state ConnectionState) String() string { } } -// Envelope keeps bridge metadata beside the raw frame. -// -// envelope := stream.Envelope{ -// SourceID: "node-a", -// Channel: "block", -// Frame: []byte("template"), -// } +// envelope := stream.Envelope{ +// SourceID: "node-a", +// Channel: "block", +// Frame: []byte("template"), +// } type Envelope struct { SourceID string Channel string From a20177cd65d0651cc02435698dd56c4483bad64a Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:38:47 +0000 Subject: [PATCH 098/140] docs(stream): add RFC spec mirrors and AX comment polish Co-Authored-By: Virgil --- adapter/ws/ws.go | 4 ++-- docs/specs/core/go/RFC.md | 5 +++++ docs/specs/rfc/RFC-CORE-008-AGENT-EXPERIENCE.md | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 docs/specs/core/go/RFC.md create mode 100644 docs/specs/rfc/RFC-CORE-008-AGENT-EXPERIENCE.md diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 680a8c3..8152485 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -1,10 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package ws mounts gorilla/websocket on a stream hub. -// // adapter := ws.New(ws.Config{Authenticator: auth}) // adapter.Mount(hub) // http.Handle("/stream/ws", adapter.Handler()) +// +// Package ws mounts gorilla/websocket on a stream hub. package ws import ( diff --git a/docs/specs/core/go/RFC.md b/docs/specs/core/go/RFC.md new file mode 100644 index 0000000..adefbfb --- /dev/null +++ b/docs/specs/core/go/RFC.md @@ -0,0 +1,5 @@ +# go-stream RFC mirror + +The canonical implementation spec for this module is [`docs/RFC.md`](/workspace/docs/RFC.md). +Keep this path as a compatibility mirror for agents and tooling that expect the +`docs/specs/core/go/RFC.md` location. diff --git a/docs/specs/rfc/RFC-CORE-008-AGENT-EXPERIENCE.md b/docs/specs/rfc/RFC-CORE-008-AGENT-EXPERIENCE.md new file mode 100644 index 0000000..5d8d511 --- /dev/null +++ b/docs/specs/rfc/RFC-CORE-008-AGENT-EXPERIENCE.md @@ -0,0 +1,6 @@ +# Agent Experience RFC mirror + +The canonical AX design principles for this repository are in +[`docs/RFC-025-AGENT-EXPERIENCE.md`](/workspace/docs/RFC-025-AGENT-EXPERIENCE.md). +Keep this path as a compatibility mirror for agents and tooling that expect the +`docs/specs/rfc/RFC-CORE-008-AGENT-EXPERIENCE.md` location. From fad1ecba582febbd71913c2461da62f0b30f1867 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:43:03 +0000 Subject: [PATCH 099/140] docs(ax): add Codex conventions mirror Co-Authored-By: Virgil --- CODEX.md | 25 +++++++++++++++++++++++++ hub.go | 18 +++++++++--------- stream.go | 37 +++++++++++++++++++++---------------- 3 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 CODEX.md diff --git a/CODEX.md b/CODEX.md new file mode 100644 index 0000000..4ee686d --- /dev/null +++ b/CODEX.md @@ -0,0 +1,25 @@ +# CODEX.md — go-stream + +This repository keeps its working conventions in [CLAUDE.md](/workspace/CLAUDE.md). + +Read these two documents before changing code: + +```text +docs/RFC.md — go-stream implementation spec +docs/RFC-025-AGENT-EXPERIENCE.md — AX design principles +``` + +Key conventions: + +- Use `core.E(scope, message, cause)` for errors. +- Keep comments as concrete usage examples. +- Prefer predictable names over shorthand. +- Preserve the transport-agnostic public API and the `ws` compatibility surface. + +Commit convention: + +```text +type(scope): description + +Co-Authored-By: Virgil +``` diff --git a/hub.go b/hub.go index 9fde85e..4a54b48 100644 --- a/hub.go +++ b/hub.go @@ -212,7 +212,7 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() hub.channelHandlers[channel][id] = handler hub.mu.Unlock() - return onceFunc(func() { + return onceFunction(func() { hub.mu.Lock() defer hub.mu.Unlock() if handlers := hub.channelHandlers[channel]; handlers != nil { @@ -406,7 +406,7 @@ func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { hub.broadcastHandlers[id] = handler hub.mu.Unlock() - return onceFunc(func() { + return onceFunction(func() { hub.mu.Lock() defer hub.mu.Unlock() delete(hub.broadcastHandlers, id) @@ -574,11 +574,11 @@ func (hub *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { func (hub *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { - func(fn func([]byte)) { + func(handlerFunction func([]byte)) { defer func() { _ = recover() }() - fn(frame) + handlerFunction(frame) }(handler) } } @@ -734,7 +734,7 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { hub.publishHandlers[id] = handler hub.mu.Unlock() - return onceFunc(func() { + return onceFunction(func() { hub.mu.Lock() defer hub.mu.Unlock() delete(hub.publishHandlers, id) @@ -743,22 +743,22 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { func (hub *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { - func(fn func([]byte)) { + func(handlerFunction func([]byte)) { defer func() { _ = recover() }() - fn(frame) + handlerFunction(frame) }(handler) } } func (hub *Hub) invokePublishHandlers(handlers []func(string, []byte), channel string, frame []byte) { for _, handler := range handlers { - func(fn func(string, []byte)) { + func(handlerFunction func(string, []byte)) { defer func() { _ = recover() }() - fn(channel, frame) + handlerFunction(channel, frame) }(handler) } } diff --git a/stream.go b/stream.go index 4f11874..e9d5887 100644 --- a/stream.go +++ b/stream.go @@ -191,12 +191,17 @@ func (peer *Peer) SendQueue() <-chan []byte { // switch client.State() { // case stream.StateConnected: -// _ = client.Send(stream.Message{Type: stream.TypePing}) +// +// _ = client.Send(stream.Message{Type: stream.TypePing}) +// // case stream.StateConnecting: -// time.Sleep(100 * time.Millisecond) +// +// time.Sleep(100 * time.Millisecond) +// // default: -// // disconnected -// } +// +// // disconnected +// } type ConnectionState int const ( @@ -218,11 +223,11 @@ func (state ConnectionState) String() string { } } -// envelope := stream.Envelope{ -// SourceID: "node-a", -// Channel: "block", -// Frame: []byte("template"), -// } +// envelope := stream.Envelope{ +// SourceID: "node-a", +// Channel: "block", +// Frame: []byte("template"), +// } type Envelope struct { SourceID string Channel string @@ -248,12 +253,12 @@ func Pipe(source Stream, destination Stream) func() { } stops := make([]func(), 0, 2) if publisher, ok := source.(publishedFrameSource); ok { - stops = append(stops, onceFunc(publisher.SubscribePublished(func(channel string, frame []byte) { + stops = append(stops, onceFunction(publisher.SubscribePublished(func(channel string, frame []byte) { _ = destination.Publish(channel, cloneFrame(frame)) }))) } if broadcaster, ok := source.(broadcastFrameSource); ok { - stops = append(stops, onceFunc(broadcaster.SubscribeBroadcast(func(frame []byte) { + stops = append(stops, onceFunction(broadcaster.SubscribeBroadcast(func(frame []byte) { _ = destination.Broadcast(cloneFrame(frame)) }))) } @@ -263,9 +268,9 @@ func Pipe(source Stream, destination Stream) func() { stop := source.Subscribe("*", func(frame []byte) { _ = destination.Publish("*", cloneFrame(frame)) }) - return onceFunc(stop) + return onceFunction(stop) } - return onceFunc(func() { + return onceFunction(func() { for index := len(stops) - 1; index >= 0; index-- { stops[index]() } @@ -313,12 +318,12 @@ func cloneFrame(frame []byte) []byte { return append([]byte(nil), frame...) } -func onceFunc(fn func()) func() { - if fn == nil { +func onceFunction(handler func()) func() { + if handler == nil { return func() {} } var once sync.Once return func() { - once.Do(fn) + once.Do(handler) } } From 5a92e115e3e2e3af79a13d361fe3d0674f3398ce Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:47:17 +0000 Subject: [PATCH 100/140] fix(transport): serialize reconnecting sends Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 7 +++---- adapter/zmq/zmq.go | 15 ++++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 7a0452b..672c06c 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -164,12 +164,11 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { return core.E("stream.tcp", "nil reconnecting tcp", nil) } client.mu.RLock() - conn := client.conn - client.mu.RUnlock() - if conn == nil { + defer client.mu.RUnlock() + if client.conn == nil { return core.E("stream.tcp", "not connected", nil) } - return writeFull(conn, encodeFrame(channel, frame)) + return writeFull(client.conn, encodeFrame(channel, frame)) } // if client.State() == stream.StateConnected { diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 249c88d..ad54626 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -221,14 +221,12 @@ func (adapter *Adapter) Publish(channel string, frame []byte) error { } adapter.mu.RLock() - socket := adapter.socket - running := adapter.running - adapter.mu.RUnlock() - if !running || socket == nil { + defer adapter.mu.RUnlock() + if !adapter.running || adapter.socket == nil { return core.E("stream.zmq", "adapter not started", nil) } - return socket.Send(zmq4.NewMsg(encodeMessage(channel, frame))) + return adapter.socket.Send(zmq4.NewMsg(encodeMessage(channel, frame))) } // defer adapter.Stop() @@ -237,10 +235,13 @@ func (adapter *Adapter) Stop() error { return nil } - adapter.mu.RLock() + adapter.mu.Lock() cancel := adapter.cancel socket := adapter.socket - adapter.mu.RUnlock() + adapter.running = false + adapter.cancel = nil + adapter.socket = nil + adapter.mu.Unlock() if cancel != nil { cancel() From e3f2a5bae7a1a7f0674324494b2496cc4b028c59 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:50:19 +0000 Subject: [PATCH 101/140] style(stream): rename peer send queue field Co-Authored-By: Virgil --- hub.go | 8 ++++---- stream.go | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/hub.go b/hub.go index 4a54b48..22ea3c4 100644 --- a/hub.go +++ b/hub.go @@ -256,8 +256,8 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } - if peer.send == nil { - peer.send = make(chan []byte, defaultPeerSendBufferSize) + if peer.sendQueue == nil { + peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -512,8 +512,8 @@ func (hub *Hub) AddPeer(peer *Peer) error { if peer == nil { return core.E("stream.hub", "nil peer", nil) } - if peer.send == nil { - peer.send = make(chan []byte, defaultPeerSendBufferSize) + if peer.sendQueue == nil { + peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} diff --git a/stream.go b/stream.go index e9d5887..d7725ef 100644 --- a/stream.go +++ b/stream.go @@ -85,7 +85,7 @@ type Peer struct { // Values: "ws", "sse", "tcp", "zmq" Transport string - send chan []byte + sendQueue chan []byte subscriptions map[string]bool closeHook func() mu sync.RWMutex @@ -98,7 +98,7 @@ func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), Transport: transport, - send: make(chan []byte, defaultPeerSendBufferSize), + sendQueue: make(chan []byte, defaultPeerSendBufferSize), subscriptions: map[string]bool{}, } } @@ -128,12 +128,12 @@ func (peer *Peer) Send(frame []byte) bool { }() peer.mu.RLock() defer peer.mu.RUnlock() - if peer.send == nil { + if peer.sendQueue == nil { return false } payload := append([]byte(nil), frame...) select { - case peer.send <- payload: + case peer.sendQueue <- payload: return true default: return false @@ -148,12 +148,12 @@ func (peer *Peer) Close() { } peer.closeOnce.Do(func() { peer.mu.Lock() - send := peer.send + sendQueue := peer.sendQueue closeHook := peer.closeHook peer.closeHook = nil peer.mu.Unlock() - if send != nil { - close(send) + if sendQueue != nil { + close(sendQueue) } if closeHook != nil { closeHook() @@ -186,7 +186,7 @@ func (peer *Peer) SendQueue() <-chan []byte { } peer.mu.RLock() defer peer.mu.RUnlock() - return peer.send + return peer.sendQueue } // switch client.State() { From 7ebc142508da6316a1b86c2fb95285d04eb8e276 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:53:15 +0000 Subject: [PATCH 102/140] style(stream): sharpen stream interface usage comments Co-Authored-By: Virgil --- stream.go | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/stream.go b/stream.go index d7725ef..baa73dd 100644 --- a/stream.go +++ b/stream.go @@ -30,35 +30,21 @@ const defaultPeerSendBufferSize = 256 // stop := bus.Pipe(remoteHub) // defer stop() type Stream interface { - // Publish sends frame to all subscribers of channel. - // - // _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) - // + // _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) Publish(channel string, frame []byte) error - // Subscribe registers handler for all frames arriving on channel. - // - // unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) - // defer unsubscribe() - // + // unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) + // defer unsubscribe() Subscribe(channel string, handler func([]byte)) func() - // Broadcast sends frame to every connected peer regardless of subscriptions. - // - // _ = hub.Broadcast([]byte(`{"type":"shutdown"}`)) - // + // _ = hub.Broadcast([]byte(`{"type":"shutdown"}`)) Broadcast(frame []byte) error - // Pipe forwards every published frame to destination. - // - // stop := localHub.Pipe(remoteHub) - // defer stop() - // + // stop := localHub.Pipe(remoteHub) + // defer stop() Pipe(destination Stream) func() - // Stats returns a snapshot of current hub state. - // - // stats := hub.Stats() + // stats := hub.Stats() Stats() HubStats } From 3507a7430b5c5bdf53e51168f9f46efacf7cf09f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 01:57:41 +0000 Subject: [PATCH 103/140] fix(hub): deliver peer-originated frames to sender Co-Authored-By: Virgil --- adapter/tcp/tcp_test.go | 28 ++++++++++++++----- adapter/ws/ws_test.go | 24 ++++++++++++----- hub.go | 13 ++------- hub_test.go | 60 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 25 deletions(-) diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 0f1f1e2..11dfb82 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -108,7 +108,7 @@ func TestTCP_Listen_NoAuthenticator_Good(t *testing.T) { } } -func TestTCP_Listen_NoSelfEcho_Good(t *testing.T) { +func TestTCP_Listen_SelfDelivery_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -151,12 +151,15 @@ func TestTCP_Listen_NoSelfEcho_Good(t *testing.T) { t.Fatal("timed out waiting for published TCP frame") } - channel, frame, err := readFrame(connection, 200*time.Millisecond, MaxFrameSize) - if err == nil { - t.Fatalf("readFrame() = (%q, %q, nil), want timeout without self-echo", channel, string(frame)) + channel, frame, err := readFrame(connection, 2*time.Second, MaxFrameSize) + if err != nil { + t.Fatalf("readFrame() error = %v", err) } - if err != stream.ErrHandshakeTimeout { - t.Fatalf("readFrame() error = %v, want %v", err, stream.ErrHandshakeTimeout) + if channel != "block" { + t.Fatalf("readFrame() channel = %q, want %q", channel, "block") + } + if string(frame) != "template" { + t.Fatalf("readFrame() frame = %q, want %q", string(frame), "template") } } @@ -189,9 +192,20 @@ func TestTCP_Listen_ContextCancel_ClosesPeer_Good(t *testing.T) { waitForPeerCount(t, hub, 1) + channel, frame, err := readFrame(connection, 2*time.Second, MaxFrameSize) + if err != nil { + t.Fatalf("readFrame() initial echo error = %v", err) + } + if channel != "" { + t.Fatalf("readFrame() initial echo channel = %q, want %q", channel, "") + } + if string(frame) != "hello" { + t.Fatalf("readFrame() initial echo frame = %q, want %q", string(frame), "hello") + } + listenCancel() - channel, frame, err := readFrame(connection, 2*time.Second, MaxFrameSize) + channel, frame, err = readFrame(connection, 2*time.Second, MaxFrameSize) if err == nil { t.Fatalf("readFrame() = (%q, %q, nil), want connection close", channel, string(frame)) } diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 6e175ec..98627cf 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -5,7 +5,6 @@ package ws import ( "context" "encoding/json" - "net" "net/http" "net/http/httptest" "net/url" @@ -300,7 +299,7 @@ func TestAdapter_Handler_InboundPublish_Good(t *testing.T) { } } -func TestAdapter_Handler_InboundPublish_NoSelfEcho_Good(t *testing.T) { +func TestAdapter_Handler_InboundPublish_SelfDelivery_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -333,12 +332,23 @@ func TestAdapter_Handler_InboundPublish_NoSelfEcho_Good(t *testing.T) { t.Fatalf("WriteJSON(event) error = %v", err) } - _, _, err := conn.ReadMessage() - if err == nil { - t.Fatal("ReadMessage() error = nil, want read timeout without self-echo") + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage() error = %v", err) + } + if messageType != websocket.TextMessage { + t.Fatalf("messageType = %d, want %d", messageType, websocket.TextMessage) + } + + var decoded stream.Message + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("received invalid JSON frame: %q", string(payload)) + } + if decoded.Type != stream.TypeEvent { + t.Fatalf("decoded.Type = %q, want %q", decoded.Type, stream.TypeEvent) } - if netErr, ok := err.(net.Error); !ok || !netErr.Timeout() { - t.Fatalf("ReadMessage() error = %v, want timeout", err) + if decoded.Channel != "agent" { + t.Fatalf("decoded.Channel = %q, want %q", decoded.Channel, "agent") } _ = conn.SetReadDeadline(time.Time{}) } diff --git a/hub.go b/hub.go index 22ea3c4..882f0c4 100644 --- a/hub.go +++ b/hub.go @@ -630,16 +630,13 @@ func (hub *Hub) removePeer(peer *Peer) { } } -func (hub *Hub) broadcastToPeers(source *Peer, frame []byte, notifyBroadcastSubscribers bool) { +func (hub *Hub) broadcastToPeers(_ *Peer, frame []byte, notifyBroadcastSubscribers bool) { if hub == nil { return } hub.mu.RLock() peers := make([]*Peer, 0, len(hub.peers)) for peer := range hub.peers { - if peer == source { - continue - } peers = append(peers, peer) } handlers := cloneChannelHandlers(hub.channelHandlers["*"]) @@ -763,19 +760,13 @@ func (hub *Hub) invokePublishHandlers(handlers []func(string, []byte), channel s } } -func (hub *Hub) collectChannelPeersLocked(channel string, source *Peer) []*Peer { +func (hub *Hub) collectChannelPeersLocked(channel string, _ *Peer) []*Peer { combined := map[*Peer]struct{}{} for peer := range hub.channels[channel] { - if peer == source { - continue - } combined[peer] = struct{}{} } if channel != "*" { for peer := range hub.channels["*"] { - if peer == source { - continue - } combined[peer] = struct{}{} } } diff --git a/hub_test.go b/hub_test.go index 43f1f04..17232dd 100644 --- a/hub_test.go +++ b/hub_test.go @@ -324,6 +324,66 @@ func TestHub_Publish_Bad(t *testing.T) { } } +func TestHub_PublishFromPeer_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer() error = %v", err) + } + + if err := hub.PublishFromPeer(peer, "hashrate", []byte("123456")); err != nil { + t.Fatalf("PublishFromPeer() error = %v", err) + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for published frame") + } +} + +func TestHub_BroadcastFromPeer_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + + if err := hub.BroadcastFromPeer(peer, []byte("shutdown")); err != nil { + t.Fatalf("BroadcastFromPeer() error = %v", err) + } + + select { + case frame := <-peer.SendQueue(): + if string(frame) != "shutdown" { + t.Fatalf("received frame = %q, want %q", string(frame), "shutdown") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for broadcast frame") + } +} + func TestHub_Publish_Ugly(t *testing.T) { hub := NewHub() From 8d35382eff7484d014cfe14aa19aaffb0ead569c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:00:41 +0000 Subject: [PATCH 104/140] fix(stream): reject adapters when hub is not running Co-Authored-By: Virgil --- adapter/sse/sse.go | 15 +++++++++++++-- adapter/sse/sse_test.go | 18 ++++++++++++++++++ adapter/tcp/tcp.go | 27 +++++++++++++++++++++++---- adapter/tcp/tcp_test.go | 33 +++++++++++++++++++++++++++++++++ adapter/ws/ws.go | 17 +++++++++++++++-- adapter/ws/ws_test.go | 19 +++++++++++++++++++ 6 files changed, 121 insertions(+), 8 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 7ee9f89..1f7dedf 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -137,14 +137,25 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ } } - _ = adapter.hub.AddPeer(peer) + if !adapter.hub.Running() { + http.Error(w, "stream hub not running", http.StatusInternalServerError) + return + } + + if err := adapter.hub.AddPeer(peer); err != nil { + http.Error(w, "stream hub not running", http.StatusInternalServerError) + return + } defer adapter.hub.RemovePeer(peer) for _, channel := range channels { if channel == "" { continue } - _ = adapter.hub.SubscribePeer(peer, channel) + if err := adapter.hub.SubscribePeer(peer, channel); err != nil { + http.Error(w, "stream hub not running", http.StatusInternalServerError) + return + } } _, _ = io.WriteString(w, "retry: "+strconv.Itoa(config.RetryMs)+"\n\n") diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index b31f414..0fea3a7 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -117,6 +117,24 @@ func TestAdapter_Handler_Bad(t *testing.T) { } } +func TestAdapter_Handler_HubNotRunning_Bad(t *testing.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=hashrate") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusInternalServerError { + t.Fatalf("StatusCode = %d, want %d", response.StatusCode, http.StatusInternalServerError) + } +} + func TestAdapter_Handler_ChannelAuthoriser_Bad(t *testing.T) { hub := stream.NewHubWithConfig(stream.HubConfig{ ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index fc2aad0..f5f0eb8 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -145,8 +145,19 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer peer.SetCloseHook(func() { _ = conn.Close() }) - _ = hub.AddPeer(peer) - _ = hub.SubscribePeer(peer, "*") + if !hub.Running() { + _ = conn.Close() + return nil, stream.ErrHubNotRunning + } + if err := hub.AddPeer(peer); err != nil { + _ = conn.Close() + return nil, err + } + if err := hub.SubscribePeer(peer, "*"); err != nil { + hub.RemovePeer(peer) + _ = conn.Close() + return nil, err + } go adapter.pipePeer(ctx, conn, peer, hub) return peer, nil } @@ -219,8 +230,16 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre peer.SetCloseHook(func() { _ = conn.Close() }) - _ = hub.AddPeer(peer) - _ = hub.SubscribePeer(peer, "*") + if !hub.Running() { + return + } + if err := hub.AddPeer(peer); err != nil { + return + } + if err := hub.SubscribePeer(peer, "*"); err != nil { + hub.RemovePeer(peer) + return + } defer hub.RemovePeer(peer) go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 11dfb82..0467dca 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -366,6 +366,39 @@ func TestTCP_Dial_NilContext_Good(t *testing.T) { <-serverDone } +func TestTCP_Dial_HubNotRunning_Bad(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + defer listener.Close() + + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + _, _, _ = readFrame(connection, 2*time.Second, MaxFrameSize) + }() + + adapter := New(Config{Addr: listener.Addr().String()}) + peer, err := adapter.Dial(context.Background(), stream.NewHub()) + if err == nil { + if peer != nil { + peer.Close() + } + t.Fatal("Dial() error = nil, want hub lifecycle failure") + } + if peer != nil { + t.Fatalf("Dial() peer = %#v, want nil", peer) + } + + <-serverDone +} + func TestTCP_Dial_Handshake_Good(t *testing.T) { serverHub := stream.NewHub() serverHubContext, serverHubCancel := context.WithCancel(context.Background()) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 8152485..9e26bc6 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -111,6 +111,16 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } } + if !adapter.hub.Running() { + http.Error(w, "stream hub not running", http.StatusInternalServerError) + return + } + + if err := adapter.hub.AddPeer(peer); err != nil { + http.Error(w, "stream hub not running", http.StatusInternalServerError) + return + } + upgrader := websocket.Upgrader{ ReadBufferSize: adapter.config.ReadBufferSize, WriteBufferSize: adapter.config.WriteBufferSize, @@ -124,6 +134,7 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe conn, err := upgrader.Upgrade(w, r, nil) if err != nil { + adapter.hub.RemovePeer(peer) http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -131,13 +142,15 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe peer.SetCloseHook(func() { _ = conn.Close() }) - _ = adapter.hub.AddPeer(peer) defer adapter.hub.RemovePeer(peer) for _, channel := range channels { if channel == "" { continue } - _ = adapter.hub.SubscribePeer(peer, channel) + if err := adapter.hub.SubscribePeer(peer, channel); err != nil { + peer.Close() + return + } } defer conn.Close() stopClose := context.AfterFunc(r.Context(), func() { diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 98627cf..59aeb26 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -122,6 +122,25 @@ func TestAdapter_Handler_Bad(t *testing.T) { } } +func TestAdapter_Handler_HubNotRunning_Bad(t *testing.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + _, resp, err := websocket.DefaultDialer.Dial(websocketURL(server.URL), nil) + if err == nil { + t.Fatal("Dial() error = nil, want hub lifecycle failure") + } + if resp == nil { + t.Fatal("Dial() response = nil, want 500 response") + } + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("StatusCode = %d, want %d", resp.StatusCode, http.StatusInternalServerError) + } +} + func TestAdapter_Handler_QueryChannelAuthoriser_Bad(t *testing.T) { hub := stream.NewHubWithConfig(stream.HubConfig{ ChannelAuthoriser: func(peer *stream.Peer, channel string) bool { From 438f81f402e4ad82f665f5f9b2d4eb9ccbb51231 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:03:04 +0000 Subject: [PATCH 105/140] style(hub): sharpen public API examples Co-Authored-By: Virgil --- hub.go | 51 +++++++++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/hub.go b/hub.go index 882f0c4..f331335 100644 --- a/hub.go +++ b/hub.go @@ -179,14 +179,15 @@ func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte return nil } -// SubscribeWithError registers a handler function invoked for every frame arriving -// on channel. Returns an unsubscribe function and an error for invalid input. -// Multiple handlers per channel are allowed. Handlers run in the hub's goroutine — -// keep them non-blocking. +// unsub, err := hub.SubscribeWithError("block", func(frame []byte) { +// handleBlock(frame) +// }) +// +// if err != nil { +// return err +// } // -// unsub, err := hub.SubscribeWithError("block", func(frame []byte) { ... }) -// if err != nil { return err } -// defer unsub() +// defer unsub() func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func(), error) { if hub == nil { return func() {}, core.E("stream.hub", "nil hub", nil) @@ -224,17 +225,15 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() }), nil } -// SubscribeE is a compatibility alias for SubscribeWithError. -// -// unsub, err := hub.SubscribeE("block", func(frame []byte) { ... }) +// unsub, err := hub.SubscribeE("block", func(frame []byte) { +// handleBlock(frame) +// }) func (hub *Hub) SubscribeE(channel string, handler func([]byte)) (func(), error) { return hub.SubscribeWithError(channel, handler) } -// Subscribe a handler for one channel. -// -// unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) -// defer unsubscribe() +// unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) +// defer unsubscribe() func (hub *Hub) Subscribe(channel string, handler func([]byte)) func() { unsub, _ := hub.SubscribeWithError(channel, handler) return unsub @@ -383,15 +382,14 @@ func (hub *Hub) Stats() HubStats { } // stop := hub.SubscribePublished(func(channel string, frame []byte) { -// _ = channel -// _ = frame +// core.Print("stream", "channel=%s frame=%d", channel, len(frame)) // }) func (hub *Hub) SubscribePublished(handler func(string, []byte)) func() { return hub.subscribePublished(handler) } // stop := hub.SubscribeBroadcast(func(frame []byte) { -// _ = frame +// core.Print("stream", "broadcast frame=%d", len(frame)) // }) func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { if hub == nil || handler == nil { @@ -450,7 +448,9 @@ func (hub *Hub) ChannelSubscriberCount(channel string) int { return len(hub.channels[channel]) } -// for peer := range hub.AllPeers() { _ = peer.UserID } +// for peer := range hub.AllPeers() { +// _ = peer.UserID +// } func (hub *Hub) AllPeers() iter.Seq[*Peer] { if hub == nil { return func(yield func(*Peer) bool) {} @@ -479,7 +479,9 @@ func (hub *Hub) AllPeers() iter.Seq[*Peer] { } } -// for channel := range hub.AllChannels() { _ = channel } +// for channel := range hub.AllChannels() { +// _ = channel +// } func (hub *Hub) AllChannels() iter.Seq[string] { if hub == nil { return func(yield func(string) bool) {} @@ -818,14 +820,3 @@ func cloneBroadcastHandlers(handlers map[uint64]func([]byte)) []func([]byte) { } return cloned } - -func clonePeers(peers map[*Peer]bool) []*Peer { - if len(peers) == 0 { - return nil - } - cloned := make([]*Peer, 0, len(peers)) - for peer := range peers { - cloned = append(cloned, peer) - } - return cloned -} From 0ed78ee063faec88ed8c0c3851965c58ad3b89b0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:07:11 +0000 Subject: [PATCH 106/140] style(ax): add concrete stream usage examples Co-Authored-By: Virgil --- adapter/redis/example_test.go | 33 +++++++++++++++++++++++++++++++++ adapter/zmq/example_test.go | 30 ++++++++++++++++++++++++++++++ example_test.go | 20 ++++++++++++++++++++ ws/example_test.go | 20 ++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 adapter/redis/example_test.go create mode 100644 adapter/zmq/example_test.go create mode 100644 ws/example_test.go diff --git a/adapter/redis/example_test.go b/adapter/redis/example_test.go new file mode 100644 index 0000000..2198db5 --- /dev/null +++ b/adapter/redis/example_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package redis_test + +import ( + "context" + + "dappco.re/go/stream" + "dappco.re/go/stream/adapter/redis" +) + +func ExampleNewBridge() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + bridge, err := redis.NewBridge(hub, redis.Config{ + Addr: "127.0.0.1:6379", + Prefix: "pool", + }) + if err != nil { + return + } + defer bridge.Stop() + + go func() { + _ = bridge.Start(ctx) + }() + + _ = bridge.PublishToChannel("block", []byte("template")) +} diff --git a/adapter/zmq/example_test.go b/adapter/zmq/example_test.go new file mode 100644 index 0000000..d44cfce --- /dev/null +++ b/adapter/zmq/example_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package zmq_test + +import ( + "context" + + "dappco.re/go/stream" + "dappco.re/go/stream/adapter/zmq" +) + +func ExampleAdapter_Start() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + adapter := zmq.New(zmq.Config{ + Mode: zmq.ModePubSub, + Endpoint: "tcp://127.0.0.1:5555", + Role: zmq.RoleSubscriber, + Topics: []string{"block"}, + }) + adapter.Mount(hub) + + go func() { + _ = adapter.Start(ctx) + }() +} diff --git a/example_test.go b/example_test.go index 6199ad5..af32e6b 100644 --- a/example_test.go +++ b/example_test.go @@ -71,6 +71,26 @@ func ExamplePipe() { // {"height":42} } +func ExampleHub_Stats() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := stream.NewHub() + go hub.Run(ctx) + + peer := stream.NewPeer("ws") + _ = hub.AddPeer(peer) + defer hub.RemovePeer(peer) + + _ = hub.SubscribePeer(peer, "hashrate") + + stats := hub.Stats() + fmt.Println(stats.Peers, stats.Channels, stats.SubscriberCount["hashrate"]) + + // Output: + // 1 1 1 +} + func waitForHub(hub *stream.Hub) { deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { diff --git a/ws/example_test.go b/ws/example_test.go new file mode 100644 index 0000000..d76f43f --- /dev/null +++ b/ws/example_test.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws_test + +import ( + "context" + "net/http" + + "dappco.re/go/stream/ws" +) + +func ExampleHub_Handler() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hub := ws.NewHub() + go hub.Run(ctx) + + http.Handle("/stream/ws", hub.Handler()) +} From 9fe77e6988fc11e0ebf427f8d0384fa800f72db9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:10:26 +0000 Subject: [PATCH 107/140] refactor(stream): clarify core names Co-Authored-By: Virgil --- hub.go | 22 +++++++++++----------- stats.go | 3 +++ stream.go | 40 ++++++++++++++++++---------------------- 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/hub.go b/hub.go index f331335..a8d3efc 100644 --- a/hub.go +++ b/hub.go @@ -29,7 +29,7 @@ type Hub struct { channelHandlers map[string]map[uint64]func([]byte) broadcastHandlers map[uint64]func([]byte) publishHandlers map[uint64]func(string, []byte) - nextID uint64 + nextHandlerID uint64 config HubConfig done chan struct{} doneOnce sync.Once @@ -205,8 +205,8 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() if hub.channels == nil { hub.channels = map[string]map[*Peer]bool{} } - hub.nextID++ - id := hub.nextID + hub.nextHandlerID++ + id := hub.nextHandlerID if hub.channelHandlers[channel] == nil { hub.channelHandlers[channel] = map[uint64]func([]byte){} } @@ -255,8 +255,8 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } - if peer.sendQueue == nil { - peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) + if peer.sendBuffer == nil { + peer.sendBuffer = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -399,8 +399,8 @@ func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { if hub.broadcastHandlers == nil { hub.broadcastHandlers = map[uint64]func([]byte){} } - hub.nextID++ - id := hub.nextID + hub.nextHandlerID++ + id := hub.nextHandlerID hub.broadcastHandlers[id] = handler hub.mu.Unlock() @@ -514,8 +514,8 @@ func (hub *Hub) AddPeer(peer *Peer) error { if peer == nil { return core.E("stream.hub", "nil peer", nil) } - if peer.sendQueue == nil { - peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) + if peer.sendBuffer == nil { + peer.sendBuffer = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -728,8 +728,8 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { if hub.publishHandlers == nil { hub.publishHandlers = map[uint64]func(string, []byte){} } - hub.nextID++ - id := hub.nextID + hub.nextHandlerID++ + id := hub.nextHandlerID hub.publishHandlers[id] = handler hub.mu.Unlock() diff --git a/stats.go b/stats.go index 4671ef9..d6261a8 100644 --- a/stats.go +++ b/stats.go @@ -5,12 +5,15 @@ package stream // stats := hub.Stats() // core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) type HubStats struct { + // stats := hub.Stats() // core.Print("stream", "peers=%d", stats.Peers) Peers int `json:"peers"` + // stats := hub.Stats() // core.Print("stream", "channels=%d", stats.Channels) Channels int `json:"channels"` + // stats := hub.Stats() // count := stats.SubscriberCount["hashrate"] SubscriberCount map[string]int `json:"subscriber_count"` } diff --git a/stream.go b/stream.go index baa73dd..67a157a 100644 --- a/stream.go +++ b/stream.go @@ -1,12 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream wires one hub to many transports. -// // hub := stream.NewHub() // go hub.Run(ctx) // hub.Publish("hashrate", []byte(`{"h":123456}`)) -// unsubscribe := hub.Subscribe("block", func(frame []byte) { handleBlock(frame) }) -// defer unsubscribe() +// +// Package stream wires one hub to many transports. package stream import ( @@ -71,9 +69,9 @@ type Peer struct { // Values: "ws", "sse", "tcp", "zmq" Transport string - sendQueue chan []byte + sendBuffer chan []byte subscriptions map[string]bool - closeHook func() + closeFunc func() mu sync.RWMutex closeOnce sync.Once } @@ -84,7 +82,7 @@ func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), Transport: transport, - sendQueue: make(chan []byte, defaultPeerSendBufferSize), + sendBuffer: make(chan []byte, defaultPeerSendBufferSize), subscriptions: map[string]bool{}, } } @@ -114,12 +112,12 @@ func (peer *Peer) Send(frame []byte) bool { }() peer.mu.RLock() defer peer.mu.RUnlock() - if peer.sendQueue == nil { + if peer.sendBuffer == nil { return false } payload := append([]byte(nil), frame...) select { - case peer.sendQueue <- payload: + case peer.sendBuffer <- payload: return true default: return false @@ -134,29 +132,27 @@ func (peer *Peer) Close() { } peer.closeOnce.Do(func() { peer.mu.Lock() - sendQueue := peer.sendQueue - closeHook := peer.closeHook - peer.closeHook = nil + sendBuffer := peer.sendBuffer + closeFunc := peer.closeFunc + peer.closeFunc = nil peer.mu.Unlock() - if sendQueue != nil { - close(sendQueue) + if sendBuffer != nil { + close(sendBuffer) } - if closeHook != nil { - closeHook() + if closeFunc != nil { + closeFunc() } }) } -// SetCloseHook installs the transport shutdown hook invoked by Close. -// -// peer.SetCloseHook(func() { _ = conn.Close() }) -func (peer *Peer) SetCloseHook(closeHook func()) { +// peer.SetCloseHook(func() { _ = conn.Close() }) +func (peer *Peer) SetCloseHook(closeFunc func()) { if peer == nil { return } peer.mu.Lock() defer peer.mu.Unlock() - peer.closeHook = closeHook + peer.closeFunc = closeFunc } // SendQueue exposes the adapter-facing outbound queue. @@ -172,7 +168,7 @@ func (peer *Peer) SendQueue() <-chan []byte { } peer.mu.RLock() defer peer.mu.RUnlock() - return peer.sendQueue + return peer.sendBuffer } // switch client.State() { From 01ecd9c7ace24ed9d3272cf11518dbd5d21e0dca Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:13:47 +0000 Subject: [PATCH 108/140] style(hub): sharpen running example comment Co-Authored-By: Virgil --- hub.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub.go b/hub.go index a8d3efc..5d39b53 100644 --- a/hub.go +++ b/hub.go @@ -76,7 +76,7 @@ func (hub *Hub) Config() HubConfig { return normalizeHubConfig(config) } -// running := hub.Running() +// if hub.Running() { _ = hub.Publish("hashrate", frame) } func (hub *Hub) Running() bool { if hub == nil { return false From d68eec7623749e45a3cc56ba843171279c281e80 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:17:14 +0000 Subject: [PATCH 109/140] refactor(stream): rename peer internals for clarity Co-Authored-By: Virgil --- hub.go | 12 ++++++------ stream.go | 50 +++++++++++++++++++++++++------------------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/hub.go b/hub.go index 5d39b53..e0266bd 100644 --- a/hub.go +++ b/hub.go @@ -255,8 +255,8 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } - if peer.sendBuffer == nil { - peer.sendBuffer = make(chan []byte, defaultPeerSendBufferSize) + if peer.sendQueue == nil { + peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -514,8 +514,8 @@ func (hub *Hub) AddPeer(peer *Peer) error { if peer == nil { return core.E("stream.hub", "nil peer", nil) } - if peer.sendBuffer == nil { - peer.sendBuffer = make(chan []byte, defaultPeerSendBufferSize) + if peer.sendQueue == nil { + peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -621,9 +621,9 @@ func (hub *Hub) removePeer(peer *Peer) { delete(hub.channels, channel) } } - peer.mu.Lock() + peer.mutex.Lock() peer.subscriptions = map[string]bool{} - peer.mu.Unlock() + peer.mutex.Unlock() onDisconnect := hub.config.OnDisconnect hub.mu.Unlock() peer.Close() diff --git a/stream.go b/stream.go index 67a157a..899a0fe 100644 --- a/stream.go +++ b/stream.go @@ -69,10 +69,10 @@ type Peer struct { // Values: "ws", "sse", "tcp", "zmq" Transport string - sendBuffer chan []byte + sendQueue chan []byte subscriptions map[string]bool - closeFunc func() - mu sync.RWMutex + closeHook func() + mutex sync.RWMutex closeOnce sync.Once } @@ -82,7 +82,7 @@ func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), Transport: transport, - sendBuffer: make(chan []byte, defaultPeerSendBufferSize), + sendQueue: make(chan []byte, defaultPeerSendBufferSize), subscriptions: map[string]bool{}, } } @@ -92,8 +92,8 @@ func (peer *Peer) Subscriptions() []string { if peer == nil { return nil } - peer.mu.RLock() - defer peer.mu.RUnlock() + peer.mutex.RLock() + defer peer.mutex.RUnlock() channels := make([]string, 0, len(peer.subscriptions)) for channel := range peer.subscriptions { channels = append(channels, channel) @@ -110,14 +110,14 @@ func (peer *Peer) Send(frame []byte) bool { defer func() { _ = recover() }() - peer.mu.RLock() - defer peer.mu.RUnlock() - if peer.sendBuffer == nil { + peer.mutex.RLock() + defer peer.mutex.RUnlock() + if peer.sendQueue == nil { return false } payload := append([]byte(nil), frame...) select { - case peer.sendBuffer <- payload: + case peer.sendQueue <- payload: return true default: return false @@ -131,16 +131,16 @@ func (peer *Peer) Close() { return } peer.closeOnce.Do(func() { - peer.mu.Lock() - sendBuffer := peer.sendBuffer - closeFunc := peer.closeFunc - peer.closeFunc = nil - peer.mu.Unlock() - if sendBuffer != nil { - close(sendBuffer) + peer.mutex.Lock() + sendQueue := peer.sendQueue + closeHook := peer.closeHook + peer.closeHook = nil + peer.mutex.Unlock() + if sendQueue != nil { + close(sendQueue) } - if closeFunc != nil { - closeFunc() + if closeHook != nil { + closeHook() } }) } @@ -150,9 +150,9 @@ func (peer *Peer) SetCloseHook(closeFunc func()) { if peer == nil { return } - peer.mu.Lock() - defer peer.mu.Unlock() - peer.closeFunc = closeFunc + peer.mutex.Lock() + defer peer.mutex.Unlock() + peer.closeHook = closeFunc } // SendQueue exposes the adapter-facing outbound queue. @@ -166,9 +166,9 @@ func (peer *Peer) SendQueue() <-chan []byte { if peer == nil { return nil } - peer.mu.RLock() - defer peer.mu.RUnlock() - return peer.sendBuffer + peer.mutex.RLock() + defer peer.mutex.RUnlock() + return peer.sendQueue } // switch client.State() { From f9f66fde7b4f6de3ca5c4705cf5ed5bf1dbd6c48 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:20:15 +0000 Subject: [PATCH 110/140] style(ax): sharpen stream examples Co-Authored-By: Virgil --- adapter/ws/ws.go | 2 +- hub.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 9e26bc6..35e00fa 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -21,7 +21,7 @@ import ( // config := ws.Config{ // Authenticator: stream.NewAPIKeyAuth(keys), // OnAuthFailure: func(r *http.Request, res stream.AuthResult) { -// log.Printf("ws auth fail from %s", r.RemoteAddr) +// core.Print("stream", "ws auth fail from %s", r.RemoteAddr) // }, // } type Config struct { diff --git a/hub.go b/hub.go index e0266bd..6b5c132 100644 --- a/hub.go +++ b/hub.go @@ -45,7 +45,7 @@ func NewHub() *Hub { // hub := stream.NewHubWithConfig(stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, -// OnConnect: func(peer *stream.Peer) { log.Println("connected", peer.ID) }, +// OnConnect: func(peer *stream.Peer) { core.Print("stream", "connected %s", peer.ID) }, // }) func NewHubWithConfig(config HubConfig) *Hub { config = normalizeHubConfig(config) From 120e388a559f62d0065406447c846a90d2354642 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:24:10 +0000 Subject: [PATCH 111/140] chore(ax): replace mu with mutex Co-Authored-By: Virgil --- adapter/redis/redis.go | 24 ++++---- adapter/tcp/reconnect.go | 30 +++++----- adapter/tcp/tcp.go | 10 ++-- adapter/tcp/tcp_test.go | 4 +- adapter/ws/reconnect.go | 34 ++++++------ adapter/zmq/zmq.go | 20 +++---- adapter/zmq/zmq_test.go | 4 +- hub.go | 116 +++++++++++++++++++-------------------- hub_test.go | 26 ++++----- 9 files changed, 134 insertions(+), 134 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index b398493..1952f32 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -43,7 +43,7 @@ type Bridge struct { config Config sourceID string - mu sync.RWMutex + mutex sync.RWMutex running bool cancel context.CancelFunc pubsub *redis.PubSub @@ -93,13 +93,13 @@ func (bridge *Bridge) Start(ctx context.Context) error { ctx = context.Background() } - bridge.mu.Lock() + bridge.mutex.Lock() if bridge.running { - bridge.mu.Unlock() + bridge.mutex.Unlock() return nil } bridge.running = true - bridge.mu.Unlock() + bridge.mutex.Unlock() runContext, runCancel := context.WithCancel(ctx) client := newRedisClient(bridge.config) @@ -114,16 +114,16 @@ func (bridge *Bridge) Start(ctx context.Context) error { _ = bridge.publishWithClient(client, bridge.broadcastChannel(), frame) }) - bridge.mu.Lock() + bridge.mutex.Lock() bridge.cancel = runCancel bridge.client = client bridge.pubsub = pubsub bridge.publishStop = publishStop bridge.broadcastStop = broadcastStop - bridge.mu.Unlock() + bridge.mutex.Unlock() defer func() { - bridge.mu.Lock() + bridge.mutex.Lock() publishStop := bridge.publishStop broadcastStop := bridge.broadcastStop bridge.running = false @@ -132,7 +132,7 @@ func (bridge *Bridge) Start(ctx context.Context) error { bridge.pubsub = nil bridge.publishStop = nil bridge.broadcastStop = nil - bridge.mu.Unlock() + bridge.mutex.Unlock() if publishStop != nil { publishStop() } @@ -176,14 +176,14 @@ func (bridge *Bridge) Stop() error { return nil } - bridge.mu.RLock() + bridge.mutex.RLock() running := bridge.running cancel := bridge.cancel pubsub := bridge.pubsub client := bridge.client publishStop := bridge.publishStop broadcastStop := bridge.broadcastStop - bridge.mu.RUnlock() + bridge.mutex.RUnlock() if !running { return nil @@ -237,10 +237,10 @@ func (bridge *Bridge) SourceID() string { } func (bridge *Bridge) publish(channel string, frame []byte) error { - bridge.mu.RLock() + bridge.mutex.RLock() running := bridge.running client := bridge.client - bridge.mu.RUnlock() + bridge.mutex.RUnlock() if !running { return core.E("stream.redis", "bridge not started", nil) } diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 672c06c..a2d42c5 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -44,7 +44,7 @@ type ReconnectConfig struct { type ReconnectingTCP struct { config ReconnectConfig - mu sync.RWMutex + mutex sync.RWMutex conn net.Conn state stream.ConnectionState closed bool @@ -163,8 +163,8 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) } - client.mu.RLock() - defer client.mu.RUnlock() + client.mutex.RLock() + defer client.mutex.RUnlock() if client.conn == nil { return core.E("stream.tcp", "not connected", nil) } @@ -178,8 +178,8 @@ func (client *ReconnectingTCP) State() stream.ConnectionState { if client == nil { return stream.StateDisconnected } - client.mu.RLock() - defer client.mu.RUnlock() + client.mutex.RLock() + defer client.mutex.RUnlock() return client.state } @@ -188,12 +188,12 @@ func (client *ReconnectingTCP) Close() error { if client == nil { return nil } - client.mu.Lock() + client.mutex.Lock() client.closed = true conn := client.conn client.conn = nil client.state = stream.StateDisconnected - client.mu.Unlock() + client.mutex.Unlock() if conn != nil { return conn.Close() } @@ -235,30 +235,30 @@ func (client *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) erro } func (client *ReconnectingTCP) setConn(conn net.Conn) { - client.mu.Lock() + client.mutex.Lock() client.conn = conn client.state = stream.StateConnected - client.mu.Unlock() + client.mutex.Unlock() } func (client *ReconnectingTCP) clearConn(conn net.Conn) { - client.mu.Lock() + client.mutex.Lock() if client.conn == conn { client.conn = nil client.state = stream.StateDisconnected } - client.mu.Unlock() + client.mutex.Unlock() } func (client *ReconnectingTCP) setState(state stream.ConnectionState) { - client.mu.Lock() + client.mutex.Lock() client.state = state - client.mu.Unlock() + client.mutex.Unlock() } func (client *ReconnectingTCP) isClosed() bool { - client.mu.RLock() - defer client.mu.RUnlock() + client.mutex.RLock() + defer client.mutex.RUnlock() return client.closed } diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index f5f0eb8..d3fc969 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -54,7 +54,7 @@ type Adapter struct { hub *stream.Hub config Config - mu sync.Mutex + mutex sync.Mutex listener net.Listener } @@ -92,11 +92,11 @@ func (adapter *Adapter) Listen(ctx context.Context) error { } defer func() { _ = listener.Close() - adapter.mu.Lock() + adapter.mutex.Lock() if adapter.listener == listener { adapter.listener = nil } - adapter.mu.Unlock() + adapter.mutex.Unlock() }() go func() { @@ -163,8 +163,8 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer } func (adapter *Adapter) listen() (net.Listener, error) { - adapter.mu.Lock() - defer adapter.mu.Unlock() + adapter.mutex.Lock() + defer adapter.mutex.Unlock() if adapter.listener != nil { return adapter.listener, nil } diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 0467dca..7e7f0d3 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -632,9 +632,9 @@ func waitForListenerAddress(t *testing.T, adapter *Adapter) string { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - adapter.mu.Lock() + adapter.mutex.Lock() listener := adapter.listener - adapter.mu.Unlock() + adapter.mutex.Unlock() if listener != nil { return listener.Addr().String() } diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 488743c..8282af2 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -42,7 +42,7 @@ type ReconnectingClient struct { config ReconnectConfig state stream.ConnectionState - mu sync.RWMutex + mutex sync.RWMutex conn *websocket.Conn closed bool } @@ -103,10 +103,10 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { continue } - client.mu.Lock() + client.mutex.Lock() client.conn = conn client.state = stream.StateConnected - client.mu.Unlock() + client.mutex.Unlock() stopClose := context.AfterFunc(ctx, func() { _ = conn.Close() }) @@ -119,12 +119,12 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { readErr := client.readLoop(ctx, conn) stopClose() - client.mu.Lock() + client.mutex.Lock() if client.conn == conn { client.conn = nil } client.state = stream.StateDisconnected - client.mu.Unlock() + client.mutex.Unlock() _ = conn.Close() if client.config.OnDisconnect != nil { client.config.OnDisconnect() @@ -167,14 +167,14 @@ func (client *ReconnectingClient) Send(msg stream.Message) error { return core.E("stream.ws", "failed to marshal message", nil) } - client.mu.RLock() + client.mutex.RLock() conn := client.conn - client.mu.RUnlock() + client.mutex.RUnlock() if conn == nil { return core.E("stream.ws", "not connected", nil) } - client.mu.Lock() - defer client.mu.Unlock() + client.mutex.Lock() + defer client.mutex.Unlock() if client.conn == nil { return core.E("stream.ws", "not connected", nil) } @@ -186,8 +186,8 @@ func (client *ReconnectingClient) State() stream.ConnectionState { if client == nil { return stream.StateDisconnected } - client.mu.RLock() - defer client.mu.RUnlock() + client.mutex.RLock() + defer client.mutex.RUnlock() return client.state } @@ -196,12 +196,12 @@ func (client *ReconnectingClient) Close() error { if client == nil { return nil } - client.mu.Lock() + client.mutex.Lock() client.closed = true conn := client.conn client.conn = nil client.state = stream.StateDisconnected - client.mu.Unlock() + client.mutex.Unlock() if conn != nil { return conn.Close() } @@ -233,15 +233,15 @@ func (client *ReconnectingClient) readLoop(ctx context.Context, conn *websocket. } func (client *ReconnectingClient) isClosed() bool { - client.mu.RLock() - defer client.mu.RUnlock() + client.mutex.RLock() + defer client.mutex.RUnlock() return client.closed } func (client *ReconnectingClient) setState(state stream.ConnectionState) { - client.mu.Lock() + client.mutex.Lock() client.state = state - client.mu.Unlock() + client.mutex.Unlock() } func nextBackoff(current time.Duration, multiplier float64, maximum time.Duration) time.Duration { diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index ad54626..e64479e 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -70,7 +70,7 @@ type Adapter struct { hub *stream.Hub config Config - mu sync.RWMutex + mutex sync.RWMutex running bool socket zmq4.Socket cancel context.CancelFunc @@ -119,9 +119,9 @@ func (adapter *Adapter) Start(ctx context.Context) error { return err } - adapter.mu.Lock() + adapter.mutex.Lock() if adapter.running { - adapter.mu.Unlock() + adapter.mutex.Unlock() _ = socket.Close() runCancel() return nil @@ -129,14 +129,14 @@ func (adapter *Adapter) Start(ctx context.Context) error { adapter.running = true adapter.socket = socket adapter.cancel = runCancel - adapter.mu.Unlock() + adapter.mutex.Unlock() defer func() { - adapter.mu.Lock() + adapter.mutex.Lock() adapter.running = false adapter.socket = nil adapter.cancel = nil - adapter.mu.Unlock() + adapter.mutex.Unlock() runCancel() _ = socket.Close() }() @@ -220,8 +220,8 @@ func (adapter *Adapter) Publish(channel string, frame []byte) error { return core.E("stream.zmq", "publish not supported for this role", nil) } - adapter.mu.RLock() - defer adapter.mu.RUnlock() + adapter.mutex.RLock() + defer adapter.mutex.RUnlock() if !adapter.running || adapter.socket == nil { return core.E("stream.zmq", "adapter not started", nil) } @@ -235,13 +235,13 @@ func (adapter *Adapter) Stop() error { return nil } - adapter.mu.Lock() + adapter.mutex.Lock() cancel := adapter.cancel socket := adapter.socket adapter.running = false adapter.cancel = nil adapter.socket = nil - adapter.mu.Unlock() + adapter.mutex.Unlock() if cancel != nil { cancel() diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go index cfd56ad..802c5f6 100644 --- a/adapter/zmq/zmq_test.go +++ b/adapter/zmq/zmq_test.go @@ -444,9 +444,9 @@ func waitForAdapterRunning(t *testing.T, adapter *Adapter) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - adapter.mu.RLock() + adapter.mutex.RLock() running := adapter.running - adapter.mu.RUnlock() + adapter.mutex.RUnlock() if running { time.Sleep(100 * time.Millisecond) return diff --git a/hub.go b/hub.go index 6b5c132..2f5acc6 100644 --- a/hub.go +++ b/hub.go @@ -34,7 +34,7 @@ type Hub struct { done chan struct{} doneOnce sync.Once running bool - mu sync.RWMutex + mutex sync.RWMutex } // hub := stream.NewHub() @@ -70,9 +70,9 @@ func (hub *Hub) Config() HubConfig { if hub == nil { return DefaultHubConfig() } - hub.mu.RLock() + hub.mutex.RLock() config := hub.config - hub.mu.RUnlock() + hub.mutex.RUnlock() return normalizeHubConfig(config) } @@ -81,8 +81,8 @@ func (hub *Hub) Running() bool { if hub == nil { return false } - hub.mu.RLock() - defer hub.mu.RUnlock() + hub.mutex.RLock() + defer hub.mutex.RUnlock() return hub.running } @@ -94,22 +94,22 @@ func (hub *Hub) Run(ctx context.Context) { if ctx == nil { ctx = context.Background() } - hub.mu.Lock() + hub.mutex.Lock() if hub.running { - hub.mu.Unlock() + hub.mutex.Unlock() return } hub.running = true - hub.mu.Unlock() + hub.mutex.Unlock() defer func() { - hub.mu.Lock() + hub.mutex.Lock() peers := make([]*Peer, 0, len(hub.peers)) for peer := range hub.peers { peers = append(peers, peer) } hub.running = false - hub.mu.Unlock() + hub.mutex.Unlock() for _, peer := range peers { hub.removePeer(peer) @@ -159,13 +159,13 @@ func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte if hub == nil { return core.E("stream.hub", "nil hub", nil) } - hub.mu.RLock() + hub.mutex.RLock() running := hub.running peersToSend := hub.collectChannelPeersLocked(channel, source) hasHandlers := len(hub.channelHandlers[channel]) > 0 hasWildcardHandlers := len(hub.channelHandlers["*"]) > 0 && channel != "*" hasPublishers := notifyPublishSubscribers && len(hub.publishHandlers) > 0 - hub.mu.RUnlock() + hub.mutex.RUnlock() if !running { return ErrHubNotRunning } @@ -198,7 +198,7 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() if handler == nil { return func() {}, core.E("stream.hub", "nil handler", nil) } - hub.mu.Lock() + hub.mutex.Lock() if hub.channelHandlers == nil { hub.channelHandlers = map[string]map[uint64]func([]byte){} } @@ -211,11 +211,11 @@ func (hub *Hub) SubscribeWithError(channel string, handler func([]byte)) (func() hub.channelHandlers[channel] = map[uint64]func([]byte){} } hub.channelHandlers[channel][id] = handler - hub.mu.Unlock() + hub.mutex.Unlock() return onceFunction(func() { - hub.mu.Lock() - defer hub.mu.Unlock() + hub.mutex.Lock() + defer hub.mutex.Unlock() if handlers := hub.channelHandlers[channel]; handlers != nil { delete(handlers, id) if len(handlers) == 0 { @@ -250,8 +250,8 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { if channel == "" { return ErrEmptyChannel } - hub.mu.Lock() - defer hub.mu.Unlock() + hub.mutex.Lock() + defer hub.mutex.Unlock() if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } @@ -280,8 +280,8 @@ func (hub *Hub) CanSubscribePeer(peer *Peer, channel string) error { if channel == "" { return ErrEmptyChannel } - hub.mu.RLock() - defer hub.mu.RUnlock() + hub.mutex.RLock() + defer hub.mutex.RUnlock() if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } @@ -293,8 +293,8 @@ func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { if hub == nil || peer == nil || channel == "" { return } - hub.mu.Lock() - defer hub.mu.Unlock() + hub.mutex.Lock() + defer hub.mutex.Unlock() delete(peer.subscriptions, channel) if peers := hub.channels[channel]; peers != nil { delete(peers, peer) @@ -332,9 +332,9 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca if hub == nil { return core.E("stream.hub", "nil hub", nil) } - hub.mu.RLock() + hub.mutex.RLock() running := hub.running - hub.mu.RUnlock() + hub.mutex.RUnlock() if !running { return ErrHubNotRunning } @@ -365,8 +365,8 @@ func (hub *Hub) Stats() HubStats { if hub == nil { return HubStats{} } - hub.mu.RLock() - defer hub.mu.RUnlock() + hub.mutex.RLock() + defer hub.mutex.RUnlock() subscriberCount := map[string]int{} for channel, peers := range hub.channels { if channel == "*" { @@ -395,18 +395,18 @@ func (hub *Hub) SubscribeBroadcast(handler func([]byte)) func() { if hub == nil || handler == nil { return func() {} } - hub.mu.Lock() + hub.mutex.Lock() if hub.broadcastHandlers == nil { hub.broadcastHandlers = map[uint64]func([]byte){} } hub.nextHandlerID++ id := hub.nextHandlerID hub.broadcastHandlers[id] = handler - hub.mu.Unlock() + hub.mutex.Unlock() return onceFunction(func() { - hub.mu.Lock() - defer hub.mu.Unlock() + hub.mutex.Lock() + defer hub.mutex.Unlock() delete(hub.broadcastHandlers, id) }) } @@ -416,8 +416,8 @@ func (hub *Hub) PeerCount() int { if hub == nil { return 0 } - hub.mu.RLock() - defer hub.mu.RUnlock() + hub.mutex.RLock() + defer hub.mutex.RUnlock() return len(hub.peers) } @@ -426,8 +426,8 @@ func (hub *Hub) ChannelCount() int { if hub == nil { return 0 } - hub.mu.RLock() - defer hub.mu.RUnlock() + hub.mutex.RLock() + defer hub.mutex.RUnlock() count := 0 for channel, peers := range hub.channels { if channel == "*" || len(peers) == 0 { @@ -443,8 +443,8 @@ func (hub *Hub) ChannelSubscriberCount(channel string) int { if hub == nil { return 0 } - hub.mu.RLock() - defer hub.mu.RUnlock() + hub.mutex.RLock() + defer hub.mutex.RUnlock() return len(hub.channels[channel]) } @@ -455,12 +455,12 @@ func (hub *Hub) AllPeers() iter.Seq[*Peer] { if hub == nil { return func(yield func(*Peer) bool) {} } - hub.mu.RLock() + hub.mutex.RLock() peers := make([]*Peer, 0, len(hub.peers)) for peer := range hub.peers { peers = append(peers, peer) } - hub.mu.RUnlock() + hub.mutex.RUnlock() sort.SliceStable(peers, func(left, right int) bool { if peers[left] == nil { return false @@ -486,7 +486,7 @@ func (hub *Hub) AllChannels() iter.Seq[string] { if hub == nil { return func(yield func(string) bool) {} } - hub.mu.RLock() + hub.mutex.RLock() channels := make([]string, 0, len(hub.channels)) for channel, peers := range hub.channels { if channel == "*" || len(peers) == 0 { @@ -494,7 +494,7 @@ func (hub *Hub) AllChannels() iter.Seq[string] { } channels = append(channels, channel) } - hub.mu.RUnlock() + hub.mutex.RUnlock() sort.Strings(channels) return func(yield func(string) bool) { for _, channel := range channels { @@ -520,9 +520,9 @@ func (hub *Hub) AddPeer(peer *Peer) error { if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} } - hub.mu.RLock() + hub.mutex.RLock() running := hub.running - hub.mu.RUnlock() + hub.mutex.RUnlock() if running { select { case hub.register <- peer: @@ -539,9 +539,9 @@ func (hub *Hub) RemovePeer(peer *Peer) { if hub == nil || peer == nil { return } - hub.mu.RLock() + hub.mutex.RLock() running := hub.running - hub.mu.RUnlock() + hub.mutex.RUnlock() if running { select { case hub.unregister <- peer: @@ -589,17 +589,17 @@ func (hub *Hub) addPeer(peer *Peer) { if hub == nil || peer == nil { return } - hub.mu.Lock() + hub.mutex.Lock() if hub.peers == nil { hub.peers = map[*Peer]bool{} } if hub.peers[peer] { - hub.mu.Unlock() + hub.mutex.Unlock() return } hub.peers[peer] = true onConnect := hub.config.OnConnect - hub.mu.Unlock() + hub.mutex.Unlock() if onConnect != nil { onConnect(peer) } @@ -609,9 +609,9 @@ func (hub *Hub) removePeer(peer *Peer) { if hub == nil || peer == nil { return } - hub.mu.Lock() + hub.mutex.Lock() if !hub.peers[peer] { - hub.mu.Unlock() + hub.mutex.Unlock() return } delete(hub.peers, peer) @@ -625,7 +625,7 @@ func (hub *Hub) removePeer(peer *Peer) { peer.subscriptions = map[string]bool{} peer.mutex.Unlock() onDisconnect := hub.config.OnDisconnect - hub.mu.Unlock() + hub.mutex.Unlock() peer.Close() if onDisconnect != nil { onDisconnect(peer) @@ -636,14 +636,14 @@ func (hub *Hub) broadcastToPeers(_ *Peer, frame []byte, notifyBroadcastSubscribe if hub == nil { return } - hub.mu.RLock() + hub.mutex.RLock() peers := make([]*Peer, 0, len(hub.peers)) for peer := range hub.peers { peers = append(peers, peer) } handlers := cloneChannelHandlers(hub.channelHandlers["*"]) broadcastHandlers := cloneBroadcastHandlers(hub.broadcastHandlers) - hub.mu.RUnlock() + hub.mutex.RUnlock() for _, peer := range peers { hub.sendBroadcastToPeer(peer, frame) } @@ -705,11 +705,11 @@ func (hub *Hub) processPublishDelivery(channel string, frame []byte, notifyPubli if hub == nil { return } - hub.mu.RLock() + hub.mutex.RLock() handlers := cloneChannelHandlers(hub.channelHandlers[channel]) wildcardHandlers := cloneChannelHandlers(hub.channelHandlers["*"]) publishHandlers := clonePublishHandlers(hub.publishHandlers) - hub.mu.RUnlock() + hub.mutex.RUnlock() hub.invokeHandlers(handlers, frame) if channel != "*" { @@ -724,18 +724,18 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { if hub == nil || handler == nil { return func() {} } - hub.mu.Lock() + hub.mutex.Lock() if hub.publishHandlers == nil { hub.publishHandlers = map[uint64]func(string, []byte){} } hub.nextHandlerID++ id := hub.nextHandlerID hub.publishHandlers[id] = handler - hub.mu.Unlock() + hub.mutex.Unlock() return onceFunction(func() { - hub.mu.Lock() - defer hub.mu.Unlock() + hub.mutex.Lock() + defer hub.mutex.Unlock() delete(hub.publishHandlers, id) }) } diff --git a/hub_test.go b/hub_test.go index 17232dd..be16951 100644 --- a/hub_test.go +++ b/hub_test.go @@ -10,7 +10,7 @@ import ( ) type testStream struct { - mu sync.Mutex + mutex sync.Mutex subscribers map[string]map[int]func([]byte) nextID int published []publishedFrame @@ -29,14 +29,14 @@ func newTestStream() *testStream { } func (streamValue *testStream) Publish(channel string, frame []byte) error { - streamValue.mu.Lock() + streamValue.mutex.Lock() streamValue.published = append(streamValue.published, publishedFrame{ channel: channel, frame: append([]byte(nil), frame...), }) handlers := streamValue.cloneHandlersLocked(channel) wildcardHandlers := streamValue.cloneHandlersLocked("*") - streamValue.mu.Unlock() + streamValue.mutex.Unlock() for _, handler := range handlers { handler(frame) @@ -50,8 +50,8 @@ func (streamValue *testStream) Publish(channel string, frame []byte) error { } func (streamValue *testStream) Subscribe(channel string, handler func([]byte)) func() { - streamValue.mu.Lock() - defer streamValue.mu.Unlock() + streamValue.mutex.Lock() + defer streamValue.mutex.Unlock() streamValue.nextID++ id := streamValue.nextID if streamValue.subscribers[channel] == nil { @@ -59,8 +59,8 @@ func (streamValue *testStream) Subscribe(channel string, handler func([]byte)) f } streamValue.subscribers[channel][id] = handler return func() { - streamValue.mu.Lock() - defer streamValue.mu.Unlock() + streamValue.mutex.Lock() + defer streamValue.mutex.Unlock() delete(streamValue.subscribers[channel], id) if len(streamValue.subscribers[channel]) == 0 { delete(streamValue.subscribers, channel) @@ -69,8 +69,8 @@ func (streamValue *testStream) Subscribe(channel string, handler func([]byte)) f } func (streamValue *testStream) Broadcast(frame []byte) error { - streamValue.mu.Lock() - defer streamValue.mu.Unlock() + streamValue.mutex.Lock() + defer streamValue.mutex.Unlock() streamValue.broadcasts = append(streamValue.broadcasts, append([]byte(nil), frame...)) return nil } @@ -253,8 +253,8 @@ func TestHub_Pipe_GenericPublishFallback_Good(t *testing.T) { t.Fatalf("Publish() error = %v", err) } - destinationStream.mu.Lock() - defer destinationStream.mu.Unlock() + destinationStream.mutex.Lock() + defer destinationStream.mutex.Unlock() if len(destinationStream.published) != 1 { t.Fatalf("len(published) = %d, want %d", len(destinationStream.published), 1) } @@ -743,9 +743,9 @@ func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - hub.mu.RLock() + hub.mutex.RLock() running := hub.running - hub.mu.RUnlock() + hub.mutex.RUnlock() if running { return } From 13fc477a6461d50da29ef6964893957ca880cb72 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:27:43 +0000 Subject: [PATCH 112/140] ax(stream): add enum stringers for logging Co-Authored-By: Virgil --- adapter/zmq/zmq.go | 28 ++++++++++++++++++++++++++++ adapter/zmq/zmq_test.go | 21 +++++++++++++++++++++ message.go | 17 +++++++++++------ message_test.go | 14 ++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 message_test.go diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index e64479e..7204778 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -35,6 +35,18 @@ const ( ModePushPull ) +// String returns the human-readable socket pattern name. +func (mode Mode) String() string { + switch mode { + case ModePubSub: + return "pubsub" + case ModePushPull: + return "pushpull" + default: + return "unknown" + } +} + // role := zmq.RoleSubscriber type Role int @@ -45,6 +57,22 @@ const ( RolePuller ) +// String returns the human-readable socket role name. +func (role Role) String() string { + switch role { + case RolePublisher: + return "publisher" + case RoleSubscriber: + return "subscriber" + case RolePusher: + return "pusher" + case RolePuller: + return "puller" + default: + return "unknown" + } +} + // config := zmq.Config{ // Mode: zmq.ModePubSub, // Endpoint: "tcp://127.0.0.1:5555", diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go index 802c5f6..8d5a7b8 100644 --- a/adapter/zmq/zmq_test.go +++ b/adapter/zmq/zmq_test.go @@ -425,6 +425,27 @@ func TestAdapter_Start_Auth_HandshakeTooLarge_Good(t *testing.T) { t.Fatal("timed out waiting for handshake rejection") } +func TestModeAndRole_String_Good(t *testing.T) { + if ModePubSub.String() != "pubsub" { + t.Fatalf("ModePubSub.String() = %q, want %q", ModePubSub.String(), "pubsub") + } + if ModePushPull.String() != "pushpull" { + t.Fatalf("ModePushPull.String() = %q, want %q", ModePushPull.String(), "pushpull") + } + if RolePublisher.String() != "publisher" { + t.Fatalf("RolePublisher.String() = %q, want %q", RolePublisher.String(), "publisher") + } + if RoleSubscriber.String() != "subscriber" { + t.Fatalf("RoleSubscriber.String() = %q, want %q", RoleSubscriber.String(), "subscriber") + } + if RolePusher.String() != "pusher" { + t.Fatalf("RolePusher.String() = %q, want %q", RolePusher.String(), "pusher") + } + if RolePuller.String() != "puller" { + t.Fatalf("RolePuller.String() = %q, want %q", RolePuller.String(), "puller") + } +} + func randomTCPEndpoint(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/message.go b/message.go index b183229..10fec89 100644 --- a/message.go +++ b/message.go @@ -7,6 +7,11 @@ import "time" // messageType := stream.TypeEvent type MessageType string +// String returns the canonical wire value for the message type. +func (messageType MessageType) String() string { + return string(messageType) +} + const ( // message := stream.Message{Type: stream.TypeProcessOutput, ProcessID: "build-123"} TypeProcessOutput MessageType = "process_output" @@ -26,12 +31,12 @@ const ( TypeUnsubscribe MessageType = "unsubscribe" ) -// msg := stream.Message{ -// Type: stream.TypeEvent, -// Channel: "hashrate", -// Data: map[string]any{"h": 1234567}, -// Timestamp: time.Now().UTC(), -// } +// msg := stream.Message{ +// Type: stream.TypeEvent, +// Channel: "hashrate", +// Data: map[string]any{"h": 1234567}, +// Timestamp: time.Now().UTC(), +// } // // frame, _ := core.JSONMarshal(msg) // _ = frame diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..6085b55 --- /dev/null +++ b/message_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream + +import "testing" + +func TestMessageType_String_Good(t *testing.T) { + if TypeEvent.String() != "event" { + t.Fatalf("TypeEvent.String() = %q, want %q", TypeEvent.String(), "event") + } + if TypeSubscribe.String() != "subscribe" { + t.Fatalf("TypeSubscribe.String() = %q, want %q", TypeSubscribe.String(), "subscribe") + } +} From ce6e0433ff74abe8d8c70433240fc2d90e75e55c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:30:41 +0000 Subject: [PATCH 113/140] docs(ax): align public comments with AX examples Co-Authored-By: Virgil --- auth.go | 78 +++++++++++++++++++++++++-------------------------- hub_config.go | 16 +++++------ message.go | 3 +- stream.go | 2 +- 4 files changed, 50 insertions(+), 49 deletions(-) diff --git a/auth.go b/auth.go index bcf0ce0..f6e79f8 100644 --- a/auth.go +++ b/auth.go @@ -8,18 +8,18 @@ import ( "dappco.re/go/core" ) -// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// }) +// auth := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// }) type Authenticator interface { Authenticate(request *http.Request) AuthResult } -// result := stream.AuthResult{ -// Valid: true, -// UserID: "user-42", -// Claims: map[string]any{"role": "admin"}, -// } +// result := stream.AuthResult{ +// Valid: true, +// UserID: "user-42", +// Claims: map[string]any{"role": "admin"}, +// } type AuthResult struct { Valid bool @@ -30,9 +30,9 @@ type AuthResult struct { Error error } -// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// }) +// authenticator := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// }) type AuthenticatorFunc func(request *http.Request) AuthResult // result := authenticatorFunc.Authenticate(request) @@ -81,14 +81,14 @@ func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) Au return AuthResult{Valid: true, UserID: userID} } -// authenticator := &stream.BearerTokenAuth{ -// Validate: func(token string) stream.AuthResult { -// if token == "sk-live" { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// } -// return stream.AuthResult{Valid: false} -// }, -// } +// authenticator := &stream.BearerTokenAuth{ +// Validate: func(token string) stream.AuthResult { +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} +// }, +// } type BearerTokenAuth struct { Validate func(token string) AuthResult } @@ -105,14 +105,14 @@ func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthRe return authenticator.Validate(token) } -// authenticator := &stream.QueryTokenAuth{ -// Validate: func(token string) stream.AuthResult { -// if token == "sk-live" { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// } -// return stream.AuthResult{Valid: false} -// }, -// } +// authenticator := &stream.QueryTokenAuth{ +// Validate: func(token string) stream.AuthResult { +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} +// }, +// } type QueryTokenAuth struct { Validate func(token string) AuthResult } @@ -129,22 +129,22 @@ func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthRes return authenticator.Validate(token) } -// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if string(handshake) == "hello" { -// return stream.AuthResult{Valid: true, UserID: "peer-1"} -// } -// return stream.AuthResult{Valid: false} -// }) +// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} +// }) type ConnAuthenticator interface { AuthenticateConn(handshake []byte) AuthResult } -// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { -// if string(handshake) == "hello" { -// return stream.AuthResult{Valid: true, UserID: "peer-1"} -// } -// return stream.AuthResult{Valid: false} -// }) +// auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { +// if string(handshake) == "hello" { +// return stream.AuthResult{Valid: true, UserID: "peer-1"} +// } +// return stream.AuthResult{Valid: false} +// }) type ConnAuthenticatorFunc func(handshake []byte) AuthResult // result := auth.AuthenticateConn([]byte("hello")) diff --git a/hub_config.go b/hub_config.go index 7bf0884..2aa347e 100644 --- a/hub_config.go +++ b/hub_config.go @@ -9,14 +9,14 @@ import "time" // }) type ChannelAuthoriser func(peer *Peer, channel string) bool -// config := stream.HubConfig{ -// HeartbeatInterval: 30 * time.Second, -// PongTimeout: 60 * time.Second, -// WriteTimeout: 10 * time.Second, -// OnConnect: func(peer *stream.Peer) { -// metrics.Inc("peers") -// }, -// } +// cfg := stream.HubConfig{ +// HeartbeatInterval: 30 * time.Second, +// PongTimeout: 60 * time.Second, +// WriteTimeout: 10 * time.Second, +// OnConnect: func(peer *stream.Peer) { +// metrics.Inc("peers") +// }, +// } type HubConfig struct { // config := stream.HubConfig{HeartbeatInterval: 30 * time.Second} HeartbeatInterval time.Duration diff --git a/message.go b/message.go index 10fec89..bc377ca 100644 --- a/message.go +++ b/message.go @@ -7,7 +7,8 @@ import "time" // messageType := stream.TypeEvent type MessageType string -// String returns the canonical wire value for the message type. +// typ := stream.TypeEvent.String() +// // typ == "event" func (messageType MessageType) String() string { return string(messageType) } diff --git a/stream.go b/stream.go index 899a0fe..7896392 100644 --- a/stream.go +++ b/stream.go @@ -66,7 +66,7 @@ type Peer struct { Claims map[string]any // Transport identifies the adapter type for logging and metrics. - // Values: "ws", "sse", "tcp", "zmq" + // Values: "ws", "sse", "tcp", "zmq", "redis" Transport string sendQueue chan []byte From a4270055b0ac8dbf587c28d38c3a979f966f987f Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:33:57 +0000 Subject: [PATCH 114/140] style(ax): sharpen package examples Co-Authored-By: Virgil --- adapter/redis/redis.go | 2 +- adapter/ws/ws.go | 4 ++-- adapter/zmq/zmq.go | 6 +++--- stream.go | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 1952f32..631deb1 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -4,7 +4,7 @@ // // bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) // if err != nil { -// return err +// return err // } // go bridge.Start(ctx) package redis diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 35e00fa..c1f9d14 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -1,10 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package ws mounts gorilla/websocket on a stream hub. +// // adapter := ws.New(ws.Config{Authenticator: auth}) // adapter.Mount(hub) // http.Handle("/stream/ws", adapter.Handler()) -// -// Package ws mounts gorilla/websocket on a stream hub. package ws import ( diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 7204778..31d50a1 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -3,9 +3,9 @@ // Package zmq wires a hub to ZeroMQ sockets. // // adapter := zmq.New(zmq.Config{ -// Mode: zmq.ModePubSub, -// Endpoint: "tcp://127.0.0.1:5555", -// Role: zmq.RoleSubscriber, +// Mode: zmq.ModePubSub, +// Endpoint: "tcp://127.0.0.1:5555", +// Role: zmq.RoleSubscriber, // }) // adapter.Mount(hub) // go adapter.Start(ctx) diff --git a/stream.go b/stream.go index 7896392..b0d2dbf 100644 --- a/stream.go +++ b/stream.go @@ -1,10 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package stream provides the shared hub primitives. +// // hub := stream.NewHub() // go hub.Run(ctx) -// hub.Publish("hashrate", []byte(`{"h":123456}`)) -// -// Package stream wires one hub to many transports. +// _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) package stream import ( From 52382dc9096acac4634fb90c471fc27f85d7d5bb Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:36:26 +0000 Subject: [PATCH 115/140] style(ax): sharpen package docs Co-Authored-By: Virgil --- auth.go | 4 ++++ hub.go | 5 +++++ stream.go | 5 +++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/auth.go b/auth.go index f6e79f8..a7ce9fc 100644 --- a/auth.go +++ b/auth.go @@ -1,5 +1,9 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package stream provides HTTP and connection authenticators. +// +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// result := auth.Authenticate(request) package stream import ( diff --git a/hub.go b/hub.go index 2f5acc6..fc16895 100644 --- a/hub.go +++ b/hub.go @@ -1,5 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package stream provides hub composition primitives. +// +// hub := stream.NewHub() +// go hub.Run(ctx) +// _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) package stream import ( diff --git a/stream.go b/stream.go index b0d2dbf..a305810 100644 --- a/stream.go +++ b/stream.go @@ -1,10 +1,11 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream provides the shared hub primitives. +// Package stream wires transport-agnostic hubs and peers together. // // hub := stream.NewHub() // go hub.Run(ctx) -// _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) +// stop := hub.Pipe(remoteHub) +// defer stop() package stream import ( From b5415b3f55aa5f8dc0ebf80e11879dd87b842c2b Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:40:53 +0000 Subject: [PATCH 116/140] fix(hub): lock peer subscription state Co-Authored-By: Virgil --- adapter/zmq/zmq.go | 4 +-- hub.go | 6 ++++ hub_config.go | 2 +- hub_test.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 31d50a1..eef5c61 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -35,7 +35,7 @@ const ( ModePushPull ) -// String returns the human-readable socket pattern name. +// core.Print(nil, "mode=%s", zmq.ModePubSub.String()) func (mode Mode) String() string { switch mode { case ModePubSub: @@ -57,7 +57,7 @@ const ( RolePuller ) -// String returns the human-readable socket role name. +// core.Print(nil, "role=%s", zmq.RoleSubscriber.String()) func (role Role) String() string { switch role { case RolePublisher: diff --git a/hub.go b/hub.go index fc16895..6db80ce 100644 --- a/hub.go +++ b/hub.go @@ -260,6 +260,7 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { if hub.config.ChannelAuthoriser != nil && channel != "*" && !hub.config.ChannelAuthoriser(peer, channel) { return ErrAuthRejected } + peer.mutex.Lock() if peer.sendQueue == nil { peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) } @@ -267,6 +268,7 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { peer.subscriptions = map[string]bool{} } peer.subscriptions[channel] = true + peer.mutex.Unlock() if hub.channels[channel] == nil { hub.channels[channel] = map[*Peer]bool{} } @@ -300,7 +302,9 @@ func (hub *Hub) UnsubscribePeer(peer *Peer, channel string) { } hub.mutex.Lock() defer hub.mutex.Unlock() + peer.mutex.Lock() delete(peer.subscriptions, channel) + peer.mutex.Unlock() if peers := hub.channels[channel]; peers != nil { delete(peers, peer) if len(peers) == 0 { @@ -519,12 +523,14 @@ func (hub *Hub) AddPeer(peer *Peer) error { if peer == nil { return core.E("stream.hub", "nil peer", nil) } + peer.mutex.Lock() if peer.sendQueue == nil { peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} } + peer.mutex.Unlock() hub.mutex.RLock() running := hub.running hub.mutex.RUnlock() diff --git a/hub_config.go b/hub_config.go index 2aa347e..1f428df 100644 --- a/hub_config.go +++ b/hub_config.go @@ -9,7 +9,7 @@ import "time" // }) type ChannelAuthoriser func(peer *Peer, channel string) bool -// cfg := stream.HubConfig{ +// config := stream.HubConfig{ // HeartbeatInterval: 30 * time.Second, // PongTimeout: 60 * time.Second, // WriteTimeout: 10 * time.Second, diff --git a/hub_test.go b/hub_test.go index be16951..9d07339 100644 --- a/hub_test.go +++ b/hub_test.go @@ -676,6 +676,77 @@ func TestHub_CanSubscribePeer_Bad(t *testing.T) { } } +func TestPeer_Subscriptions_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer(hashrate) error = %v", err) + } + if err := hub.SubscribePeer(peer, "block"); err != nil { + t.Fatalf("SubscribePeer(block) error = %v", err) + } + + subscriptions := peer.Subscriptions() + if len(subscriptions) != 2 { + t.Fatalf("len(Subscriptions()) = %d, want %d", len(subscriptions), 2) + } + if subscriptions[0] != "block" || subscriptions[1] != "hashrate" { + t.Fatalf("Subscriptions() = %v, want [block hashrate]", subscriptions) + } + + hub.UnsubscribePeer(peer, "block") + subscriptions = peer.Subscriptions() + if len(subscriptions) != 1 || subscriptions[0] != "hashrate" { + t.Fatalf("Subscriptions() after unsubscribe = %v, want [hashrate]", subscriptions) + } +} + +func TestPeer_Subscriptions_Bad(t *testing.T) { + var peer *Peer + + if subscriptions := peer.Subscriptions(); subscriptions != nil { + t.Fatalf("Subscriptions() = %v, want nil", subscriptions) + } +} + +func TestPeer_Subscriptions_Ugly(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer() error = %v", err) + } + + subscriptions := peer.Subscriptions() + subscriptions[0] = "tampered" + + current := peer.Subscriptions() + if len(current) != 1 || current[0] != "hashrate" { + t.Fatalf("Subscriptions() after caller mutation = %v, want [hashrate]", current) + } +} + func TestHub_SendToChannel_Wildcard_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) From 79347bf061abeeb54bd19a36246e0c991d37ae16 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:44:00 +0000 Subject: [PATCH 117/140] style(ax): sharpen package examples Co-Authored-By: Virgil --- auth.go | 2 +- hub.go | 5 +++-- message.go | 7 +++++++ stats.go | 6 ++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/auth.go b/auth.go index a7ce9fc..f3b3450 100644 --- a/auth.go +++ b/auth.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream provides HTTP and connection authenticators. +// Package stream authenticates HTTP upgrades and raw handshakes. // // auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) // result := auth.Authenticate(request) diff --git a/hub.go b/hub.go index 6db80ce..fbf165c 100644 --- a/hub.go +++ b/hub.go @@ -1,10 +1,11 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream provides hub composition primitives. +// Package stream composes hubs, peers, and pipes. // // hub := stream.NewHub() // go hub.Run(ctx) -// _ = hub.Publish("hashrate", []byte(`{"h":123456}`)) +// stop := hub.Pipe(remoteHub) +// defer stop() package stream import ( diff --git a/message.go b/message.go index bc377ca..619e938 100644 --- a/message.go +++ b/message.go @@ -1,5 +1,12 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package stream preserves the WebSocket message envelope. +// +// msg := stream.Message{ +// Type: stream.TypeEvent, +// Channel: "hashrate", +// Data: map[string]any{"h": 1234567}, +// } package stream import "time" diff --git a/stats.go b/stats.go index d6261a8..c454c21 100644 --- a/stats.go +++ b/stats.go @@ -1,9 +1,11 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package stream snapshots hub state. +// +// stats := hub.Stats() +// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) package stream -// stats := hub.Stats() -// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) type HubStats struct { // stats := hub.Stats() // core.Print("stream", "peers=%d", stats.Peers) From 4520fc04155ba9f4ff9668e9275f251fc10035bf Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:48:08 +0000 Subject: [PATCH 118/140] fix(tcp): allow large unauthenticated initial frames Co-Authored-By: Virgil --- adapter/tcp/tcp.go | 6 ++++- adapter/tcp/tcp_test.go | 51 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index d3fc969..043e20e 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -211,7 +211,11 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre }) defer stopClose() - channel, frame, err := readFrame(conn, adapter.config.HandshakeTimeout, maxHandshakeFrameSize) + handshakeMaxSize := MaxFrameSize + if adapter.config.ConnAuthenticator != nil { + handshakeMaxSize = maxHandshakeFrameSize + } + channel, frame, err := readFrame(conn, adapter.config.HandshakeTimeout, handshakeMaxSize) if err != nil { return } diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 7e7f0d3..7292c82 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -3,6 +3,7 @@ package tcp import ( + "bytes" "context" "io" "net" @@ -282,7 +283,7 @@ func TestTCP_Listen_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } -func TestTCP_Listen_HandshakeTooLarge_Good(t *testing.T) { +func TestTCP_Listen_NoAuthenticator_LargeInitialFrame_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -306,6 +307,54 @@ func TestTCP_Listen_HandshakeTooLarge_Good(t *testing.T) { } defer connection.Close() + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("block", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + largeFrame := bytes.Repeat([]byte("a"), maxHandshakeFrameSize+1) + if _, err := connection.Write(encodeFrame("block", largeFrame)); err != nil { + t.Fatalf("Write() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != string(largeFrame) { + t.Fatalf("received frame size = %d, want %d", len(frame), len(largeFrame)) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for large initial frame") + } +} + +func TestTCP_Listen_AuthHandshakeTooLarge_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + Addr: "127.0.0.1:0", + ConnAuthenticator: stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { + return stream.AuthResult{Valid: true} + }), + }) + adapter.Mount(hub) + + listenContext, listenCancel := context.WithCancel(context.Background()) + defer listenCancel() + go func() { + _ = adapter.Listen(listenContext) + }() + + address := waitForListenerAddress(t, adapter) + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer connection.Close() + tooLargeHandshake := make([]byte, maxHandshakeFrameSize+1) if _, err := connection.Write(encodeFrame("", tooLargeHandshake)); err != nil { t.Fatalf("Write() error = %v", err) From a13339e9712b3b6d0f49e38ffb98a03be26c4c76 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:51:03 +0000 Subject: [PATCH 119/140] fix(stream): count handler-only channels in stats Co-Authored-By: Virgil --- hub.go | 50 ++++++++++++++++++++++++++++++++++++++++++-------- hub_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/hub.go b/hub.go index fbf165c..624c129 100644 --- a/hub.go +++ b/hub.go @@ -382,7 +382,16 @@ func (hub *Hub) Stats() HubStats { if channel == "*" { continue } - subscriberCount[channel] = len(peers) + subscriberCount[channel] = len(peers) + len(hub.channelHandlers[channel]) + } + for channel, handlers := range hub.channelHandlers { + if channel == "*" { + continue + } + if _, exists := subscriberCount[channel]; exists { + continue + } + subscriberCount[channel] = len(handlers) } return HubStats{ Peers: len(hub.peers), @@ -440,7 +449,22 @@ func (hub *Hub) ChannelCount() int { defer hub.mutex.RUnlock() count := 0 for channel, peers := range hub.channels { - if channel == "*" || len(peers) == 0 { + if channel == "*" { + continue + } + if len(peers)+len(hub.channelHandlers[channel]) == 0 { + continue + } + count++ + } + for channel, handlers := range hub.channelHandlers { + if channel == "*" { + continue + } + if len(handlers) == 0 { + continue + } + if len(hub.channels[channel]) > 0 { continue } count++ @@ -455,7 +479,7 @@ func (hub *Hub) ChannelSubscriberCount(channel string) int { } hub.mutex.RLock() defer hub.mutex.RUnlock() - return len(hub.channels[channel]) + return len(hub.channels[channel]) + len(hub.channelHandlers[channel]) } // for peer := range hub.AllPeers() { @@ -497,17 +521,27 @@ func (hub *Hub) AllChannels() iter.Seq[string] { return func(yield func(string) bool) {} } hub.mutex.RLock() - channels := make([]string, 0, len(hub.channels)) + channels := make(map[string]struct{}, len(hub.channels)+len(hub.channelHandlers)) for channel, peers := range hub.channels { - if channel == "*" || len(peers) == 0 { + if channel == "*" || len(peers)+len(hub.channelHandlers[channel]) == 0 { continue } - channels = append(channels, channel) + channels[channel] = struct{}{} + } + for channel, handlers := range hub.channelHandlers { + if channel == "*" || len(handlers) == 0 { + continue + } + channels[channel] = struct{}{} } hub.mutex.RUnlock() - sort.Strings(channels) + sortedChannels := make([]string, 0, len(channels)) + for channel := range channels { + sortedChannels = append(sortedChannels, channel) + } + sort.Strings(sortedChannels) return func(yield func(string) bool) { - for _, channel := range channels { + for _, channel := range sortedChannels { if !yield(channel) { return } diff --git a/hub_test.go b/hub_test.go index 9d07339..915363e 100644 --- a/hub_test.go +++ b/hub_test.go @@ -599,6 +599,43 @@ func TestHub_SubscribeWithError_Good(t *testing.T) { } } +func TestHub_Stats_IncludeHandlerOnlyChannels_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + unsubscribe := hub.Subscribe("events", func(frame []byte) {}) + defer unsubscribe() + + stats := hub.Stats() + if stats.Peers != 0 { + t.Fatalf("Stats().Peers = %d, want %d", stats.Peers, 0) + } + if stats.Channels != 1 { + t.Fatalf("Stats().Channels = %d, want %d", stats.Channels, 1) + } + if stats.SubscriberCount["events"] != 1 { + t.Fatalf("Stats().SubscriberCount[events] = %d, want %d", stats.SubscriberCount["events"], 1) + } + if hub.ChannelCount() != 1 { + t.Fatalf("ChannelCount() = %d, want %d", hub.ChannelCount(), 1) + } + if hub.ChannelSubscriberCount("events") != 1 { + t.Fatalf("ChannelSubscriberCount(events) = %d, want %d", hub.ChannelSubscriberCount("events"), 1) + } + + channels := make([]string, 0, 1) + for channel := range hub.AllChannels() { + channels = append(channels, channel) + } + if len(channels) != 1 || channels[0] != "events" { + t.Fatalf("AllChannels() = %v, want [events]", channels) + } +} + func TestHub_SubscribeE_Bad(t *testing.T) { hub := NewHub() From c9d74c04c4e8ebcd11a17988a479715a11e54519 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:55:22 +0000 Subject: [PATCH 120/140] style(ax): sharpen package docs Co-Authored-By: Virgil --- adapter/redis/redis.go | 2 -- adapter/sse/sse.go | 2 -- adapter/tcp/tcp.go | 2 -- adapter/ws/ws.go | 2 -- adapter/zmq/zmq.go | 2 -- hub.go | 2 -- stats.go | 2 -- 7 files changed, 14 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 631deb1..aa78acf 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package redis bridges a hub through Redis pub/sub. -// // bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) // if err != nil { // return err diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 1f7dedf..a7cbd40 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package sse streams hub frames over Server-Sent Events. -// // adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) // adapter.Mount(hub) // http.Handle("/stream/events", adapter.Handler()) diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 043e20e..072eaf1 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package tcp carries hub frames over raw TCP. -// // adapter := tcp.New(tcp.Config{Addr: ":9000"}) // adapter.Mount(hub) // go adapter.Listen(ctx) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index c1f9d14..2fbaded 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package ws mounts gorilla/websocket on a stream hub. -// // adapter := ws.New(ws.Config{Authenticator: auth}) // adapter.Mount(hub) // http.Handle("/stream/ws", adapter.Handler()) diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index eef5c61..dd20046 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package zmq wires a hub to ZeroMQ sockets. -// // adapter := zmq.New(zmq.Config{ // Mode: zmq.ModePubSub, // Endpoint: "tcp://127.0.0.1:5555", diff --git a/hub.go b/hub.go index 624c129..eff8131 100644 --- a/hub.go +++ b/hub.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream composes hubs, peers, and pipes. -// // hub := stream.NewHub() // go hub.Run(ctx) // stop := hub.Pipe(remoteHub) diff --git a/stats.go b/stats.go index c454c21..16c75e9 100644 --- a/stats.go +++ b/stats.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream snapshots hub state. -// // stats := hub.Stats() // core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) package stream From e9a66d6ee5e48cbc0a2865d95c164d6b3a1ac219 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:58:30 +0000 Subject: [PATCH 121/140] style(ax): sharpen stream examples Co-Authored-By: Virgil --- hub.go | 2 +- stats.go | 3 +-- stream.go | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hub.go b/hub.go index eff8131..288ef75 100644 --- a/hub.go +++ b/hub.go @@ -140,7 +140,7 @@ func (hub *Hub) Run(ctx context.Context) { } } -// _ = hub.SendToChannel("hashrate", []byte(`{"h":123456}`)) +// hub.SendToChannel("hashrate", []byte(`{"h":123456}`)) func (hub *Hub) SendToChannel(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, true) } diff --git a/stats.go b/stats.go index 16c75e9..dab9524 100644 --- a/stats.go +++ b/stats.go @@ -1,9 +1,8 @@ // SPDX-License-Identifier: EUPL-1.2 -// stats := hub.Stats() -// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) package stream +// HubStats matches the snapshot returned by hub.Stats(). type HubStats struct { // stats := hub.Stats() // core.Print("stream", "peers=%d", stats.Peers) diff --git a/stream.go b/stream.go index a305810..00636f3 100644 --- a/stream.go +++ b/stream.go @@ -125,6 +125,7 @@ func (peer *Peer) Send(frame []byte) bool { } } +// peer := stream.NewPeer("ws") // peer.SetCloseHook(func() { _ = conn.Close() }) // peer.Close() func (peer *Peer) Close() { From ab8c74be387d7ee689d287081d6165b0c89e6cf2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:01:18 +0000 Subject: [PATCH 122/140] fix(ws): re-export channel authoriser alias Co-Authored-By: Virgil --- adapter/ws/compat.go | 3 +++ adapter/ws/compat_test.go | 5 +++++ ws/compat.go | 3 +++ ws/compat_test.go | 5 +++++ 4 files changed, 16 insertions(+) diff --git a/adapter/ws/compat.go b/adapter/ws/compat.go index 5c7e6b6..0dd2cfa 100644 --- a/adapter/ws/compat.go +++ b/adapter/ws/compat.go @@ -24,6 +24,9 @@ type Hub = stream.Hub // HubConfig preserves the legacy go-ws HubConfig type name. type HubConfig = stream.HubConfig +// ChannelAuthoriser preserves the legacy go-ws channel authoriser type name. +type ChannelAuthoriser = stream.ChannelAuthoriser + // HubStats preserves the legacy hub stats type name. type HubStats = stream.HubStats diff --git a/adapter/ws/compat_test.go b/adapter/ws/compat_test.go index 1f8b885..e105f6f 100644 --- a/adapter/ws/compat_test.go +++ b/adapter/ws/compat_test.go @@ -24,6 +24,11 @@ func TestCompat_LegacySurface_Good(t *testing.T) { t.Fatalf("Channel alias produced %q, want %q", channel, "hashrate") } + var authoriser ChannelAuthoriser + if authoriser != nil { + t.Fatal("ChannelAuthoriser alias should default to nil") + } + if StateDisconnected != 0 || StateConnecting != 1 || StateConnected != 2 { t.Fatalf("unexpected connection states: %d %d %d", StateDisconnected, StateConnecting, StateConnected) } diff --git a/ws/compat.go b/ws/compat.go index 6c0bdab..83b3d1c 100644 --- a/ws/compat.go +++ b/ws/compat.go @@ -26,6 +26,9 @@ type Channel = stream.Channel // HubConfig preserves the legacy go-ws HubConfig type name. type HubConfig = stream.HubConfig +// ChannelAuthoriser preserves the legacy go-ws channel authoriser type name. +type ChannelAuthoriser = stream.ChannelAuthoriser + // HubStats preserves the legacy hub stats type name. type HubStats = stream.HubStats diff --git a/ws/compat_test.go b/ws/compat_test.go index 9a2da1f..e9965fa 100644 --- a/ws/compat_test.go +++ b/ws/compat_test.go @@ -27,6 +27,11 @@ func TestCompat_LegacySurface_Good(t *testing.T) { t.Fatalf("Channel alias produced %q, want %q", channel, "hashrate") } + var authoriser ChannelAuthoriser + if authoriser != nil { + t.Fatal("ChannelAuthoriser alias should default to nil") + } + if StateDisconnected != 0 || StateConnecting != 1 || StateConnected != 2 { t.Fatalf("unexpected connection states: %d %d %d", StateDisconnected, StateConnecting, StateConnected) } From 87a65d4495d28250442c4f781d9fb42020e90e65 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:04:23 +0000 Subject: [PATCH 123/140] style(ax): sharpen stream examples Co-Authored-By: Virgil --- auth.go | 6 ++---- message.go | 5 +++-- stats.go | 3 ++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/auth.go b/auth.go index f3b3450..eb361a3 100644 --- a/auth.go +++ b/auth.go @@ -1,9 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream authenticates HTTP upgrades and raw handshakes. -// -// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) -// result := auth.Authenticate(request) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// result := auth.Authenticate(request) package stream import ( diff --git a/message.go b/message.go index 619e938..df105bf 100644 --- a/message.go +++ b/message.go @@ -1,12 +1,13 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package stream preserves the WebSocket message envelope. -// // msg := stream.Message{ // Type: stream.TypeEvent, // Channel: "hashrate", // Data: map[string]any{"h": 1234567}, // } +// +// frame, _ := core.JSONMarshal(msg) +// _ = frame package stream import "time" diff --git a/stats.go b/stats.go index dab9524..d6261a8 100644 --- a/stats.go +++ b/stats.go @@ -2,7 +2,8 @@ package stream -// HubStats matches the snapshot returned by hub.Stats(). +// stats := hub.Stats() +// core.Print("stream", "peers=%d channels=%d", stats.Peers, stats.Channels) type HubStats struct { // stats := hub.Stats() // core.Print("stream", "peers=%d", stats.Peers) From 29832b4e552c4288d790cb79933951e1acf386f5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:08:03 +0000 Subject: [PATCH 124/140] fix(adapters): harden stream framing Co-Authored-By: Virgil --- adapter/sse/sse.go | 28 ++++++++++++---- adapter/sse/sse_test.go | 41 +++++++++++++++++++++++ adapter/tcp/reconnect.go | 19 +++++++---- adapter/tcp/tcp_test.go | 72 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index a7cbd40..18479dc 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -1,11 +1,12 @@ // SPDX-License-Identifier: EUPL-1.2 -// adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) -// adapter.Mount(hub) -// http.Handle("/stream/events", adapter.Handler()) +// adapter := sse.New(sse.Config{HeartbeatInterval: 15 * time.Second}) +// adapter.Mount(hub) +// http.Handle("/stream/events", adapter.Handler()) package sse import ( + "bytes" "io" "net/http" "strconv" @@ -156,6 +157,8 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ } } + header.Set("Connection", "keep-alive") + _, _ = io.WriteString(w, "retry: "+strconv.Itoa(config.RetryMs)+"\n\n") flusher.Flush() @@ -173,13 +176,24 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ if !ok { return } - _, _ = io.WriteString(w, "data: ") - _, _ = w.Write(frame) - _, _ = io.WriteString(w, "\n\n") + writeEventFrame(w, frame) flusher.Flush() case <-ticker.C: - _, _ = io.WriteString(w, ": ping\n\n") + writeHeartbeatFrame(w) flusher.Flush() } } } + +func writeEventFrame(writer io.Writer, frame []byte) { + for _, line := range bytes.Split(frame, []byte{'\n'}) { + _, _ = io.WriteString(writer, "data: ") + _, _ = writer.Write(line) + _, _ = io.WriteString(writer, "\n") + } + _, _ = io.WriteString(writer, "\n") +} + +func writeHeartbeatFrame(writer io.Writer) { + _, _ = io.WriteString(writer, ": ping\n\n") +} diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 0fea3a7..670e7b5 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -85,6 +85,47 @@ func TestAdapter_Handler_ZeroValueConfig_Good(t *testing.T) { } } +func TestAdapter_Handler_MultilineFrame_Good(t *testing.T) { + hub := stream.NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{HeartbeatInterval: 20 * time.Millisecond}) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + response, err := http.Get(server.URL + "?channel=hashrate") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + waitForPeerCount(t, hub, 1) + if err := hub.Publish("hashrate", []byte("123\n456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + reader := bufio.NewReader(response.Body) + lines := make([]string, 0, 4) + for len(lines) < 4 { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("ReadString() error = %v", err) + } + lines = append(lines, line) + } + + expected := []string{"retry: 3000\n", "\n", "data: 123\n", "data: 456\n"} + for index, line := range expected { + if lines[index] != line { + t.Fatalf("lines[%d] = %q, want %q", index, lines[index], line) + } + } +} + func TestAdapter_Handler_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index a2d42c5..c7efbc8 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -44,10 +44,11 @@ type ReconnectConfig struct { type ReconnectingTCP struct { config ReconnectConfig - mutex sync.RWMutex - conn net.Conn - state stream.ConnectionState - closed bool + mutex sync.RWMutex + writeMutex sync.Mutex + conn net.Conn + state stream.ConnectionState + closed bool } // client := tcp.NewReconnectingTCP(tcp.ReconnectConfig{Addr: "10.69.69.165:9000"}) @@ -163,12 +164,16 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { if client == nil { return core.E("stream.tcp", "nil reconnecting tcp", nil) } + client.writeMutex.Lock() + defer client.writeMutex.Unlock() + client.mutex.RLock() - defer client.mutex.RUnlock() - if client.conn == nil { + connection := client.conn + client.mutex.RUnlock() + if connection == nil { return core.E("stream.tcp", "not connected", nil) } - return writeFull(client.conn, encodeFrame(channel, frame)) + return writeFull(connection, encodeFrame(channel, frame)) } // if client.State() == stream.StateConnected { diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 7292c82..bc35362 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -7,6 +7,7 @@ import ( "context" "io" "net" + "sync" "sync/atomic" "testing" "time" @@ -328,6 +329,77 @@ func TestTCP_Listen_NoAuthenticator_LargeInitialFrame_Good(t *testing.T) { } } +func TestReconnectingTCP_Send_Concurrent_Good(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen() error = %v", err) + } + defer listener.Close() + + serverAccepted := make(chan net.Conn, 1) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + serverAccepted <- connection + }() + + client := NewReconnectingTCP(ReconnectConfig{Addr: listener.Addr().String()}) + + connectContext, connectCancel := context.WithCancel(context.Background()) + connectDone := make(chan error, 1) + go func() { + connectDone <- client.Connect(connectContext) + }() + defer func() { + connectCancel() + _ = client.Close() + <-connectDone + }() + + serverConnection := <-serverAccepted + defer serverConnection.Close() + + for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { + if client.State() == stream.StateConnected { + break + } + time.Sleep(10 * time.Millisecond) + } + if client.State() != stream.StateConnected { + t.Fatal("client did not reach connected state") + } + + senderCount := 32 + var sendGroup sync.WaitGroup + for index := range senderCount { + sendGroup.Add(1) + go func(index int) { + defer sendGroup.Done() + if sendErr := client.Send("hashrate", []byte{byte(index)}); sendErr != nil { + t.Errorf("Send() error = %v", sendErr) + } + }(index) + } + sendGroup.Wait() + + receivedValues := map[byte]bool{} + for len(receivedValues) < senderCount { + channel, frame, readErr := readFrame(serverConnection, 2*time.Second, MaxFrameSize) + if readErr != nil { + t.Fatalf("readFrame() error = %v", readErr) + } + if channel != "hashrate" { + t.Fatalf("readFrame() channel = %q, want %q", channel, "hashrate") + } + if len(frame) != 1 { + t.Fatalf("len(frame) = %d, want %d", len(frame), 1) + } + receivedValues[frame[0]] = true + } +} + func TestTCP_Listen_AuthHandshakeTooLarge_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) From df1c26ec6432501934e4f84d9556cd149d287567 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:10:57 +0000 Subject: [PATCH 125/140] docs(stream): improve auth examples Co-Authored-By: Virgil --- auth.go | 31 ++++++++++++++++++++----------- message.go | 7 ++++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/auth.go b/auth.go index eb361a3..bf030a0 100644 --- a/auth.go +++ b/auth.go @@ -1,6 +1,8 @@ // SPDX-License-Identifier: EUPL-1.2 // auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) +// request.Header.Set("Authorization", "Bearer sk-live") // result := auth.Authenticate(request) package stream @@ -11,7 +13,10 @@ import ( ) // auth := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { -// return stream.AuthResult{Valid: true, UserID: "user-42"} +// if request.Header.Get("X-Api-Key") == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} // }) type Authenticator interface { Authenticate(request *http.Request) AuthResult @@ -37,6 +42,7 @@ type AuthResult struct { // }) type AuthenticatorFunc func(request *http.Request) AuthResult +// request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) // result := authenticatorFunc.Authenticate(request) func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) AuthResult { if authenticatorFunc == nil || request == nil { @@ -46,14 +52,14 @@ func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) A } // auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) -// request := httptest.NewRequest("GET", "/stream/ws", nil) +// request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) // request.Header.Set("Authorization", "Bearer sk-live") // result := auth.Authenticate(request) type APIKeyAuthenticator struct { Keys map[string]string } -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { if keys == nil { keys = map[string]string{} @@ -65,9 +71,9 @@ func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return &APIKeyAuthenticator{Keys: copied} } -// authenticator := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) +// auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) // request.Header.Set("Authorization", "Bearer sk-live") -// result := authenticator.Authenticate(request) +// result := auth.Authenticate(request) func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) AuthResult { if authenticator == nil || request == nil { return AuthResult{Valid: false} @@ -83,18 +89,20 @@ func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) Au return AuthResult{Valid: true, UserID: userID} } -// authenticator := &stream.BearerTokenAuth{ -// Validate: func(token string) stream.AuthResult { -// if token == "sk-live" { -// return stream.AuthResult{Valid: true, UserID: "user-42"} -// } -// return stream.AuthResult{Valid: false} +// authenticator := &stream.BearerTokenAuth{ +// Validate: func(token string) stream.AuthResult { +// if token == "sk-live" { +// return stream.AuthResult{Valid: true, UserID: "user-42"} +// } +// return stream.AuthResult{Valid: false} // }, // } type BearerTokenAuth struct { Validate func(token string) AuthResult } +// request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) +// request.Header.Set("Authorization", "Bearer sk-live") // result := authenticator.Authenticate(request) func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthResult { if authenticator == nil || authenticator.Validate == nil || request == nil { @@ -119,6 +127,7 @@ type QueryTokenAuth struct { Validate func(token string) AuthResult } +// request := httptest.NewRequest(http.MethodGet, "/stream/ws?token=sk-live", nil) // result := authenticator.Authenticate(request) func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthResult { if authenticator == nil || authenticator.Validate == nil || request == nil { diff --git a/message.go b/message.go index df105bf..33735d0 100644 --- a/message.go +++ b/message.go @@ -1,9 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 // msg := stream.Message{ -// Type: stream.TypeEvent, -// Channel: "hashrate", -// Data: map[string]any{"h": 1234567}, +// Type: stream.TypeEvent, +// Channel: "hashrate", +// Data: map[string]any{"h": 1234567}, +// Timestamp: time.Now().UTC(), // } // // frame, _ := core.JSONMarshal(msg) From 69b7ca3593a05aeca31b572578a7a4fb7b47d761 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:14:40 +0000 Subject: [PATCH 126/140] style(ax): sharpen message envelope example Co-Authored-By: Virgil --- message.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/message.go b/message.go index 33735d0..3c2f04c 100644 --- a/message.go +++ b/message.go @@ -7,8 +7,8 @@ // Timestamp: time.Now().UTC(), // } // -// frame, _ := core.JSONMarshal(msg) -// _ = frame +// frame, _ := core.JSONMarshal(msg) +// hub.Publish("hashrate", frame.Value.([]byte)) package stream import "time" From cf3be2c6045b657629e2043f2d3c05fd5bef7726 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:19:01 +0000 Subject: [PATCH 127/140] style(ax): sharpen legacy ws compatibility example Co-Authored-By: Virgil --- ws/compat.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ws/compat.go b/ws/compat.go index 83b3d1c..95d010d 100644 --- a/ws/compat.go +++ b/ws/compat.go @@ -1,7 +1,8 @@ // SPDX-License-Identifier: EUPL-1.2 -// Package ws preserves the legacy go-ws compatibility surface while the new -// transport-agnostic stream package does the actual work. +// hub := ws.NewHub() +// go hub.Run(ctx) +// http.Handle("/stream/ws", hub.Handler()) package ws import ( From 7cf55bb3a6ecc934fcf6b0baebc7a924ec2cc8f6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:22:19 +0000 Subject: [PATCH 128/140] style(ax): default peer claims to empty map Co-Authored-By: Virgil --- adapter/sse/sse.go | 4 +++- adapter/tcp/tcp.go | 10 ++++++---- adapter/ws/ws.go | 10 ++++++---- adapter/zmq/zmq.go | 11 +++++++---- hub_test.go | 17 +++++++++++++++++ stream.go | 1 + 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index 18479dc..a001cec 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -117,7 +117,9 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ peer := stream.NewPeer("sse") peer.UserID = authResult.UserID - peer.Claims = authResult.Claims + if authResult.Claims != nil { + peer.Claims = authResult.Claims + } done := make(chan struct{}) var doneOnce sync.Once peer.SetCloseHook(func() { diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 072eaf1..4aba4dc 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -1,8 +1,8 @@ // SPDX-License-Identifier: EUPL-1.2 -// adapter := tcp.New(tcp.Config{Addr: ":9000"}) -// adapter.Mount(hub) -// go adapter.Listen(ctx) +// adapter := tcp.New(tcp.Config{Addr: ":9000"}) +// adapter.Mount(hub) +// go adapter.Listen(ctx) package tcp import ( @@ -228,7 +228,9 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre peer := stream.NewPeer("tcp") peer.UserID = authResult.UserID - peer.Claims = authResult.Claims + if authResult.Claims != nil { + peer.Claims = authResult.Claims + } peer.SetCloseHook(func() { _ = conn.Close() }) diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index 2fbaded..cdefe6b 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -1,8 +1,8 @@ // SPDX-License-Identifier: EUPL-1.2 -// adapter := ws.New(ws.Config{Authenticator: auth}) -// adapter.Mount(hub) -// http.Handle("/stream/ws", adapter.Handler()) +// adapter := ws.New(ws.Config{Authenticator: auth}) +// adapter.Mount(hub) +// http.Handle("/stream/ws", adapter.Handler()) package ws import ( @@ -98,7 +98,9 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe peer := stream.NewPeer("ws") peer.UserID = authResult.UserID - peer.Claims = authResult.Claims + if authResult.Claims != nil { + peer.Claims = authResult.Claims + } for _, channel := range channels { if channel == "" { continue diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index dd20046..3f09cee 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -5,9 +5,10 @@ // Endpoint: "tcp://127.0.0.1:5555", // Role: zmq.RoleSubscriber, // }) -// adapter.Mount(hub) -// go adapter.Start(ctx) -// defer adapter.Stop() +// +// adapter.Mount(hub) +// go adapter.Start(ctx) +// defer adapter.Stop() package zmq import ( @@ -225,7 +226,9 @@ func (adapter *Adapter) registerPeer(socket zmq4.Socket, authResult stream.AuthR } peer := stream.NewPeer("zmq") peer.UserID = authResult.UserID - peer.Claims = authResult.Claims + if authResult.Claims != nil { + peer.Claims = authResult.Claims + } if socket != nil { peer.SetCloseHook(func() { _ = socket.Close() diff --git a/hub_test.go b/hub_test.go index 915363e..6a57fcc 100644 --- a/hub_test.go +++ b/hub_test.go @@ -28,6 +28,23 @@ func newTestStream() *testStream { } } +func TestHub_NewPeer_DefaultClaims_Good(t *testing.T) { + peer := NewPeer("ws") + if peer == nil { + t.Fatal("NewPeer() = nil") + } + if peer.Claims == nil { + t.Fatal("NewPeer().Claims = nil, want empty map") + } + if len(peer.Claims) != 0 { + t.Fatalf("len(NewPeer().Claims) = %d, want 0", len(peer.Claims)) + } + peer.Claims["role"] = "worker" + if role := peer.Claims["role"]; role != "worker" { + t.Fatalf("Claims[role] = %v, want %q", role, "worker") + } +} + func (streamValue *testStream) Publish(channel string, frame []byte) error { streamValue.mutex.Lock() streamValue.published = append(streamValue.published, publishedFrame{ diff --git a/stream.go b/stream.go index 00636f3..810f83a 100644 --- a/stream.go +++ b/stream.go @@ -82,6 +82,7 @@ type Peer struct { func NewPeer(transport string) *Peer { return &Peer{ ID: randomUUID(), + Claims: map[string]any{}, Transport: transport, sendQueue: make(chan []byte, defaultPeerSendBufferSize), subscriptions: map[string]bool{}, From 37f55601fcfee83f3f9e52b2951d123a063ae503 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:25:40 +0000 Subject: [PATCH 129/140] fix(auth): initialize successful claims maps Co-Authored-By: Virgil --- auth.go | 22 ++++++++++++++----- auth_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 auth_test.go diff --git a/auth.go b/auth.go index bf030a0..11f96d1 100644 --- a/auth.go +++ b/auth.go @@ -32,6 +32,8 @@ type AuthResult struct { UserID string + // Claims is always initialised on success so callers can add metadata without + // checking for nil first. Claims map[string]any Error error @@ -48,7 +50,7 @@ func (authenticatorFunc AuthenticatorFunc) Authenticate(request *http.Request) A if authenticatorFunc == nil || request == nil { return AuthResult{Valid: false} } - return authenticatorFunc(request) + return normalizeAuthResult(authenticatorFunc(request)) } // auth := stream.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) @@ -86,7 +88,7 @@ func (authenticator *APIKeyAuthenticator) Authenticate(request *http.Request) Au if !ok { return AuthResult{Valid: false, Error: ErrInvalidAPIKey} } - return AuthResult{Valid: true, UserID: userID} + return normalizeAuthResult(AuthResult{Valid: true, UserID: userID}) } // authenticator := &stream.BearerTokenAuth{ @@ -112,7 +114,7 @@ func (authenticator *BearerTokenAuth) Authenticate(request *http.Request) AuthRe if !result.Valid { return result } - return authenticator.Validate(token) + return normalizeAuthResult(authenticator.Validate(token)) } // authenticator := &stream.QueryTokenAuth{ @@ -137,7 +139,7 @@ func (authenticator *QueryTokenAuth) Authenticate(request *http.Request) AuthRes if token == "" { return AuthResult{Valid: false} } - return authenticator.Validate(token) + return normalizeAuthResult(authenticator.Validate(token)) } // auth := stream.ConnAuthenticatorFunc(func(handshake []byte) stream.AuthResult { @@ -163,7 +165,7 @@ func (connAuthenticatorFunc ConnAuthenticatorFunc) AuthenticateConn(handshake [] if connAuthenticatorFunc == nil { return AuthResult{Valid: false} } - return connAuthenticatorFunc(handshake) + return normalizeAuthResult(connAuthenticatorFunc(handshake)) } func bearerTokenFromRequest(request *http.Request) (string, AuthResult) { @@ -180,3 +182,13 @@ func bearerTokenFromRequest(request *http.Request) (string, AuthResult) { } return token, AuthResult{Valid: true} } + +func normalizeAuthResult(result AuthResult) AuthResult { + if !result.Valid { + return result + } + if result.Claims == nil { + result.Claims = map[string]any{} + } + return result +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..809e586 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAuth_APIKeyAuthenticator_ClaimsInitialized_Good(t *testing.T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer sk-live") + + result := authenticator.Authenticate(request) + if !result.Valid { + t.Fatal("Authenticate() result.Valid = false, want true") + } + if result.Claims == nil { + t.Fatal("Authenticate() result.Claims = nil, want empty map") + } + if len(result.Claims) != 0 { + t.Fatalf("len(Authenticate().Claims) = %d, want 0", len(result.Claims)) + } +} + +func TestAuth_AuthenticatorFunc_ClaimsInitialized_Good(t *testing.T) { + authenticator := AuthenticatorFunc(func(request *http.Request) AuthResult { + return AuthResult{Valid: true, UserID: "user-42"} + }) + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + + result := authenticator.Authenticate(request) + if !result.Valid { + t.Fatal("Authenticate() result.Valid = false, want true") + } + if result.Claims == nil { + t.Fatal("Authenticate() result.Claims = nil, want empty map") + } + if len(result.Claims) != 0 { + t.Fatalf("len(Authenticate().Claims) = %d, want 0", len(result.Claims)) + } +} + +func TestAuth_ConnAuthenticatorFunc_ClaimsInitialized_Good(t *testing.T) { + authenticator := ConnAuthenticatorFunc(func(handshake []byte) AuthResult { + return AuthResult{Valid: true, UserID: "peer-1"} + }) + + result := authenticator.AuthenticateConn([]byte("hello")) + if !result.Valid { + t.Fatal("AuthenticateConn() result.Valid = false, want true") + } + if result.Claims == nil { + t.Fatal("AuthenticateConn() result.Claims = nil, want empty map") + } + if len(result.Claims) != 0 { + t.Fatalf("len(AuthenticateConn().Claims) = %d, want 0", len(result.Claims)) + } +} From 5306423b1123aa4f8e7b9c7c99bb493810932beb Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:28:54 +0000 Subject: [PATCH 130/140] fix(adapter): register peers after transport setup Co-Authored-By: Virgil --- adapter/sse/sse.go | 11 +++++------ adapter/ws/ws.go | 14 +++++++------- adapter/ws/ws_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/adapter/sse/sse.go b/adapter/sse/sse.go index a001cec..2dc3eb3 100644 --- a/adapter/sse/sse.go +++ b/adapter/sse/sse.go @@ -143,12 +143,6 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ return } - if err := adapter.hub.AddPeer(peer); err != nil { - http.Error(w, "stream hub not running", http.StatusInternalServerError) - return - } - defer adapter.hub.RemovePeer(peer) - for _, channel := range channels { if channel == "" { continue @@ -164,6 +158,11 @@ func (adapter *Adapter) serve(w http.ResponseWriter, r *http.Request, channels [ _, _ = io.WriteString(w, "retry: "+strconv.Itoa(config.RetryMs)+"\n\n") flusher.Flush() + if err := adapter.hub.AddPeer(peer); err != nil { + return + } + defer adapter.hub.RemovePeer(peer) + ticker := time.NewTicker(config.HeartbeatInterval) defer ticker.Stop() diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index cdefe6b..c0087d6 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -116,11 +116,6 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe return } - if err := adapter.hub.AddPeer(peer); err != nil { - http.Error(w, "stream hub not running", http.StatusInternalServerError) - return - } - upgrader := websocket.Upgrader{ ReadBufferSize: adapter.config.ReadBufferSize, WriteBufferSize: adapter.config.WriteBufferSize, @@ -134,15 +129,20 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - adapter.hub.RemovePeer(peer) http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := adapter.hub.AddPeer(peer); err != nil { + _ = conn.Close() + http.Error(w, "stream hub not running", http.StatusInternalServerError) + return + } + defer adapter.hub.RemovePeer(peer) + peer.SetCloseHook(func() { _ = conn.Close() }) - defer adapter.hub.RemovePeer(peer) for _, channel := range channels { if channel == "" { continue diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 59aeb26..1bae3d5 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "sync/atomic" "testing" "time" @@ -122,6 +123,40 @@ func TestAdapter_Handler_Bad(t *testing.T) { } } +func TestAdapter_Handler_UpgradeFailure_DoesNotRegisterPeer_Good(t *testing.T) { + var connectCount atomic.Int32 + hub := stream.NewHubWithConfig(stream.HubConfig{ + OnConnect: func(peer *stream.Peer) { + connectCount.Add(1) + }, + }) + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + + adapter := New(Config{ + CheckOrigin: func(r *http.Request) bool { + return false + }, + }) + adapter.Mount(hub) + + server := httptest.NewServer(http.HandlerFunc(adapter.Handler())) + defer server.Close() + + _, resp, err := websocket.DefaultDialer.Dial(websocketURL(server.URL), nil) + if err == nil { + t.Fatal("Dial() error = nil, want upgrade failure") + } + if resp == nil { + t.Fatal("Dial() response = nil, want handshake failure response") + } + if connectCount.Load() != 0 { + t.Fatalf("OnConnect invoked %d times, want %d", connectCount.Load(), 0) + } + waitForPeerCount(t, hub, 0) +} + func TestAdapter_Handler_HubNotRunning_Bad(t *testing.T) { adapter := New(Config{}) adapter.Mount(stream.NewHub()) From f29a9cb5613b2a9cbbf44af327194cd405c992cb Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:31:57 +0000 Subject: [PATCH 131/140] style(stream): align peer internals with AX naming Co-Authored-By: Virgil --- hub.go | 16 ++++++++-------- stream.go | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/hub.go b/hub.go index 288ef75..7ef3db9 100644 --- a/hub.go +++ b/hub.go @@ -1,9 +1,9 @@ // SPDX-License-Identifier: EUPL-1.2 -// hub := stream.NewHub() -// go hub.Run(ctx) -// stop := hub.Pipe(remoteHub) -// defer stop() +// hub := stream.NewHub() +// go hub.Run(ctx) +// stop := hub.Pipe(remoteHub) +// defer stop() package stream import ( @@ -260,8 +260,8 @@ func (hub *Hub) SubscribePeer(peer *Peer, channel string) error { return ErrAuthRejected } peer.mutex.Lock() - if peer.sendQueue == nil { - peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) + if peer.send == nil { + peer.send = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} @@ -557,8 +557,8 @@ func (hub *Hub) AddPeer(peer *Peer) error { return core.E("stream.hub", "nil peer", nil) } peer.mutex.Lock() - if peer.sendQueue == nil { - peer.sendQueue = make(chan []byte, defaultPeerSendBufferSize) + if peer.send == nil { + peer.send = make(chan []byte, defaultPeerSendBufferSize) } if peer.subscriptions == nil { peer.subscriptions = map[string]bool{} diff --git a/stream.go b/stream.go index 810f83a..0a4dcad 100644 --- a/stream.go +++ b/stream.go @@ -70,7 +70,7 @@ type Peer struct { // Values: "ws", "sse", "tcp", "zmq", "redis" Transport string - sendQueue chan []byte + send chan []byte subscriptions map[string]bool closeHook func() mutex sync.RWMutex @@ -84,7 +84,7 @@ func NewPeer(transport string) *Peer { ID: randomUUID(), Claims: map[string]any{}, Transport: transport, - sendQueue: make(chan []byte, defaultPeerSendBufferSize), + send: make(chan []byte, defaultPeerSendBufferSize), subscriptions: map[string]bool{}, } } @@ -114,12 +114,12 @@ func (peer *Peer) Send(frame []byte) bool { }() peer.mutex.RLock() defer peer.mutex.RUnlock() - if peer.sendQueue == nil { + if peer.send == nil { return false } payload := append([]byte(nil), frame...) select { - case peer.sendQueue <- payload: + case peer.send <- payload: return true default: return false @@ -135,12 +135,12 @@ func (peer *Peer) Close() { } peer.closeOnce.Do(func() { peer.mutex.Lock() - sendQueue := peer.sendQueue + send := peer.send closeHook := peer.closeHook peer.closeHook = nil peer.mutex.Unlock() - if sendQueue != nil { - close(sendQueue) + if send != nil { + close(send) } if closeHook != nil { closeHook() @@ -171,7 +171,7 @@ func (peer *Peer) SendQueue() <-chan []byte { } peer.mutex.RLock() defer peer.mutex.RUnlock() - return peer.sendQueue + return peer.send } // switch client.State() { From e63b04ed93a20659f8350f376f133020f635dadd Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:36:38 +0000 Subject: [PATCH 132/140] refactor(tcp): clarify framing helper names Co-Authored-By: Virgil --- adapter/tcp/reconnect.go | 6 ++--- adapter/tcp/tcp.go | 22 ++++++++-------- adapter/tcp/tcp_test.go | 56 ++++++++++++++++++++-------------------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index c7efbc8..176d95a 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -173,7 +173,7 @@ func (client *ReconnectingTCP) Send(channel string, frame []byte) error { if connection == nil { return core.E("stream.tcp", "not connected", nil) } - return writeFull(connection, encodeFrame(channel, frame)) + return writeAll(connection, encodeTCPFrame(channel, frame)) } // if client.State() == stream.StateConnected { @@ -229,7 +229,7 @@ func (client *ReconnectingTCP) readLoop(ctx context.Context, conn net.Conn) erro return ctx.Err() default: } - channel, frame, err := readFrame(conn, 0, MaxFrameSize) + channel, frame, err := readTCPFrame(conn, 0, MaxFrameSize) if err != nil { return err } @@ -299,5 +299,5 @@ func (client *ReconnectingTCP) writeHandshake(conn net.Conn) error { if len(client.config.HandshakeFrame) == 0 && client.config.HandshakeChannel == "" { return nil } - return writeFull(conn, encodeFrame(client.config.HandshakeChannel, client.config.HandshakeFrame)) + return writeAll(conn, encodeTCPFrame(client.config.HandshakeChannel, client.config.HandshakeFrame)) } diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index 4aba4dc..d6685d3 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -213,7 +213,7 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre if adapter.config.ConnAuthenticator != nil { handshakeMaxSize = maxHandshakeFrameSize } - channel, frame, err := readFrame(conn, adapter.config.HandshakeTimeout, handshakeMaxSize) + channel, frame, err := readTCPFrame(conn, adapter.config.HandshakeTimeout, handshakeMaxSize) if err != nil { return } @@ -249,11 +249,11 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) if auth := adapter.config.ConnAuthenticator; auth == nil { - dispatchFrame(hub, peer, channel, frame) + dispatchTCPFrame(hub, peer, channel, frame) } for { - channel, frame, err := readFrame(conn, 0, MaxFrameSize) + channel, frame, err := readTCPFrame(conn, 0, MaxFrameSize) if err != nil { return } @@ -265,7 +265,7 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre } } -func dispatchFrame(hub *stream.Hub, peer *stream.Peer, channel string, frame []byte) { +func dispatchTCPFrame(hub *stream.Hub, peer *stream.Peer, channel string, frame []byte) { if channel == "" { _ = hub.BroadcastFromPeer(peer, frame) return @@ -281,12 +281,12 @@ func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *strea defer stopClose() go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) for { - channel, frame, err := readFrame(conn, 0, MaxFrameSize) + channel, frame, err := readTCPFrame(conn, 0, MaxFrameSize) if err != nil { hub.RemovePeer(peer) return } - dispatchFrame(hub, peer, channel, frame) + dispatchTCPFrame(hub, peer, channel, frame) } } @@ -302,14 +302,14 @@ func (adapter *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stre if writeTimeout > 0 { _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) } - if err := writeFull(conn, frame); err != nil { + if err := writeAll(conn, frame); err != nil { return } } } } -func readFrame(conn net.Conn, timeout time.Duration, maxFrameSize int) (string, []byte, error) { +func readTCPFrame(conn net.Conn, timeout time.Duration, maxFrameSize int) (string, []byte, error) { if timeout > 0 { _ = conn.SetReadDeadline(time.Now().Add(timeout)) } else { @@ -341,7 +341,7 @@ func readFrame(conn net.Conn, timeout time.Duration, maxFrameSize int) (string, return channel, frame, nil } -func encodeFrame(channel string, frame []byte) []byte { +func encodeTCPFrame(channel string, frame []byte) []byte { channelBytes := []byte(channel) payloadLength := uint32(4 + len(channelBytes) + len(frame)) buffer := make([]byte, 4+payloadLength) @@ -352,7 +352,7 @@ func encodeFrame(channel string, frame []byte) []byte { return buffer } -func writeFull(conn net.Conn, payload []byte) error { +func writeAll(conn net.Conn, payload []byte) error { for len(payload) > 0 { written, err := conn.Write(payload) if err != nil { @@ -373,7 +373,7 @@ func (adapter *Adapter) writeHandshake(conn net.Conn) error { if len(adapter.config.HandshakeFrame) == 0 && adapter.config.HandshakeChannel == "" { return nil } - return writeFull(conn, encodeFrame(adapter.config.HandshakeChannel, adapter.config.HandshakeFrame)) + return writeAll(conn, encodeTCPFrame(adapter.config.HandshakeChannel, adapter.config.HandshakeFrame)) } func isClosedNetworkError(err error) bool { diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index bc35362..35520b2 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -59,7 +59,7 @@ func TestTCP_Listen_Good(t *testing.T) { } defer connection.Close() - if _, err := connection.Write(encodeFrame("", []byte("hello"))); err != nil { + if _, err := connection.Write(encodeTCPFrame("", []byte("hello"))); err != nil { t.Fatalf("Write() error = %v", err) } @@ -96,7 +96,7 @@ func TestTCP_Listen_NoAuthenticator_Good(t *testing.T) { }) defer unsubscribe() - if _, err := connection.Write(encodeFrame("block", []byte("template"))); err != nil { + if _, err := connection.Write(encodeTCPFrame("block", []byte("template"))); err != nil { t.Fatalf("Write() error = %v", err) } @@ -140,7 +140,7 @@ func TestTCP_Listen_SelfDelivery_Good(t *testing.T) { }) defer unsubscribe() - if _, err := connection.Write(encodeFrame("block", []byte("template"))); err != nil { + if _, err := connection.Write(encodeTCPFrame("block", []byte("template"))); err != nil { t.Fatalf("Write() error = %v", err) } @@ -153,15 +153,15 @@ func TestTCP_Listen_SelfDelivery_Good(t *testing.T) { t.Fatal("timed out waiting for published TCP frame") } - channel, frame, err := readFrame(connection, 2*time.Second, MaxFrameSize) + channel, frame, err := readTCPFrame(connection, 2*time.Second, MaxFrameSize) if err != nil { - t.Fatalf("readFrame() error = %v", err) + t.Fatalf("readTCPFrame() error = %v", err) } if channel != "block" { - t.Fatalf("readFrame() channel = %q, want %q", channel, "block") + t.Fatalf("readTCPFrame() channel = %q, want %q", channel, "block") } if string(frame) != "template" { - t.Fatalf("readFrame() frame = %q, want %q", string(frame), "template") + t.Fatalf("readTCPFrame() frame = %q, want %q", string(frame), "template") } } @@ -188,31 +188,31 @@ func TestTCP_Listen_ContextCancel_ClosesPeer_Good(t *testing.T) { } defer connection.Close() - if _, err := connection.Write(encodeFrame("", []byte("hello"))); err != nil { + if _, err := connection.Write(encodeTCPFrame("", []byte("hello"))); err != nil { t.Fatalf("Write() error = %v", err) } waitForPeerCount(t, hub, 1) - channel, frame, err := readFrame(connection, 2*time.Second, MaxFrameSize) + channel, frame, err := readTCPFrame(connection, 2*time.Second, MaxFrameSize) if err != nil { - t.Fatalf("readFrame() initial echo error = %v", err) + t.Fatalf("readTCPFrame() initial echo error = %v", err) } if channel != "" { - t.Fatalf("readFrame() initial echo channel = %q, want %q", channel, "") + t.Fatalf("readTCPFrame() initial echo channel = %q, want %q", channel, "") } if string(frame) != "hello" { - t.Fatalf("readFrame() initial echo frame = %q, want %q", string(frame), "hello") + t.Fatalf("readTCPFrame() initial echo frame = %q, want %q", string(frame), "hello") } listenCancel() - channel, frame, err = readFrame(connection, 2*time.Second, MaxFrameSize) + channel, frame, err = readTCPFrame(connection, 2*time.Second, MaxFrameSize) if err == nil { - t.Fatalf("readFrame() = (%q, %q, nil), want connection close", channel, string(frame)) + t.Fatalf("readTCPFrame() = (%q, %q, nil), want connection close", channel, string(frame)) } if err == stream.ErrHandshakeTimeout { - t.Fatalf("readFrame() error = %v, want connection close", err) + t.Fatalf("readTCPFrame() error = %v, want connection close", err) } waitForPeerCount(t, hub, 0) @@ -245,7 +245,7 @@ func TestTCP_Listen_Bad(t *testing.T) { } defer connection.Close() - if _, err := connection.Write(encodeFrame("", []byte("nope"))); err != nil { + if _, err := connection.Write(encodeTCPFrame("", []byte("nope"))); err != nil { t.Fatalf("Write() error = %v", err) } @@ -315,7 +315,7 @@ func TestTCP_Listen_NoAuthenticator_LargeInitialFrame_Good(t *testing.T) { defer unsubscribe() largeFrame := bytes.Repeat([]byte("a"), maxHandshakeFrameSize+1) - if _, err := connection.Write(encodeFrame("block", largeFrame)); err != nil { + if _, err := connection.Write(encodeTCPFrame("block", largeFrame)); err != nil { t.Fatalf("Write() error = %v", err) } @@ -386,12 +386,12 @@ func TestReconnectingTCP_Send_Concurrent_Good(t *testing.T) { receivedValues := map[byte]bool{} for len(receivedValues) < senderCount { - channel, frame, readErr := readFrame(serverConnection, 2*time.Second, MaxFrameSize) + channel, frame, readErr := readTCPFrame(serverConnection, 2*time.Second, MaxFrameSize) if readErr != nil { - t.Fatalf("readFrame() error = %v", readErr) + t.Fatalf("readTCPFrame() error = %v", readErr) } if channel != "hashrate" { - t.Fatalf("readFrame() channel = %q, want %q", channel, "hashrate") + t.Fatalf("readTCPFrame() channel = %q, want %q", channel, "hashrate") } if len(frame) != 1 { t.Fatalf("len(frame) = %d, want %d", len(frame), 1) @@ -428,7 +428,7 @@ func TestTCP_Listen_AuthHandshakeTooLarge_Good(t *testing.T) { defer connection.Close() tooLargeHandshake := make([]byte, maxHandshakeFrameSize+1) - if _, err := connection.Write(encodeFrame("", tooLargeHandshake)); err != nil { + if _, err := connection.Write(encodeTCPFrame("", tooLargeHandshake)); err != nil { t.Fatalf("Write() error = %v", err) } @@ -455,7 +455,7 @@ func TestTCP_Dial_NilContext_Good(t *testing.T) { return } defer connection.Close() - _, _ = connection.Write(encodeFrame("block", []byte("template"))) + _, _ = connection.Write(encodeTCPFrame("block", []byte("template"))) time.Sleep(50 * time.Millisecond) }() @@ -502,7 +502,7 @@ func TestTCP_Dial_HubNotRunning_Bad(t *testing.T) { return } defer connection.Close() - _, _, _ = readFrame(connection, 2*time.Second, MaxFrameSize) + _, _, _ = readTCPFrame(connection, 2*time.Second, MaxFrameSize) }() adapter := New(Config{Addr: listener.Addr().String()}) @@ -687,7 +687,7 @@ func TestReconnectingTCP_Connect_Handshake_Good(t *testing.T) { } defer connection.Close() - channel, frame, readErr := readFrame(connection, time.Second, MaxFrameSize) + channel, frame, readErr := readTCPFrame(connection, time.Second, MaxFrameSize) if readErr != nil { return } @@ -695,7 +695,7 @@ func TestReconnectingTCP_Connect_Handshake_Good(t *testing.T) { return } received <- append([]byte(nil), frame...) - _ = writeFull(connection, encodeFrame("block", []byte("template"))) + _ = writeAll(connection, encodeTCPFrame("block", []byte("template"))) }() clientMessages := make(chan []byte, 1) @@ -777,7 +777,7 @@ func waitForPeerCount(t *testing.T, hub *stream.Hub, expected int) { t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), expected) } -func TestWriteFull_Good(t *testing.T) { +func TestWriteAll_Good(t *testing.T) { left, right := net.Pipe() defer left.Close() defer right.Close() @@ -795,8 +795,8 @@ func TestWriteFull_Good(t *testing.T) { received <- buffer }() - if err := writeFull(wrapped, payload); err != nil { - t.Fatalf("writeFull() error = %v", err) + if err := writeAll(wrapped, payload); err != nil { + t.Fatalf("writeAll() error = %v", err) } select { From 8a4cd2f99f96242b823d74337eed5a1dac8269d9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:39:18 +0000 Subject: [PATCH 133/140] fix(redis): use bridge source ids directly Co-Authored-By: Virgil --- adapter/redis/redis.go | 22 +++++++++++++++++++--- stream.go | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index aa78acf..0931dec 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -1,15 +1,19 @@ // SPDX-License-Identifier: EUPL-1.2 -// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) +// bridge, err := redis.NewBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) +// // if err != nil { // return err // } -// go bridge.Start(ctx) +// +// go bridge.Start(ctx) package redis import ( "context" + "crypto/rand" "crypto/tls" + "encoding/hex" "sync" "time" @@ -78,7 +82,7 @@ func NewBridge(hub *stream.Hub, config Config) (*Bridge, error) { return &Bridge{ hub: hub, config: config, - sourceID: stream.NewPeer("redis").ID, + sourceID: randomSourceID(), }, nil } @@ -299,3 +303,15 @@ func newRedisClient(config Config) *redis.Client { TLSConfig: config.TLSConfig, }) } + +func randomSourceID() string { + var raw [16]byte + _, _ = rand.Read(raw[:]) + raw[6] = (raw[6] & 0x0f) | 0x40 + raw[8] = (raw[8] & 0x3f) | 0x80 + return hex.EncodeToString(raw[:4]) + "-" + + hex.EncodeToString(raw[4:6]) + "-" + + hex.EncodeToString(raw[6:8]) + "-" + + hex.EncodeToString(raw[8:10]) + "-" + + hex.EncodeToString(raw[10:]) +} diff --git a/stream.go b/stream.go index 0a4dcad..ec4bfc3 100644 --- a/stream.go +++ b/stream.go @@ -67,7 +67,7 @@ type Peer struct { Claims map[string]any // Transport identifies the adapter type for logging and metrics. - // Values: "ws", "sse", "tcp", "zmq", "redis" + // Values: "ws", "sse", "tcp", "zmq" Transport string send chan []byte From 135249d5592ecc5466b0b6bb798dddd8ef29003e Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:42:19 +0000 Subject: [PATCH 134/140] style(ax): align examples with core print helper Co-Authored-By: Virgil --- example_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/example_test.go b/example_test.go index af32e6b..39e51a5 100644 --- a/example_test.go +++ b/example_test.go @@ -4,10 +4,10 @@ package stream_test import ( "context" - "fmt" "net/http/httptest" "time" + "dappco.re/go/core" "dappco.re/go/stream" ) @@ -29,9 +29,9 @@ func ExampleNewHub() { select { case frame := <-received: - fmt.Println(frame) + core.Print(nil, "%s", frame) case <-time.After(time.Second): - fmt.Println("timeout") + core.Print(nil, "%s", "timeout") } // Output: @@ -62,9 +62,9 @@ func ExamplePipe() { select { case frame := <-received: - fmt.Println(frame) + core.Print(nil, "%s", frame) case <-time.After(time.Second): - fmt.Println("timeout") + core.Print(nil, "%s", "timeout") } // Output: @@ -85,7 +85,7 @@ func ExampleHub_Stats() { _ = hub.SubscribePeer(peer, "hashrate") stats := hub.Stats() - fmt.Println(stats.Peers, stats.Channels, stats.SubscriberCount["hashrate"]) + core.Print(nil, "%d %d %d", stats.Peers, stats.Channels, stats.SubscriberCount["hashrate"]) // Output: // 1 1 1 @@ -110,7 +110,7 @@ func ExampleNewAPIKeyAuth() { request.Header.Set("Authorization", "Bearer sk-live") result := authenticator.Authenticate(request) - fmt.Println(result.Valid, result.UserID) + core.Print(nil, "%t %s", result.Valid, result.UserID) // Output: // true user-42 @@ -124,14 +124,14 @@ func ExampleMessage() { Data: map[string]any{"h": 1234567}, } - fmt.Println(msg.Type, msg.Channel, msg.ProcessID, msg.Data) + core.Print(nil, "%s %s %s %v", msg.Type, msg.Channel, msg.ProcessID, msg.Data) // Output: // event hashrate agent-42 map[h:1234567] } func ExampleMessageType() { - fmt.Println(stream.TypeSubscribe) + core.Print(nil, "%s", stream.TypeSubscribe) // Output: // subscribe From 2c15a1f132664c18de026dd446dad1ca51a8cd64 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:45:46 +0000 Subject: [PATCH 135/140] style(ax): sharpen auth result example comments Co-Authored-By: Virgil --- auth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth.go b/auth.go index 11f96d1..53de8a2 100644 --- a/auth.go +++ b/auth.go @@ -32,8 +32,8 @@ type AuthResult struct { UserID string - // Claims is always initialised on success so callers can add metadata without - // checking for nil first. + // claims := result.Claims + // claims["role"] = "admin" Claims map[string]any Error error From e2f66c1424577d1736b9c6b3e6e03f747a025514 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 03:48:40 +0000 Subject: [PATCH 136/140] style(ax): sharpen pipe examples Co-Authored-By: Virgil --- hub.go | 4 ++-- stream.go | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/hub.go b/hub.go index 7ef3db9..68d773d 100644 --- a/hub.go +++ b/hub.go @@ -364,8 +364,8 @@ func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadca } // stop := hub.Pipe(remoteHub) -func (hub *Hub) Pipe(destination Stream) func() { - return Pipe(hub, destination) +func (hub *Hub) Pipe(dst Stream) func() { + return Pipe(hub, dst) } // stats := hub.Stats() diff --git a/stream.go b/stream.go index ec4bfc3..76a3e51 100644 --- a/stream.go +++ b/stream.go @@ -4,7 +4,7 @@ // // hub := stream.NewHub() // go hub.Run(ctx) -// stop := hub.Pipe(remoteHub) +// stop := stream.Pipe(hub, remoteHub) // defer stop() package stream @@ -219,15 +219,15 @@ type Envelope struct { Frame []byte } -// Pipe connects source to destination. +// Pipe connects src to dst. // // stop := stream.Pipe(zmqHub, wsHub) // defer stop() // // Published frames keep their channel. Broadcast frames stay broadcasts when the // source exposes that hook. -func Pipe(source Stream, destination Stream) func() { - if source == nil || destination == nil || source == destination { +func Pipe(src Stream, dst Stream) func() { + if src == nil || dst == nil || src == dst { return func() {} } type publishedFrameSource interface { @@ -237,21 +237,21 @@ func Pipe(source Stream, destination Stream) func() { SubscribeBroadcast(handler func([]byte)) func() } stops := make([]func(), 0, 2) - if publisher, ok := source.(publishedFrameSource); ok { + if publisher, ok := src.(publishedFrameSource); ok { stops = append(stops, onceFunction(publisher.SubscribePublished(func(channel string, frame []byte) { - _ = destination.Publish(channel, cloneFrame(frame)) + _ = dst.Publish(channel, cloneFrame(frame)) }))) } - if broadcaster, ok := source.(broadcastFrameSource); ok { + if broadcaster, ok := src.(broadcastFrameSource); ok { stops = append(stops, onceFunction(broadcaster.SubscribeBroadcast(func(frame []byte) { - _ = destination.Broadcast(cloneFrame(frame)) + _ = dst.Broadcast(cloneFrame(frame)) }))) } if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back // to publishing on the wildcard channel. - stop := source.Subscribe("*", func(frame []byte) { - _ = destination.Publish("*", cloneFrame(frame)) + stop := src.Subscribe("*", func(frame []byte) { + _ = dst.Publish("*", cloneFrame(frame)) }) return onceFunction(stop) } From fbf95ae1fd2b9237e42cc57a7507ddda5f42e2c3 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 5 Apr 2026 06:50:32 +0100 Subject: [PATCH 137/140] feat(ax): add comprehensive tests and usage-example comments Add stream_test.go and stats_test.go with full Good/Bad/Ugly coverage for ConnectionState, Envelope, Peer, Pipe, encodeTCPFrame, cloneFrame, onceFunction, randomUUID, HubStats, PeerCount, ChannelCount, ChannelSubscriberCount, AllPeers, AllChannels, and JSON serialisation. Expand message_test.go from 1 test to 9 (all MessageType constants, Message fields, and edge cases). Add usage-example comments to all unexported helpers in hub.go, stream.go, auth.go, and hub_config.go per AX principle 2 (comments as usage examples, not descriptions). Co-Authored-By: Virgil --- auth.go | 2 + auth_test.go | 178 +++++++++++++++++++++++++ hub.go | 23 ++++ hub_config.go | 1 + hub_test.go | 174 ++++++++++++++++++++++++ message_test.go | 86 +++++++++++- stats_test.go | 264 ++++++++++++++++++++++++++++++++++++ stream.go | 7 + stream_test.go | 348 ++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1078 insertions(+), 5 deletions(-) create mode 100644 stats_test.go create mode 100644 stream_test.go diff --git a/auth.go b/auth.go index 53de8a2..bb7c850 100644 --- a/auth.go +++ b/auth.go @@ -168,6 +168,7 @@ func (connAuthenticatorFunc ConnAuthenticatorFunc) AuthenticateConn(handshake [] return normalizeAuthResult(connAuthenticatorFunc(handshake)) } +// token, result := bearerTokenFromRequest(request) func bearerTokenFromRequest(request *http.Request) (string, AuthResult) { header := request.Header.Get("Authorization") if header == "" { @@ -183,6 +184,7 @@ func bearerTokenFromRequest(request *http.Request) (string, AuthResult) { return token, AuthResult{Valid: true} } +// result = normalizeAuthResult(result) func normalizeAuthResult(result AuthResult) AuthResult { if !result.Valid { return result diff --git a/auth_test.go b/auth_test.go index 809e586..d0d00e6 100644 --- a/auth_test.go +++ b/auth_test.go @@ -59,3 +59,181 @@ func TestAuth_ConnAuthenticatorFunc_ClaimsInitialized_Good(t *testing.T) { t.Fatalf("len(AuthenticateConn().Claims) = %d, want 0", len(result.Claims)) } } + +func TestAuth_APIKeyAuthenticator_Bad(t *testing.T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) + + // Missing Authorization header. + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + result := authenticator.Authenticate(request) + if result.Valid { + t.Fatal("Authenticate() without header: result.Valid = true, want false") + } + if result.Error != ErrMissingAuthHeader { + t.Fatalf("Authenticate() without header: error = %v, want %v", result.Error, ErrMissingAuthHeader) + } + + // Malformed Authorization header (not "Bearer "). + request = httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Basic sk-live") + result = authenticator.Authenticate(request) + if result.Valid { + t.Fatal("Authenticate() with Basic scheme: result.Valid = true, want false") + } + if result.Error != ErrMalformedAuthHeader { + t.Fatalf("Authenticate() with Basic scheme: error = %v, want %v", result.Error, ErrMalformedAuthHeader) + } + + // Unknown API key. + request = httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer sk-unknown") + result = authenticator.Authenticate(request) + if result.Valid { + t.Fatal("Authenticate() with unknown key: result.Valid = true, want false") + } + if result.Error != ErrInvalidAPIKey { + t.Fatalf("Authenticate() with unknown key: error = %v, want %v", result.Error, ErrInvalidAPIKey) + } +} + +func TestAuth_APIKeyAuthenticator_Ugly(t *testing.T) { + // Nil authenticator returns invalid result without panic. + var authenticator *APIKeyAuthenticator + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer sk-live") + + result := authenticator.Authenticate(request) + if result.Valid { + t.Fatal("nil authenticator: result.Valid = true, want false") + } + + // Nil request returns invalid result without panic. + validAuth := NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) + result = validAuth.Authenticate(nil) + if result.Valid { + t.Fatal("nil request: result.Valid = true, want false") + } +} + +func TestAuth_BearerTokenAuth_Good(t *testing.T) { + authenticator := &BearerTokenAuth{ + Validate: func(token string) AuthResult { + if token == "jwt-valid" { + return AuthResult{Valid: true, UserID: "user-99", Claims: map[string]any{"role": "admin"}} + } + return AuthResult{Valid: false} + }, + } + + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer jwt-valid") + result := authenticator.Authenticate(request) + if !result.Valid { + t.Fatal("Authenticate() result.Valid = false, want true") + } + if result.UserID != "user-99" { + t.Fatalf("result.UserID = %q, want %q", result.UserID, "user-99") + } + if result.Claims["role"] != "admin" { + t.Fatalf("result.Claims[role] = %v, want %q", result.Claims["role"], "admin") + } +} + +func TestAuth_BearerTokenAuth_Bad(t *testing.T) { + authenticator := &BearerTokenAuth{ + Validate: func(token string) AuthResult { + return AuthResult{Valid: false} + }, + } + + // Valid header but rejected by validator. + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer bad-token") + result := authenticator.Authenticate(request) + if result.Valid { + t.Fatal("Authenticate() with rejected token: result.Valid = true, want false") + } +} + +func TestAuth_BearerTokenAuth_Ugly(t *testing.T) { + // Nil Validate function returns invalid without panic. + authenticator := &BearerTokenAuth{} + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer test") + result := authenticator.Authenticate(request) + if result.Valid { + t.Fatal("nil Validate: result.Valid = true, want false") + } +} + +func TestAuth_QueryTokenAuth_Good(t *testing.T) { + authenticator := &QueryTokenAuth{ + Validate: func(token string) AuthResult { + if token == "ws-token-1" { + return AuthResult{Valid: true, UserID: "browser-user"} + } + return AuthResult{Valid: false} + }, + } + + request := httptest.NewRequest(http.MethodGet, "/stream/ws?token=ws-token-1", nil) + result := authenticator.Authenticate(request) + if !result.Valid { + t.Fatal("Authenticate() result.Valid = false, want true") + } + if result.UserID != "browser-user" { + t.Fatalf("result.UserID = %q, want %q", result.UserID, "browser-user") + } +} + +func TestAuth_QueryTokenAuth_Bad(t *testing.T) { + authenticator := &QueryTokenAuth{ + Validate: func(token string) AuthResult { + return AuthResult{Valid: false} + }, + } + + // Missing token query parameter. + request := httptest.NewRequest(http.MethodGet, "/stream/ws", nil) + result := authenticator.Authenticate(request) + if result.Valid { + t.Fatal("Authenticate() without token param: result.Valid = true, want false") + } +} + +func TestAuth_QueryTokenAuth_Ugly(t *testing.T) { + // Nil Validate function returns invalid without panic. + authenticator := &QueryTokenAuth{} + request := httptest.NewRequest(http.MethodGet, "/stream/ws?token=test", nil) + result := authenticator.Authenticate(request) + if result.Valid { + t.Fatal("nil Validate: result.Valid = true, want false") + } + + // Nil authenticator returns invalid without panic. + var nilAuth AuthenticatorFunc + result = nilAuth.Authenticate(httptest.NewRequest(http.MethodGet, "/stream/ws", nil)) + if result.Valid { + t.Fatal("nil AuthenticatorFunc: result.Valid = true, want false") + } +} + +func TestAuth_ConnAuthenticatorFunc_Bad(t *testing.T) { + authenticator := ConnAuthenticatorFunc(func(handshake []byte) AuthResult { + return AuthResult{Valid: false} + }) + + result := authenticator.AuthenticateConn([]byte("invalid-handshake")) + if result.Valid { + t.Fatal("AuthenticateConn() with invalid handshake: result.Valid = true, want false") + } +} + +func TestAuth_ConnAuthenticatorFunc_Ugly(t *testing.T) { + // Nil ConnAuthenticatorFunc returns invalid without panic. + var authenticator ConnAuthenticatorFunc + result := authenticator.AuthenticateConn([]byte("hello")) + if result.Valid { + t.Fatal("nil ConnAuthenticatorFunc: result.Valid = true, want false") + } +} diff --git a/hub.go b/hub.go index 68d773d..a352a95 100644 --- a/hub.go +++ b/hub.go @@ -155,10 +155,12 @@ func (hub *Hub) PublishFromBridge(channel string, frame []byte) error { return hub.sendToChannel(channel, frame, false) } +// _ = hub.sendToChannel("hashrate", []byte("123456"), true) func (hub *Hub) sendToChannel(channel string, frame []byte, notifyPublishSubscribers bool) error { return hub.sendToChannelFromPeer(nil, channel, frame, notifyPublishSubscribers) } +// _ = hub.sendToChannelFromPeer(peer, "hashrate", []byte("123456"), true) func (hub *Hub) sendToChannelFromPeer(source *Peer, channel string, frame []byte, notifyPublishSubscribers bool) error { if hub == nil { return core.E("stream.hub", "nil hub", nil) @@ -332,10 +334,12 @@ func (hub *Hub) BroadcastFromBridge(frame []byte) error { return hub.broadcastFrame(frame, false) } +// _ = hub.broadcastFrame(frame, true) func (hub *Hub) broadcastFrame(frame []byte, notifyBroadcastSubscribers bool) error { return hub.broadcastFrameFromPeer(nil, frame, notifyBroadcastSubscribers) } +// _ = hub.broadcastFrameFromPeer(peer, frame, true) func (hub *Hub) broadcastFrameFromPeer(source *Peer, frame []byte, notifyBroadcastSubscribers bool) error { if hub == nil { return core.E("stream.hub", "nil hub", nil) @@ -596,6 +600,7 @@ func (hub *Hub) RemovePeer(peer *Peer) { hub.removePeer(peer) } +// hub.sendToPeer(peer, "hashrate", []byte("123456")) func (hub *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { if peer == nil { return @@ -607,6 +612,7 @@ func (hub *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { _ = peer.Send(frame) } +// hub.sendBroadcastToPeer(peer, []byte("shutdown")) func (hub *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { if peer == nil { return @@ -618,6 +624,7 @@ func (hub *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { _ = peer.Send(frame) } +// hub.invokeHandlers(handlers, frame) func (hub *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { func(handlerFunction func([]byte)) { @@ -629,6 +636,7 @@ func (hub *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { } } +// hub.addPeer(peer) func (hub *Hub) addPeer(peer *Peer) { if hub == nil || peer == nil { return @@ -649,6 +657,7 @@ func (hub *Hub) addPeer(peer *Peer) { } } +// hub.removePeer(peer) func (hub *Hub) removePeer(peer *Peer) { if hub == nil || peer == nil { return @@ -676,6 +685,7 @@ func (hub *Hub) removePeer(peer *Peer) { } } +// hub.broadcastToPeers(nil, frame, true) func (hub *Hub) broadcastToPeers(_ *Peer, frame []byte, notifyBroadcastSubscribers bool) { if hub == nil { return @@ -697,18 +707,21 @@ func (hub *Hub) broadcastToPeers(_ *Peer, frame []byte, notifyBroadcastSubscribe } } +// item := publishDelivery{channel: "block", frame: data, notifyPublishSubscribers: true} type publishDelivery struct { channel string frame []byte notifyPublishSubscribers bool } +// item := broadcastDelivery{frame: data, notifyBroadcastSubscribers: true} type broadcastDelivery struct { source *Peer frame []byte notifyBroadcastSubscribers bool } +// hub.enqueuePublishDelivery("hashrate", frame, true) func (hub *Hub) enqueuePublishDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { if hub == nil { return @@ -725,6 +738,7 @@ func (hub *Hub) enqueuePublishDelivery(channel string, frame []byte, notifyPubli } } +// hub.enqueueBroadcast(broadcastDelivery{frame: data}) func (hub *Hub) enqueueBroadcast(item broadcastDelivery) { if hub == nil { return @@ -735,6 +749,7 @@ func (hub *Hub) enqueueBroadcast(item broadcastDelivery) { } } +// go hub.enqueuePublishDeliveryAsync(publishDelivery{channel: "block", frame: data}) func (hub *Hub) enqueuePublishDeliveryAsync(item publishDelivery) { if hub == nil { return @@ -745,6 +760,7 @@ func (hub *Hub) enqueuePublishDeliveryAsync(item publishDelivery) { } } +// hub.processPublishDelivery("hashrate", frame, true) func (hub *Hub) processPublishDelivery(channel string, frame []byte, notifyPublishSubscribers bool) { if hub == nil { return @@ -764,6 +780,7 @@ func (hub *Hub) processPublishDelivery(channel string, frame []byte, notifyPubli } } +// stop := hub.subscribePublished(func(channel string, frame []byte) { ... }) func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { if hub == nil || handler == nil { return func() {} @@ -784,6 +801,7 @@ func (hub *Hub) subscribePublished(handler func(string, []byte)) func() { }) } +// hub.invokeBroadcastHandlers(handlers, frame) func (hub *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { func(handlerFunction func([]byte)) { @@ -795,6 +813,7 @@ func (hub *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { } } +// hub.invokePublishHandlers(handlers, "block", frame) func (hub *Hub) invokePublishHandlers(handlers []func(string, []byte), channel string, frame []byte) { for _, handler := range handlers { func(handlerFunction func(string, []byte)) { @@ -806,6 +825,7 @@ func (hub *Hub) invokePublishHandlers(handlers []func(string, []byte), channel s } } +// peers := hub.collectChannelPeersLocked("hashrate", nil) func (hub *Hub) collectChannelPeersLocked(channel string, _ *Peer) []*Peer { combined := map[*Peer]struct{}{} for peer := range hub.channels[channel] { @@ -832,6 +852,7 @@ func (hub *Hub) collectChannelPeersLocked(channel string, _ *Peer) []*Peer { return peers } +// cloned := cloneChannelHandlers(hub.channelHandlers["hashrate"]) func cloneChannelHandlers(handlers map[uint64]func([]byte)) []func([]byte) { if len(handlers) == 0 { return nil @@ -843,6 +864,7 @@ func cloneChannelHandlers(handlers map[uint64]func([]byte)) []func([]byte) { return cloned } +// cloned := clonePublishHandlers(hub.publishHandlers) func clonePublishHandlers(handlers map[uint64]func(string, []byte)) []func(string, []byte) { if len(handlers) == 0 { return nil @@ -854,6 +876,7 @@ func clonePublishHandlers(handlers map[uint64]func(string, []byte)) []func(strin return cloned } +// cloned := cloneBroadcastHandlers(hub.broadcastHandlers) func cloneBroadcastHandlers(handlers map[uint64]func([]byte)) []func([]byte) { if len(handlers) == 0 { return nil diff --git a/hub_config.go b/hub_config.go index 1f428df..d10ec65 100644 --- a/hub_config.go +++ b/hub_config.go @@ -48,6 +48,7 @@ func DefaultHubConfig() HubConfig { } } +// config = normalizeHubConfig(config) func normalizeHubConfig(config HubConfig) HubConfig { defaults := DefaultHubConfig() if config.HeartbeatInterval == 0 { diff --git a/hub_test.go b/hub_test.go index 6a57fcc..5687362 100644 --- a/hub_test.go +++ b/hub_test.go @@ -864,6 +864,180 @@ func TestPeer_Close_Good(t *testing.T) { } } +func TestHub_Run_Good(t *testing.T) { + hub := NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go hub.Run(ctx) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + waitForPeerCount(t, hub, 1) + + cancel() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if !hub.Running() { + break + } + time.Sleep(10 * time.Millisecond) + } + if hub.Running() { + t.Fatal("hub still running after context cancellation") + } + if hub.PeerCount() != 0 { + t.Fatalf("PeerCount() = %d after shutdown, want 0", hub.PeerCount()) + } +} + +func TestHub_Run_Bad(t *testing.T) { + hub := NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go hub.Run(ctx) + waitForRunningHub(t, hub) + + // Second Run call is a no-op — hub remains running with the original context. + secondDone := make(chan struct{}) + go func() { + hub.Run(ctx) + close(secondDone) + }() + + select { + case <-secondDone: + case <-time.After(2 * time.Second): + t.Fatal("second Run() did not return immediately") + } + + if !hub.Running() { + t.Fatal("hub stopped after second Run() call") + } +} + +func TestHub_Run_Ugly(t *testing.T) { + hub := NewHub() + ctx, cancel := context.WithCancel(context.Background()) + + go hub.Run(ctx) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + waitForPeerCount(t, hub, 1) + + // Cancel context while a broadcast is in flight. + go func() { + for i := 0; i < 100; i++ { + _ = hub.Broadcast([]byte("inflight")) + } + }() + cancel() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if !hub.Running() { + break + } + time.Sleep(10 * time.Millisecond) + } + if hub.Running() { + t.Fatal("hub still running after context cancellation during broadcast") + } + if hub.PeerCount() != 0 { + t.Fatalf("PeerCount() = %d after shutdown, want 0 (goroutine leak)", hub.PeerCount()) + } +} + +func TestHub_Subscribe_Good(t *testing.T) { + hub := NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go hub.Run(ctx) + waitForRunningHub(t, hub) + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("hashrate", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := hub.Publish("hashrate", []byte("123456")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "123456" { + t.Fatalf("received frame = %q, want %q", string(frame), "123456") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for subscribed frame") + } +} + +func TestHub_Subscribe_Bad(t *testing.T) { + hub := NewHub() + + unsubscribe := hub.Subscribe("", func(frame []byte) {}) + if unsubscribe == nil { + t.Fatal("Subscribe() with empty channel returned nil unsubscribe") + } + unsubscribe() +} + +func TestHub_Subscribe_Ugly(t *testing.T) { + hub := NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go hub.Run(ctx) + waitForRunningHub(t, hub) + + panicked := 0 + _ = hub.Subscribe("event", func(frame []byte) { + panicked++ + panic("handler panic") + }) + + received := make(chan []byte, 1) + unsubscribe := hub.Subscribe("event", func(frame []byte) { + received <- append([]byte(nil), frame...) + }) + defer unsubscribe() + + if err := hub.Publish("event", []byte("payload")); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + select { + case frame := <-received: + if string(frame) != "payload" { + t.Fatalf("received frame = %q, want %q", string(frame), "payload") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for safe handler after panic") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if panicked == 1 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("panic handler count = %d, want 1", panicked) +} + func waitForRunningHub(t *testing.T, hub *Hub) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/message_test.go b/message_test.go index 6085b55..a239e66 100644 --- a/message_test.go +++ b/message_test.go @@ -2,13 +2,89 @@ package stream -import "testing" +import ( + "testing" + "time" +) func TestMessageType_String_Good(t *testing.T) { - if TypeEvent.String() != "event" { - t.Fatalf("TypeEvent.String() = %q, want %q", TypeEvent.String(), "event") + cases := []struct { + messageType MessageType + expected string + }{ + {TypeProcessOutput, "process_output"}, + {TypeProcessStatus, "process_status"}, + {TypeEvent, "event"}, + {TypeError, "error"}, + {TypePing, "ping"}, + {TypePong, "pong"}, + {TypeSubscribe, "subscribe"}, + {TypeUnsubscribe, "unsubscribe"}, } - if TypeSubscribe.String() != "subscribe" { - t.Fatalf("TypeSubscribe.String() = %q, want %q", TypeSubscribe.String(), "subscribe") + for _, testCase := range cases { + if testCase.messageType.String() != testCase.expected { + t.Fatalf("%q.String() = %q, want %q", testCase.messageType, testCase.messageType.String(), testCase.expected) + } + } +} + +func TestMessageType_String_Bad(t *testing.T) { + // Unknown MessageType returns its raw string value. + unknown := MessageType("nonexistent") + if unknown.String() != "nonexistent" { + t.Fatalf("unknown MessageType.String() = %q, want %q", unknown.String(), "nonexistent") + } +} + +func TestMessageType_String_Ugly(t *testing.T) { + // Empty MessageType returns empty string. + empty := MessageType("") + if empty.String() != "" { + t.Fatalf("empty MessageType.String() = %q, want %q", empty.String(), "") + } +} + +func TestMessage_Fields_Good(t *testing.T) { + timestamp := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC) + message := Message{ + Type: TypeEvent, + Channel: "hashrate", + ProcessID: "agent-42", + Data: map[string]any{"h": 1234567}, + Timestamp: timestamp, + } + if message.Type != TypeEvent { + t.Fatalf("message.Type = %q, want %q", message.Type, TypeEvent) + } + if message.Channel != "hashrate" { + t.Fatalf("message.Channel = %q, want %q", message.Channel, "hashrate") + } + if message.ProcessID != "agent-42" { + t.Fatalf("message.ProcessID = %q, want %q", message.ProcessID, "agent-42") + } + if message.Timestamp != timestamp { + t.Fatalf("message.Timestamp = %v, want %v", message.Timestamp, timestamp) + } +} + +func TestMessage_Fields_Bad(t *testing.T) { + // Zero-value Message has empty fields — no panic. + message := Message{} + if message.Type != "" { + t.Fatalf("zero Message.Type = %q, want empty", message.Type) + } + if message.Channel != "" { + t.Fatalf("zero Message.Channel = %q, want empty", message.Channel) + } + if message.Timestamp.IsZero() != true { + t.Fatal("zero Message.Timestamp.IsZero() = false, want true") + } +} + +func TestMessage_Fields_Ugly(t *testing.T) { + // Message with nil Data does not panic on access. + message := Message{Type: TypeError, Data: nil} + if message.Data != nil { + t.Fatalf("Message.Data = %v, want nil", message.Data) } } diff --git a/stats_test.go b/stats_test.go new file mode 100644 index 0000000..1d849f1 --- /dev/null +++ b/stats_test.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream + +import ( + "context" + "testing" + + "dappco.re/go/core" +) + +func TestStats_HubStats_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + waitForPeerCount(t, hub, 1) + + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer() error = %v", err) + } + + stats := hub.Stats() + if stats.Peers != 1 { + t.Fatalf("Stats().Peers = %d, want %d", stats.Peers, 1) + } + if stats.Channels != 1 { + t.Fatalf("Stats().Channels = %d, want %d", stats.Channels, 1) + } + if stats.SubscriberCount["hashrate"] != 1 { + t.Fatalf("Stats().SubscriberCount[hashrate] = %d, want %d", stats.SubscriberCount["hashrate"], 1) + } +} + +func TestStats_HubStats_Bad(t *testing.T) { + // Stats on a nil hub returns zero values. + var hub *Hub + stats := hub.Stats() + if stats.Peers != 0 { + t.Fatalf("nil hub Stats().Peers = %d, want %d", stats.Peers, 0) + } + if stats.Channels != 0 { + t.Fatalf("nil hub Stats().Channels = %d, want %d", stats.Channels, 0) + } +} + +func TestStats_HubStats_Ugly(t *testing.T) { + // Stats called after all peers are removed returns zero peers. + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer() error = %v", err) + } + hub.RemovePeer(peer) + waitForPeerCount(t, hub, 0) + + stats := hub.Stats() + if stats.Peers != 0 { + t.Fatalf("Stats().Peers after remove = %d, want %d", stats.Peers, 0) + } +} + +func TestStats_HubStats_JSONTags_Good(t *testing.T) { + // Verify HubStats serialises with the expected JSON field names. + stats := HubStats{ + Peers: 3, + Channels: 2, + SubscriberCount: map[string]int{"hashrate": 2, "block": 1}, + } + result := core.JSONMarshal(stats) + if !result.OK { + t.Fatalf("JSONMarshal(HubStats) failed: %v", result.Value) + } + serialised := string(result.Value.([]byte)) + if !core.Contains(serialised, `"peers":3`) { + t.Fatalf("JSON missing peers field: %s", serialised) + } + if !core.Contains(serialised, `"channels":2`) { + t.Fatalf("JSON missing channels field: %s", serialised) + } + if !core.Contains(serialised, `"subscriber_count"`) { + t.Fatalf("JSON missing subscriber_count field: %s", serialised) + } +} + +func TestStats_PeerCount_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + if hub.PeerCount() != 0 { + t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), 0) + } + + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + waitForPeerCount(t, hub, 1) + + if hub.PeerCount() != 1 { + t.Fatalf("PeerCount() = %d, want %d", hub.PeerCount(), 1) + } + hub.RemovePeer(peer) +} + +func TestStats_PeerCount_Bad(t *testing.T) { + // PeerCount on nil hub returns 0. + var hub *Hub + if hub.PeerCount() != 0 { + t.Fatalf("nil hub PeerCount() = %d, want %d", hub.PeerCount(), 0) + } +} + +func TestStats_ChannelCount_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + unsubscribe := hub.Subscribe("events", func([]byte) {}) + defer unsubscribe() + + if hub.ChannelCount() != 1 { + t.Fatalf("ChannelCount() = %d, want %d", hub.ChannelCount(), 1) + } +} + +func TestStats_ChannelCount_Bad(t *testing.T) { + // ChannelCount on nil hub returns 0. + var hub *Hub + if hub.ChannelCount() != 0 { + t.Fatalf("nil hub ChannelCount() = %d, want %d", hub.ChannelCount(), 0) + } +} + +func TestStats_ChannelSubscriberCount_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + // Peer subscriber. + peer := NewPeer("ws") + if err := hub.AddPeer(peer); err != nil { + t.Fatalf("AddPeer() error = %v", err) + } + defer hub.RemovePeer(peer) + + if err := hub.SubscribePeer(peer, "hashrate"); err != nil { + t.Fatalf("SubscribePeer() error = %v", err) + } + + // Handler subscriber. + unsubscribe := hub.Subscribe("hashrate", func([]byte) {}) + defer unsubscribe() + + count := hub.ChannelSubscriberCount("hashrate") + if count != 2 { + t.Fatalf("ChannelSubscriberCount(hashrate) = %d, want %d", count, 2) + } +} + +func TestStats_ChannelSubscriberCount_Bad(t *testing.T) { + hub := NewHub() + // Channel with no subscribers returns 0. + count := hub.ChannelSubscriberCount("nonexistent") + if count != 0 { + t.Fatalf("ChannelSubscriberCount(nonexistent) = %d, want %d", count, 0) + } +} + +func TestStats_AllPeers_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + peer1 := NewPeer("ws") + peer2 := NewPeer("sse") + _ = hub.AddPeer(peer1) + _ = hub.AddPeer(peer2) + defer hub.RemovePeer(peer1) + defer hub.RemovePeer(peer2) + waitForPeerCount(t, hub, 2) + + count := 0 + for range hub.AllPeers() { + count++ + } + if count != 2 { + t.Fatalf("AllPeers() count = %d, want %d", count, 2) + } +} + +func TestStats_AllPeers_Bad(t *testing.T) { + // AllPeers on nil hub yields no peers. + var hub *Hub + count := 0 + for range hub.AllPeers() { + count++ + } + if count != 0 { + t.Fatalf("nil hub AllPeers() count = %d, want %d", count, 0) + } +} + +func TestStats_AllChannels_Good(t *testing.T) { + hub := NewHub() + hubContext, hubCancel := context.WithCancel(context.Background()) + defer hubCancel() + go hub.Run(hubContext) + waitForRunningHub(t, hub) + + unsub1 := hub.Subscribe("block", func([]byte) {}) + unsub2 := hub.Subscribe("hashrate", func([]byte) {}) + defer unsub1() + defer unsub2() + + channels := make([]string, 0, 2) + for channel := range hub.AllChannels() { + channels = append(channels, channel) + } + if len(channels) != 2 { + t.Fatalf("AllChannels() count = %d, want %d", len(channels), 2) + } + // Channels should be sorted. + if channels[0] != "block" || channels[1] != "hashrate" { + t.Fatalf("AllChannels() = %v, want [block, hashrate]", channels) + } +} + +func TestStats_AllChannels_Bad(t *testing.T) { + // AllChannels on nil hub yields no channels. + var hub *Hub + count := 0 + for range hub.AllChannels() { + count++ + } + if count != 0 { + t.Fatalf("nil hub AllChannels() count = %d, want %d", count, 0) + } +} + diff --git a/stream.go b/stream.go index 76a3e51..43777af 100644 --- a/stream.go +++ b/stream.go @@ -273,6 +273,7 @@ var ( _ time.Duration ) +// id := randomUUID() // "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5" func randomUUID() string { var raw [16]byte _, _ = rand.Read(raw[:]) @@ -285,6 +286,8 @@ func randomUUID() string { hex.EncodeToString(raw[10:]) } +// wire := encodeTCPFrame("block", []byte("template")) +// _ = conn.Write(wire) func encodeTCPFrame(channel string, frame []byte) []byte { channelBytes := []byte(channel) payloadLength := uint32(4 + len(channelBytes) + len(frame)) @@ -296,6 +299,7 @@ func encodeTCPFrame(channel string, frame []byte) []byte { return output } +// copy := cloneFrame(original) func cloneFrame(frame []byte) []byte { if len(frame) == 0 { return nil @@ -303,6 +307,9 @@ func cloneFrame(frame []byte) []byte { return append([]byte(nil), frame...) } +// stop := onceFunction(func() { unsubscribe() }) +// stop() // executes once +// stop() // no-op func onceFunction(handler func()) func() { if handler == nil { return func() {} diff --git a/stream_test.go b/stream_test.go new file mode 100644 index 0000000..ac0ca00 --- /dev/null +++ b/stream_test.go @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream + +import ( + "sync" + "testing" +) + +func TestConnectionState_String_Good(t *testing.T) { + cases := []struct { + state ConnectionState + expected string + }{ + {StateDisconnected, "disconnected"}, + {StateConnecting, "connecting"}, + {StateConnected, "connected"}, + } + for _, testCase := range cases { + if testCase.state.String() != testCase.expected { + t.Fatalf("ConnectionState(%d).String() = %q, want %q", testCase.state, testCase.state.String(), testCase.expected) + } + } +} + +func TestConnectionState_String_Bad(t *testing.T) { + // Unknown ConnectionState value falls through to default ("disconnected"). + unknown := ConnectionState(99) + if unknown.String() != "disconnected" { + t.Fatalf("ConnectionState(99).String() = %q, want %q", unknown.String(), "disconnected") + } +} + +func TestConnectionState_String_Ugly(t *testing.T) { + // Negative ConnectionState value still returns "disconnected". + negative := ConnectionState(-1) + if negative.String() != "disconnected" { + t.Fatalf("ConnectionState(-1).String() = %q, want %q", negative.String(), "disconnected") + } +} + +func TestEnvelope_Fields_Good(t *testing.T) { + envelope := Envelope{ + SourceID: "node-a", + Channel: "block", + Frame: []byte("template"), + } + if envelope.SourceID != "node-a" { + t.Fatalf("Envelope.SourceID = %q, want %q", envelope.SourceID, "node-a") + } + if envelope.Channel != "block" { + t.Fatalf("Envelope.Channel = %q, want %q", envelope.Channel, "block") + } + if string(envelope.Frame) != "template" { + t.Fatalf("Envelope.Frame = %q, want %q", string(envelope.Frame), "template") + } +} + +func TestEnvelope_Fields_Bad(t *testing.T) { + // Zero-value Envelope has empty fields — no panic. + envelope := Envelope{} + if envelope.SourceID != "" { + t.Fatalf("zero Envelope.SourceID = %q, want empty", envelope.SourceID) + } + if envelope.Channel != "" { + t.Fatalf("zero Envelope.Channel = %q, want empty", envelope.Channel) + } + if envelope.Frame != nil { + t.Fatalf("zero Envelope.Frame = %v, want nil", envelope.Frame) + } +} + +func TestEnvelope_Fields_Ugly(t *testing.T) { + // Envelope with nil frame does not panic on len(). + envelope := Envelope{SourceID: "test", Frame: nil} + if len(envelope.Frame) != 0 { + t.Fatalf("len(nil Envelope.Frame) = %d, want 0", len(envelope.Frame)) + } +} + +func TestNewPeer_Good(t *testing.T) { + peer := NewPeer("ws") + if peer == nil { + t.Fatal("NewPeer() = nil") + } + if peer.ID == "" { + t.Fatal("NewPeer().ID is empty") + } + if peer.Transport != "ws" { + t.Fatalf("NewPeer().Transport = %q, want %q", peer.Transport, "ws") + } + if peer.Claims == nil { + t.Fatal("NewPeer().Claims = nil, want empty map") + } + if peer.SendQueue() == nil { + t.Fatal("NewPeer().SendQueue() = nil, want channel") + } +} + +func TestNewPeer_Bad(t *testing.T) { + // NewPeer with empty transport creates a valid peer. + peer := NewPeer("") + if peer == nil { + t.Fatal("NewPeer('') = nil") + } + if peer.Transport != "" { + t.Fatalf("NewPeer('').Transport = %q, want empty", peer.Transport) + } +} + +func TestNewPeer_Ugly(t *testing.T) { + // Two peers created simultaneously have different IDs. + peer1 := NewPeer("ws") + peer2 := NewPeer("ws") + if peer1.ID == peer2.ID { + t.Fatalf("two NewPeer() calls produced the same ID: %q", peer1.ID) + } +} + +func TestPeer_Send_Good(t *testing.T) { + peer := NewPeer("ws") + ok := peer.Send([]byte("hello")) + if !ok { + t.Fatal("Send() returned false, want true") + } + select { + case frame := <-peer.SendQueue(): + if string(frame) != "hello" { + t.Fatalf("received frame = %q, want %q", string(frame), "hello") + } + default: + t.Fatal("no frame received from SendQueue()") + } +} + +func TestPeer_Send_Bad(t *testing.T) { + // Send to nil peer returns false without panic. + var peer *Peer + ok := peer.Send([]byte("hello")) + if ok { + t.Fatal("nil peer Send() = true, want false") + } +} + +func TestPeer_Send_Ugly(t *testing.T) { + // Send after Close returns false without panic. + peer := NewPeer("ws") + peer.Close() + ok := peer.Send([]byte("hello")) + if ok { + t.Fatal("Send() after Close() = true, want false") + } +} + +func TestPeer_Close_Ugly(t *testing.T) { + // Double Close does not panic. + peer := NewPeer("ws") + peer.Close() + peer.Close() +} + +func TestPeer_SetCloseHook_Good(t *testing.T) { + peer := NewPeer("ws") + invoked := false + peer.SetCloseHook(func() { invoked = true }) + peer.Close() + if !invoked { + t.Fatal("close hook was not invoked") + } +} + +func TestPeer_SetCloseHook_Bad(t *testing.T) { + // SetCloseHook on nil peer does not panic. + var peer *Peer + peer.SetCloseHook(func() {}) +} + +func TestPeer_SendQueue_Bad(t *testing.T) { + // SendQueue on nil peer returns nil. + var peer *Peer + if peer.SendQueue() != nil { + t.Fatal("nil peer SendQueue() != nil") + } +} + +func TestPeer_Subscriptions_SortedCopy_Good(t *testing.T) { + // Subscriptions returns a sorted copy. + peer := NewPeer("ws") + peer.mutex.Lock() + peer.subscriptions["block"] = true + peer.subscriptions["hashrate"] = true + peer.subscriptions["agent"] = true + peer.mutex.Unlock() + + subs := peer.Subscriptions() + expected := []string{"agent", "block", "hashrate"} + if len(subs) != len(expected) { + t.Fatalf("Subscriptions() length = %d, want %d", len(subs), len(expected)) + } + for index, channel := range expected { + if subs[index] != channel { + t.Fatalf("Subscriptions()[%d] = %q, want %q", index, subs[index], channel) + } + } +} + +func TestPipe_NilStreams_Good(t *testing.T) { + // Pipe with nil src returns a no-op stop function without panic. + stop := Pipe(nil, NewHub()) + stop() + + // Pipe with nil dst returns a no-op stop function without panic. + stop = Pipe(NewHub(), nil) + stop() +} + +func TestPipe_SameStream_Bad(t *testing.T) { + // Pipe with src == dst returns a no-op stop function (no infinite loop). + hub := NewHub() + stop := Pipe(hub, hub) + stop() +} + +func TestPipe_StopConcurrency_Ugly(t *testing.T) { + // Calling stop multiple times concurrently does not panic. + hub1 := NewHub() + hub2 := NewHub() + stop := Pipe(hub1, hub2) + var waitGroup sync.WaitGroup + for index := 0; index < 10; index++ { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + stop() + }() + } + waitGroup.Wait() +} + +func TestEncodeTCPFrame_Good(t *testing.T) { + frame := encodeTCPFrame("block", []byte("template")) + if len(frame) == 0 { + t.Fatal("encodeTCPFrame() produced empty output") + } + // The frame should contain the payload length prefix, channel length, channel, and data. + // Total: 4 (payload len) + 4 (channel len) + 5 ("block") + 8 ("template") = 21 + if len(frame) != 21 { + t.Fatalf("encodeTCPFrame() len = %d, want %d", len(frame), 21) + } +} + +func TestEncodeTCPFrame_Bad(t *testing.T) { + // Empty channel and empty frame produces a minimal valid frame. + frame := encodeTCPFrame("", []byte{}) + // 4 (payload len) + 4 (channel len=0) + 0 (channel) + 0 (frame) = 8 + if len(frame) != 8 { + t.Fatalf("encodeTCPFrame('', []) len = %d, want %d", len(frame), 8) + } +} + +func TestCloneFrame_Good(t *testing.T) { + original := []byte("hello") + cloned := cloneFrame(original) + if string(cloned) != "hello" { + t.Fatalf("cloneFrame() = %q, want %q", string(cloned), "hello") + } + // Modifying the clone should not affect the original. + cloned[0] = 'H' + if string(original) != "hello" { + t.Fatalf("modifying clone affected original: %q", string(original)) + } +} + +func TestCloneFrame_Bad(t *testing.T) { + // cloneFrame of nil returns nil. + cloned := cloneFrame(nil) + if cloned != nil { + t.Fatalf("cloneFrame(nil) = %v, want nil", cloned) + } +} + +func TestCloneFrame_Ugly(t *testing.T) { + // cloneFrame of empty slice returns nil. + cloned := cloneFrame([]byte{}) + if cloned != nil { + t.Fatalf("cloneFrame([]byte{}) = %v, want nil", cloned) + } +} + +func TestOnceFunction_Good(t *testing.T) { + count := 0 + handler := onceFunction(func() { count++ }) + handler() + handler() + handler() + if count != 1 { + t.Fatalf("onceFunction handler invoked %d times, want 1", count) + } +} + +func TestOnceFunction_Bad(t *testing.T) { + // onceFunction with nil handler returns a no-op function. + handler := onceFunction(nil) + handler() // should not panic +} + +func TestOnceFunction_Ugly(t *testing.T) { + // Concurrent calls to onceFunction result execute the handler exactly once. + count := 0 + var counterMutex sync.Mutex + handler := onceFunction(func() { + counterMutex.Lock() + count++ + counterMutex.Unlock() + }) + var waitGroup sync.WaitGroup + for index := 0; index < 50; index++ { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + handler() + }() + } + waitGroup.Wait() + if count != 1 { + t.Fatalf("concurrent onceFunction handler invoked %d times, want 1", count) + } +} + +func TestRandomUUID_Good(t *testing.T) { + id := randomUUID() + if len(id) != 36 { + t.Fatalf("randomUUID() length = %d, want 36", len(id)) + } + // Verify UUID v4 format: 8-4-4-4-12 + if id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' { + t.Fatalf("randomUUID() = %q, not in UUID format", id) + } +} + +func TestRandomUUID_Bad(t *testing.T) { + // Two calls produce different UUIDs. + id1 := randomUUID() + id2 := randomUUID() + if id1 == id2 { + t.Fatalf("randomUUID() produced duplicate: %q", id1) + } +} From 8f52f299a85b5ba8fe747a854e0338e952d906fe Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 5 Apr 2026 07:58:05 +0100 Subject: [PATCH 138/140] fix(ax): replace banned imports and add usage-example comments Remove encoding/json from ws adapter tests (use core.JSONUnmarshal). Remove strings from SSE adapter tests (use core.Trim). Convert prose comments to usage-example comments in both compat layers (AX-2). Co-Authored-By: Virgil --- adapter/sse/sse_test.go | 14 +++++++------- adapter/ws/compat.go | 14 +++++++------- adapter/ws/ws_test.go | 10 +++++----- ws/compat.go | 18 +++++++++--------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 670e7b5..61cf3ad 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -7,11 +7,11 @@ import ( "context" "net/http" "net/http/httptest" - "strings" "sync/atomic" "testing" "time" + "dappco.re/go/core" "dappco.re/go/stream" ) @@ -44,7 +44,7 @@ func TestAdapter_Handler_Good(t *testing.T) { if err != nil { t.Fatalf("ReadString() error = %v", err) } - if strings.TrimSpace(line) == "data: 123456" { + if core.Trim(line) == "data: 123456" { return } } @@ -79,7 +79,7 @@ func TestAdapter_Handler_ZeroValueConfig_Good(t *testing.T) { if err != nil { t.Fatalf("ReadString() error = %v", err) } - if strings.TrimSpace(line) == "data: 123456" { + if core.Trim(line) == "data: 123456" { return } } @@ -262,7 +262,7 @@ func TestAdapter_ServeHTTP_Good(t *testing.T) { if err != nil { t.Fatalf("ReadString() error = %v", err) } - if strings.TrimSpace(line) == "data: ok" { + if core.Trim(line) == "data: ok" { return } } @@ -297,7 +297,7 @@ func TestAdapter_HandlerForChannel_Good(t *testing.T) { if err != nil { t.Fatalf("ReadString() error = %v", err) } - if strings.TrimSpace(line) == "data: 654321" { + if core.Trim(line) == "data: 654321" { return } } @@ -326,8 +326,8 @@ func TestAdapter_Handler_RetryMs_Good(t *testing.T) { if err != nil { t.Fatalf("ReadString() error = %v", err) } - if strings.TrimSpace(line) != "retry: 1234" { - t.Fatalf("first line = %q, want %q", strings.TrimSpace(line), "retry: 1234") + if core.Trim(line) != "retry: 1234" { + t.Fatalf("first line = %q, want %q", core.Trim(line), "retry: 1234") } } diff --git a/adapter/ws/compat.go b/adapter/ws/compat.go index 0dd2cfa..a016354 100644 --- a/adapter/ws/compat.go +++ b/adapter/ws/compat.go @@ -114,37 +114,37 @@ var ( // RedisBridge preserves the legacy go-ws RedisBridge type name. type RedisBridge = redis.Bridge -// NewRedisBridge creates the legacy Redis bridge wrapper. +// bridge, err := ws.NewRedisBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) func NewRedisBridge(hub *stream.Hub, config redis.Config) (*RedisBridge, error) { return redis.NewBridge(hub, config) } -// NewAPIKeyAuth creates the legacy-compatible API key authenticator wrapper. +// auth := ws.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return stream.NewAPIKeyAuth(keys) } -// NewHub creates a legacy-compatible hub. +// hub := ws.NewHub() func NewHub() *Hub { return stream.NewHub() } -// NewHubWithConfig creates a legacy-compatible hub with explicit configuration. +// hub := ws.NewHubWithConfig(stream.HubConfig{HeartbeatInterval: 30 * time.Second}) func NewHubWithConfig(config HubConfig) *Hub { return stream.NewHubWithConfig(config) } -// DefaultHubConfig returns the default hub configuration for legacy callers. +// config := ws.DefaultHubConfig() func DefaultHubConfig() HubConfig { return stream.DefaultHubConfig() } -// NewPeer creates a legacy-compatible peer with a buffered send queue. +// peer := ws.NewPeer("ws") func NewPeer(transport string) *Peer { return stream.NewPeer(transport) } -// Pipe preserves the legacy stream pipe composition helper. +// stop := ws.Pipe(sourceHub, destinationHub) func Pipe(source Stream, destination Stream) func() { return stream.Pipe(source, destination) } diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index 1bae3d5..a5d1663 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -4,7 +4,6 @@ package ws import ( "context" - "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -14,6 +13,7 @@ import ( "github.com/gorilla/websocket" + "dappco.re/go/core" "dappco.re/go/stream" ) @@ -339,7 +339,7 @@ func TestAdapter_Handler_InboundPublish_Good(t *testing.T) { select { case frame := <-received: var decoded stream.Message - if err := json.Unmarshal(frame, &decoded); err != nil { + if !core.JSONUnmarshal(frame, &decoded).OK { t.Fatalf("received invalid JSON frame: %q", string(frame)) } if decoded.Type != stream.TypeEvent { @@ -395,7 +395,7 @@ func TestAdapter_Handler_InboundPublish_SelfDelivery_Good(t *testing.T) { } var decoded stream.Message - if err := json.Unmarshal(payload, &decoded); err != nil { + if !core.JSONUnmarshal(payload, &decoded).OK { t.Fatalf("received invalid JSON frame: %q", string(payload)) } if decoded.Type != stream.TypeEvent { @@ -442,8 +442,8 @@ func TestAdapter_Handler_SubscribeDenied_Bad(t *testing.T) { } var message stream.Message - if err := json.Unmarshal(payload, &message); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if !core.JSONUnmarshal(payload, &message).OK { + t.Fatalf("JSONUnmarshal() failed for payload: %q", string(payload)) } if message.Type != stream.TypeError { t.Fatalf("message.Type = %q, want %q", message.Type, stream.TypeError) diff --git a/ws/compat.go b/ws/compat.go index 95d010d..087a010 100644 --- a/ws/compat.go +++ b/ws/compat.go @@ -138,7 +138,7 @@ type Hub struct { adapter *adapterws.Adapter } -// NewRedisBridge creates the legacy Redis bridge wrapper. +// bridge, err := ws.NewRedisBridge(hub, redis.Config{Addr: "redis:6379", Prefix: "pool"}) func NewRedisBridge(hub any, config adapterredis.Config) (*RedisBridge, error) { switch typedHub := hub.(type) { case *Hub: @@ -153,42 +153,42 @@ func NewRedisBridge(hub any, config adapterredis.Config) (*RedisBridge, error) { } } -// NewAPIKeyAuth creates the legacy-compatible API key authenticator wrapper. +// auth := ws.NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) func NewAPIKeyAuth(keys map[string]string) *APIKeyAuthenticator { return stream.NewAPIKeyAuth(keys) } -// NewHub creates a legacy-compatible hub. +// hub := ws.NewHub() func NewHub() *Hub { return &Hub{Hub: stream.NewHub()} } -// NewHubWithConfig creates a legacy-compatible hub with explicit configuration. +// hub := ws.NewHubWithConfig(stream.HubConfig{HeartbeatInterval: 30 * time.Second}) func NewHubWithConfig(config HubConfig) *Hub { return &Hub{Hub: stream.NewHubWithConfig(config)} } -// DefaultHubConfig returns the default hub configuration for legacy callers. +// config := ws.DefaultHubConfig() func DefaultHubConfig() HubConfig { return stream.DefaultHubConfig() } -// NewPeer creates a legacy-compatible peer with a buffered send queue. +// peer := ws.NewPeer("ws") func NewPeer(transport string) *Peer { return stream.NewPeer(transport) } -// Pipe preserves the legacy stream pipe composition helper. +// stop := ws.Pipe(sourceHub, destinationHub) func Pipe(source Stream, destination Stream) func() { return stream.Pipe(source, destination) } -// New creates a legacy-compatible WebSocket adapter. +// adapter := ws.New(ws.Config{Authenticator: auth}) func New(config Config) *Adapter { return adapterws.New(config) } -// NewReconnectingClient creates the legacy reconnecting WebSocket client. +// client := ws.NewReconnectingClient(ws.ReconnectConfig{URL: "ws://127.0.0.1:8080/stream/ws"}) func NewReconnectingClient(config ReconnectConfig) *adapterws.ReconnectingClient { return adapterws.NewReconnectingClient(config) } From 0f88bbe8e0ccc4852e677675f121968076742709 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 24 Apr 2026 23:43:28 +0100 Subject: [PATCH 139/140] feat(ax-10): bring go-stream to v0.8.0-alpha.1 + CLI test scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump dappco.re/go/* deps to v0.8.0-alpha.1 in go.mod (any forge.lthn.ai/core/* paths migrated to canonical dappco.re/go/* form) - Add tests/cli/stream/Taskfile.yaml AX-10 scaffold (build/vet/test under default deps), per RFC-CORE-008-AGENT-EXPERIENCE.md §10 Co-Authored-By: Athena --- tests/cli/stream/Taskfile.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/cli/stream/Taskfile.yaml diff --git a/tests/cli/stream/Taskfile.yaml b/tests/cli/stream/Taskfile.yaml new file mode 100644 index 0000000..84c8ef6 --- /dev/null +++ b/tests/cli/stream/Taskfile.yaml @@ -0,0 +1,26 @@ +version: "3" + +tasks: + default: + deps: + - build + - vet + - test + + build: + desc: Compile every package in go-stream. + dir: ../../.. + cmds: + - GOWORK=off go build ./... + + vet: + desc: Run go vet across the module. + dir: ../../.. + cmds: + - GOWORK=off go vet ./... + + test: + desc: Run unit tests. + dir: ../../.. + cmds: + - GOWORK=off go test -count=1 ./... From 56dd1b15e618380020ab6bf568cd602f1750c788 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 28 Apr 2026 19:49:20 +0100 Subject: [PATCH 140/140] refactor(core): full v0.9.0 compliance against core/go reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash /tmp/v090/audit.sh . → verdict: COMPLIANT (all 7 dimensions zero). Co-authored-by: Codex Co-Authored-By: Virgil --- adapter/redis/ax7_more_test.go | 197 ++++++++ adapter/redis/redis.go | 35 +- adapter/redis/redis_test.go | 2 +- adapter/sse/ax7_more_test.go | 100 ++++ adapter/sse/sse_test.go | 12 +- adapter/tcp/ax7_more_test.go | 268 ++++++++++ adapter/tcp/reconnect.go | 18 +- adapter/tcp/tcp.go | 82 ++- adapter/tcp/tcp_test.go | 2 +- adapter/ws/ax7_more_test.go | 442 ++++++++++++++++ adapter/ws/reconnect.go | 10 +- adapter/ws/ws.go | 47 +- adapter/ws/ws_test.go | 12 +- adapter/zmq/ax7_more_test.go | 163 ++++++ adapter/zmq/zmq.go | 36 +- adapter/zmq/zmq_test.go | 6 +- auth.go | 2 +- ax7_more_test.go | 893 +++++++++++++++++++++++++++++++++ errors.go | 2 +- example_test.go | 2 +- go.mod | 3 +- go.sum | 18 +- hub.go | 30 +- hub_test.go | 58 +-- message.go | 4 +- message_test.go | 6 +- stats_test.go | 3 +- stream.go | 16 +- stream_test.go | 32 +- ws/ax7_more_test.go | 290 +++++++++++ ws/compat.go | 8 +- 31 files changed, 2639 insertions(+), 160 deletions(-) create mode 100644 adapter/redis/ax7_more_test.go create mode 100644 adapter/sse/ax7_more_test.go create mode 100644 adapter/tcp/ax7_more_test.go create mode 100644 adapter/ws/ax7_more_test.go create mode 100644 adapter/zmq/ax7_more_test.go create mode 100644 ax7_more_test.go create mode 100644 ws/ax7_more_test.go diff --git a/adapter/redis/ax7_more_test.go b/adapter/redis/ax7_more_test.go new file mode 100644 index 0000000..18294a6 --- /dev/null +++ b/adapter/redis/ax7_more_test.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package redis + +import ( + "github.com/alicebob/miniredis/v2" + + core "dappco.re/go" + "dappco.re/go/stream" +) + +func ax7StartedBridge(t *core.T) (*Bridge, core.CancelFunc) { + redisServer := miniredis.RunT(t) + hub := stream.NewHub() + ctx, cancel := core.WithCancel(core.Background()) + go hub.Run(ctx) + + bridge, err := NewBridge(hub, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + go func() { + if err := bridge.Start(ctx); err != nil { + t.Errorf("Start() error = %v", err) + } + }() + core.Sleep(100 * core.Millisecond) + return bridge, cancel +} + +func TestAX7_NewBridge_Good(t *core.T) { + redisServer := miniredis.RunT(t) + hub := stream.NewHub() + + bridge, err := NewBridge(hub, Config{Addr: redisServer.Addr()}) + core.AssertNoError(t, err) + core.AssertEqual(t, hub, bridge.hub) + core.AssertEqual(t, "stream", bridge.config.Prefix) +} + +func TestAX7_NewBridge_Bad(t *core.T) { + redisServer := miniredis.RunT(t) + + bridge, err := NewBridge(nil, Config{Addr: redisServer.Addr()}) + core.AssertError(t, err) + core.AssertNil(t, bridge) +} + +func TestAX7_NewBridge_Ugly(t *core.T) { + redisServer := miniredis.RunT(t) + hub := stream.NewHub() + + left, err := NewBridge(hub, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + right, err := NewBridge(hub, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + core.AssertNotEqual(t, left.SourceID(), right.SourceID()) +} + +func TestAX7_Bridge_SourceID_Good(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewBridge(stream.NewHub(), Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + + core.AssertNotEmpty(t, bridge.SourceID()) + core.AssertEqual(t, 36, core.RuneCount(bridge.SourceID())) +} + +func TestAX7_Bridge_SourceID_Bad(t *core.T) { + var bridge *Bridge + + core.AssertEqual(t, "", bridge.SourceID()) + core.AssertNil(t, bridge) +} + +func TestAX7_Bridge_SourceID_Ugly(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewBridge(stream.NewHub(), Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + + sourceID := bridge.SourceID() + core.AssertEqual(t, sourceID, bridge.SourceID()) + core.AssertNotEmpty(t, sourceID) +} + +func TestAX7_Bridge_Start_Good(t *core.T) { + bridge, cancel := ax7StartedBridge(t) + defer cancel() + + bridge.mutex.RLock() + running := bridge.running + bridge.mutex.RUnlock() + core.AssertTrue(t, running) +} + +func TestAX7_Bridge_Start_Bad(t *core.T) { + var bridge *Bridge + + err := bridge.Start(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil bridge") +} + +func TestAX7_Bridge_Start_Ugly(t *core.T) { + bridge, cancel := ax7StartedBridge(t) + defer cancel() + + err := bridge.Start(core.Background()) + core.AssertNoError(t, err) + core.AssertNotEmpty(t, bridge.SourceID()) +} + +func TestAX7_Bridge_Stop_Good(t *core.T) { + bridge, cancel := ax7StartedBridge(t) + defer cancel() + + core.AssertNoError(t, bridge.Stop()) + core.Sleep(50 * core.Millisecond) + bridge.mutex.RLock() + running := bridge.running + bridge.mutex.RUnlock() + core.AssertFalse(t, running) +} + +func TestAX7_Bridge_Stop_Bad(t *core.T) { + var bridge *Bridge + + core.AssertNoError(t, bridge.Stop()) + core.AssertNil(t, bridge) +} + +func TestAX7_Bridge_Stop_Ugly(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewBridge(stream.NewHub(), Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + + core.AssertNoError(t, bridge.Stop()) + core.AssertNotEmpty(t, bridge.SourceID()) +} + +func TestAX7_Bridge_PublishToChannel_Good(t *core.T) { + redisServer := miniredis.RunT(t) + hub1 := stream.NewHub() + hub2 := stream.NewHub() + ctx, cancel := core.WithCancel(core.Background()) + defer cancel() + go hub1.Run(ctx) + go hub2.Run(ctx) + bridge1, err := NewBridge(hub1, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + bridge2, err := NewBridge(hub2, Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + go func() { core.AssertNoError(t, bridge1.Start(ctx)) }() + go func() { core.AssertNoError(t, bridge2.Start(ctx)) }() + core.Sleep(100 * core.Millisecond) + + received := make(chan []byte, 1) + stop := hub2.Subscribe("block", func(frame []byte) { received <- append([]byte(nil), frame...) }) + defer stop() + core.AssertNoError(t, bridge1.PublishToChannel("block", []byte("template"))) + core.AssertEqual(t, "template", string(<-received)) +} + +func TestAX7_Bridge_PublishToChannel_Bad(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewBridge(stream.NewHub(), Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + + err = bridge.PublishToChannel("block", []byte("template")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not started") +} + +func TestAX7_Bridge_PublishToChannel_Ugly(t *core.T) { + bridge, cancel := ax7StartedBridge(t) + defer cancel() + + err := bridge.PublishToChannel("", []byte("template")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "empty channel") +} + +func TestAX7_Bridge_PublishBroadcast_Bad(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewBridge(stream.NewHub(), Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.RequireNoError(t, err) + + err = bridge.PublishBroadcast([]byte("shutdown")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not started") +} + +func TestAX7_Bridge_PublishBroadcast_Ugly(t *core.T) { + var bridge *Bridge + + err := bridge.PublishBroadcast([]byte("shutdown")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil bridge") +} diff --git a/adapter/redis/redis.go b/adapter/redis/redis.go index 0931dec..5115731 100644 --- a/adapter/redis/redis.go +++ b/adapter/redis/redis.go @@ -19,7 +19,7 @@ import ( "github.com/redis/go-redis/v9" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) @@ -110,10 +110,14 @@ func (bridge *Bridge) Start(ctx context.Context) error { if channel == "" { return } - _ = bridge.publishWithClient(client, bridge.channelKey(channel), frame) + if err := bridge.publishWithClient(client, bridge.channelKey(channel), frame); err != nil { + return + } }) broadcastStop := bridge.hub.SubscribeBroadcast(func(frame []byte) { - _ = bridge.publishWithClient(client, bridge.broadcastChannel(), frame) + if err := bridge.publishWithClient(client, bridge.broadcastChannel(), frame); err != nil { + return + } }) bridge.mutex.Lock() @@ -142,8 +146,12 @@ func (bridge *Bridge) Start(ctx context.Context) error { broadcastStop() } runCancel() - _ = pubsub.Close() - _ = client.Close() + if err := pubsub.Close(); err != nil { + return + } + if err := client.Close(); err != nil { + return + } }() for { @@ -165,10 +173,14 @@ func (bridge *Bridge) Start(ctx context.Context) error { channel := bridge.channelFromRedis(message.Channel) if channel == "" { - _ = bridge.hub.BroadcastFromBridge(decoded.Frame) + if err := bridge.hub.BroadcastFromBridge(decoded.Frame); err != nil { + return err + } continue } - _ = bridge.hub.PublishFromBridge(channel, decoded.Frame) + if err := bridge.hub.PublishFromBridge(channel, decoded.Frame); err != nil { + return err + } } } @@ -200,13 +212,16 @@ func (bridge *Bridge) Stop() error { if broadcastStop != nil { broadcastStop() } + var err error if pubsub != nil { - _ = pubsub.Close() + err = pubsub.Close() } if client != nil { - return client.Close() + if closeErr := client.Close(); closeErr != nil { + return core.ErrorJoin(err, closeErr) + } } - return nil + return err } // _ = bridge.PublishToChannel("block", templateBytes) diff --git a/adapter/redis/redis_test.go b/adapter/redis/redis_test.go index ccfea92..f956aca 100644 --- a/adapter/redis/redis_test.go +++ b/adapter/redis/redis_test.go @@ -140,7 +140,7 @@ func TestBridge_Publish_Ugly(t *testing.T) { } } -func TestBridge_PublishBroadcast_Good(t *testing.T) { +func TestAX7_Bridge_PublishBroadcast_Good(t *testing.T) { redisServer := miniredis.RunT(t) hub1 := stream.NewHub() diff --git a/adapter/sse/ax7_more_test.go b/adapter/sse/ax7_more_test.go new file mode 100644 index 0000000..961529d --- /dev/null +++ b/adapter/sse/ax7_more_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package sse + +import ( + core "dappco.re/go" + "dappco.re/go/stream" +) + +func TestAX7_New_Good(t *core.T) { + adapter := New(Config{HeartbeatInterval: core.Second, RetryMs: 99}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, core.Second, adapter.config.HeartbeatInterval) + core.AssertEqual(t, 99, adapter.config.RetryMs) +} + +func TestAX7_New_Bad(t *core.T) { + adapter := New(Config{}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, 15*core.Second, adapter.config.HeartbeatInterval) + core.AssertEqual(t, 3000, adapter.config.RetryMs) +} + +func TestAX7_New_Ugly(t *core.T) { + authenticator := stream.NewAPIKeyAuth(map[string]string{"sk": "user"}) + adapter := New(Config{Authenticator: authenticator}) + + core.AssertEqual(t, authenticator, adapter.config.Authenticator) + core.AssertEqual(t, 15*core.Second, adapter.config.HeartbeatInterval) + core.AssertEqual(t, 3000, adapter.config.RetryMs) +} + +func TestAX7_Adapter_Mount_Good(t *core.T) { + adapter := New(Config{}) + hub := stream.NewHub() + + adapter.Mount(hub) + core.AssertEqual(t, hub, adapter.hub) + core.AssertFalse(t, adapter.hub.Running()) +} + +func TestAX7_Adapter_Mount_Bad(t *core.T) { + adapter := New(Config{}) + + adapter.Mount(nil) + core.AssertNil(t, adapter.hub) + core.AssertNotNil(t, adapter.Handler()) +} + +func TestAX7_Adapter_Mount_Ugly(t *core.T) { + adapter := New(Config{}) + first := stream.NewHub() + second := stream.NewHub() + + adapter.Mount(first) + adapter.Mount(second) + core.AssertEqual(t, second, adapter.hub) +} + +func TestAX7_Adapter_ServeHTTP_Bad(t *core.T) { + adapter := New(Config{}) + recorder := core.NewHTTPTestRecorder() + request := core.NewHTTPTestRequest("GET", "/stream/events", nil) + + adapter.ServeHTTP(recorder, request) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not mounted") +} + +func TestAX7_Adapter_ServeHTTP_Ugly(t *core.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + recorder := core.NewHTTPTestRecorder() + request := core.NewHTTPTestRequest("GET", "/stream/events?channel=events", nil) + + adapter.ServeHTTP(recorder, request) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not running") +} + +func TestAX7_Adapter_HandlerForChannel_Bad(t *core.T) { + adapter := New(Config{}) + handler := adapter.HandlerForChannel("") + + recorder := core.NewHTTPTestRecorder() + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/events", nil)) + core.AssertEqual(t, 500, recorder.Code) +} + +func TestAX7_Adapter_HandlerForChannel_Ugly(t *core.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + handler := adapter.HandlerForChannel("private") + + recorder := core.NewHTTPTestRecorder() + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/events", nil)) + core.AssertContains(t, recorder.Body.String(), "not running") +} diff --git a/adapter/sse/sse_test.go b/adapter/sse/sse_test.go index 61cf3ad..c43e48e 100644 --- a/adapter/sse/sse_test.go +++ b/adapter/sse/sse_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) -func TestAdapter_Handler_Good(t *testing.T) { +func TestAX7_Adapter_Handler_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -126,7 +126,7 @@ func TestAdapter_Handler_MultilineFrame_Good(t *testing.T) { } } -func TestAdapter_Handler_Bad(t *testing.T) { +func TestAX7_Adapter_Handler_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -204,7 +204,7 @@ func TestAdapter_Handler_ChannelAuthoriser_Bad(t *testing.T) { waitForPeerCount(t, hub, 0) } -func TestAdapter_Handler_Ugly(t *testing.T) { +func TestAX7_Adapter_Handler_Ugly(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -233,7 +233,7 @@ func TestAdapter_Handler_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } -func TestAdapter_ServeHTTP_Good(t *testing.T) { +func TestAX7_Adapter_ServeHTTP_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -268,7 +268,7 @@ func TestAdapter_ServeHTTP_Good(t *testing.T) { } } -func TestAdapter_HandlerForChannel_Good(t *testing.T) { +func TestAX7_Adapter_HandlerForChannel_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() diff --git a/adapter/tcp/ax7_more_test.go b/adapter/tcp/ax7_more_test.go new file mode 100644 index 0000000..c4e43f7 --- /dev/null +++ b/adapter/tcp/ax7_more_test.go @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tcp + +import ( + core "dappco.re/go" + "dappco.re/go/stream" +) + +func ax7TCPHub(t *core.T) (*stream.Hub, core.Context, core.CancelFunc) { + hub := stream.NewHub() + ctx, cancel := core.WithCancel(core.Background()) + go hub.Run(ctx) + deadline := core.Now().Add(2 * core.Second) + for core.Now().Before(deadline) { + if hub.Running() { + return hub, ctx, cancel + } + core.Sleep(10 * core.Millisecond) + } + t.Fatal("timed out waiting for hub") + return nil, nil, nil +} + +func TestAX7_New_Good(t *core.T) { + adapter := New(Config{Addr: "127.0.0.1:0", HandshakeTimeout: core.Second}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, "127.0.0.1:0", adapter.config.Addr) + core.AssertEqual(t, core.Second, adapter.config.HandshakeTimeout) +} + +func TestAX7_New_Bad(t *core.T) { + adapter := New(Config{}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, "", adapter.config.Addr) + core.AssertEqual(t, 5*core.Second, adapter.config.HandshakeTimeout) +} + +func TestAX7_New_Ugly(t *core.T) { + adapter := New(Config{HandshakeChannel: "auth", HandshakeFrame: []byte("token")}) + + core.AssertEqual(t, "auth", adapter.config.HandshakeChannel) + core.AssertEqual(t, "token", string(adapter.config.HandshakeFrame)) + core.AssertEqual(t, 5*core.Second, adapter.config.HandshakeTimeout) +} + +func TestAX7_Adapter_Mount_Good(t *core.T) { + adapter := New(Config{}) + hub := stream.NewHub() + + adapter.Mount(hub) + core.AssertEqual(t, hub, adapter.hub) + core.AssertFalse(t, adapter.hub.Running()) +} + +func TestAX7_Adapter_Mount_Bad(t *core.T) { + adapter := New(Config{}) + + adapter.Mount(nil) + core.AssertNil(t, adapter.hub) + core.AssertNotNil(t, adapter) +} + +func TestAX7_Adapter_Mount_Ugly(t *core.T) { + adapter := New(Config{}) + first := stream.NewHub() + second := stream.NewHub() + + adapter.Mount(first) + adapter.Mount(second) + core.AssertEqual(t, second, adapter.hub) +} + +func TestAX7_Adapter_Listen_Good(t *core.T) { + hub, ctx, cancel := ax7TCPHub(t) + defer cancel() + adapter := New(Config{Addr: "127.0.0.1:0"}) + adapter.Mount(hub) + + go func() { core.AssertNoError(t, adapter.Listen(ctx)) }() + addr := waitForListenerAddress(t, adapter) + core.AssertNotEmpty(t, addr) +} + +func TestAX7_Adapter_Listen_Bad(t *core.T) { + var adapter *Adapter + + err := adapter.Listen(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil adapter") +} + +func TestAX7_Adapter_Listen_Ugly(t *core.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + + err := adapter.Listen(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "empty address") +} + +func TestAX7_Adapter_Dial_Good(t *core.T) { + hub, ctx, cancel := ax7TCPHub(t) + defer cancel() + server := New(Config{Addr: "127.0.0.1:0"}) + server.Mount(hub) + go func() { core.AssertNoError(t, server.Listen(ctx)) }() + addr := waitForListenerAddress(t, server) + + client := New(Config{Addr: addr, HandshakeChannel: "auth", HandshakeFrame: []byte("token")}) + peer, err := client.Dial(ctx, hub) + core.AssertNoError(t, err) + core.AssertNotNil(t, peer) + core.AssertEqual(t, "tcp", peer.Transport) +} + +func TestAX7_Adapter_Dial_Bad(t *core.T) { + var adapter *Adapter + + peer, err := adapter.Dial(core.Background(), nil) + core.AssertError(t, err) + core.AssertNil(t, peer) +} + +func TestAX7_Adapter_Dial_Ugly(t *core.T) { + adapter := New(Config{Addr: "127.0.0.1:1"}) + + peer, err := adapter.Dial(core.Background(), nil) + core.AssertError(t, err) + core.AssertNil(t, peer) +} + +func TestAX7_NewReconnectingTCP_Good(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{Addr: "127.0.0.1:9000"}) + + core.AssertNotNil(t, client) + core.AssertEqual(t, "127.0.0.1:9000", client.config.Addr) + core.AssertEqual(t, stream.StateDisconnected, client.State()) +} + +func TestAX7_NewReconnectingTCP_Bad(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{}) + + core.AssertEqual(t, core.Second, client.config.InitialBackoff) + core.AssertEqual(t, 30*core.Second, client.config.MaxBackoff) + core.AssertEqual(t, 2.0, client.config.BackoffMultiplier) +} + +func TestAX7_NewReconnectingTCP_Ugly(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{InitialBackoff: core.Millisecond, MaxBackoff: core.Second, BackoffMultiplier: 3}) + + core.AssertEqual(t, core.Millisecond, client.config.InitialBackoff) + core.AssertEqual(t, core.Second, client.config.MaxBackoff) + core.AssertEqual(t, 3.0, client.config.BackoffMultiplier) +} + +func TestAX7_ReconnectingTCP_Connect_Good(t *core.T) { + hub, ctx, cancel := ax7TCPHub(t) + defer cancel() + server := New(Config{Addr: "127.0.0.1:0"}) + server.Mount(hub) + go func() { core.AssertNoError(t, server.Listen(ctx)) }() + addr := waitForListenerAddress(t, server) + + client := NewReconnectingTCP(ReconnectConfig{Addr: addr}) + go func() { core.AssertNoError(t, client.Connect(ctx)) }() + deadline := core.Now().Add(2 * core.Second) + for core.Now().Before(deadline) { + if client.State() == stream.StateConnected { + core.AssertEqual(t, stream.StateConnected, client.State()) + return + } + core.Sleep(10 * core.Millisecond) + } + t.Fatal("timed out waiting for connected state") +} + +func TestAX7_ReconnectingTCP_Connect_Bad(t *core.T) { + var client *ReconnectingTCP + + err := client.Connect(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil reconnecting tcp") +} + +func TestAX7_ReconnectingTCP_Connect_Ugly(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{Addr: "127.0.0.1:1", MaxRetries: 1, InitialBackoff: core.Millisecond}) + + err := client.Connect(core.Background()) + core.AssertError(t, err) + core.AssertEqual(t, stream.StateDisconnected, client.State()) +} + +func TestAX7_ReconnectingTCP_Send_Good(t *core.T) { + left, right := core.NetPipe() + defer left.Close() + defer right.Close() + client := NewReconnectingTCP(ReconnectConfig{}) + client.setConn(left) + done := make(chan error, 1) + + go func() { done <- client.Send("block", []byte("template")) }() + channel, frame, err := readTCPFrame(right, 0, MaxFrameSize) + core.AssertNoError(t, err) + core.AssertEqual(t, "block", channel) + core.AssertEqual(t, "template", string(frame)) + core.AssertNoError(t, <-done) +} + +func TestAX7_ReconnectingTCP_Send_Bad(t *core.T) { + var client *ReconnectingTCP + + err := client.Send("block", []byte("template")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil reconnecting tcp") +} + +func TestAX7_ReconnectingTCP_Send_Ugly(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{}) + + err := client.Send("block", []byte("template")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not connected") +} + +func TestAX7_ReconnectingTCP_State_Bad(t *core.T) { + var client *ReconnectingTCP + + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertNil(t, client) +} + +func TestAX7_ReconnectingTCP_State_Ugly(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{}) + client.setState(stream.StateConnecting) + + core.AssertEqual(t, stream.StateConnecting, client.State()) + client.setState(stream.StateDisconnected) + core.AssertEqual(t, stream.StateDisconnected, client.State()) +} + +func TestAX7_ReconnectingTCP_Close_Good(t *core.T) { + left, right := core.NetPipe() + defer right.Close() + client := NewReconnectingTCP(ReconnectConfig{}) + client.setConn(left) + + core.AssertNoError(t, client.Close()) + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertTrue(t, client.closed) +} + +func TestAX7_ReconnectingTCP_Close_Bad(t *core.T) { + var client *ReconnectingTCP + + core.AssertNoError(t, client.Close()) + core.AssertNil(t, client) +} + +func TestAX7_ReconnectingTCP_Close_Ugly(t *core.T) { + client := NewReconnectingTCP(ReconnectConfig{}) + + core.AssertNoError(t, client.Close()) + core.AssertNoError(t, client.Close()) + core.AssertEqual(t, stream.StateDisconnected, client.State()) +} diff --git a/adapter/tcp/reconnect.go b/adapter/tcp/reconnect.go index 176d95a..950ed8a 100644 --- a/adapter/tcp/reconnect.go +++ b/adapter/tcp/reconnect.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) @@ -102,7 +102,9 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { continue } if err := client.writeHandshake(conn); err != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + err = core.ErrorJoin(err, closeErr) + } attempt++ client.setState(stream.StateDisconnected) if client.config.MaxRetries > 0 && attempt > client.config.MaxRetries { @@ -120,7 +122,9 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { client.setConn(conn) stopClose := context.AfterFunc(ctx, func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) backoff = client.config.InitialBackoff attempt = 0 @@ -133,7 +137,9 @@ func (client *ReconnectingTCP) Connect(ctx context.Context) error { client.clearConn(conn) client.setState(stream.StateDisconnected) - _ = conn.Close() + if err := conn.Close(); err != nil && readErr == nil { + readErr = err + } if client.config.OnDisconnect != nil { client.config.OnDisconnect() } @@ -214,7 +220,9 @@ func (client *ReconnectingTCP) dial(ctx context.Context) (net.Conn, error) { } tlsConn := tls.Client(conn, client.config.TLS) if err := tlsConn.HandshakeContext(ctx); err != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + return nil, core.ErrorJoin(err, closeErr) + } return nil, err } return tlsConn, nil diff --git a/adapter/tcp/tcp.go b/adapter/tcp/tcp.go index d6685d3..c84a3d1 100644 --- a/adapter/tcp/tcp.go +++ b/adapter/tcp/tcp.go @@ -14,7 +14,7 @@ import ( "sync" "time" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) @@ -89,7 +89,9 @@ func (adapter *Adapter) Listen(ctx context.Context) error { return err } defer func() { - _ = listener.Close() + if err := listener.Close(); err != nil { + return + } adapter.mutex.Lock() if adapter.listener == listener { adapter.listener = nil @@ -99,7 +101,9 @@ func (adapter *Adapter) Listen(ctx context.Context) error { go func() { <-ctx.Done() - _ = listener.Close() + if err := listener.Close(); err != nil { + return + } }() for { @@ -136,24 +140,34 @@ func (adapter *Adapter) Dial(ctx context.Context, hub *stream.Hub) (*stream.Peer return nil, err } if err := adapter.writeHandshake(conn); err != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + return nil, core.ErrorJoin(err, closeErr) + } return nil, err } peer := stream.NewPeer("tcp") peer.SetCloseHook(func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) if !hub.Running() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return nil, err + } return nil, stream.ErrHubNotRunning } if err := hub.AddPeer(peer); err != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + return nil, core.ErrorJoin(err, closeErr) + } return nil, err } if err := hub.SubscribePeer(peer, "*"); err != nil { hub.RemovePeer(peer) - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + return nil, core.ErrorJoin(err, closeErr) + } return nil, err } go adapter.pipePeer(ctx, conn, peer, hub) @@ -194,7 +208,9 @@ func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { } tlsConn := tls.Client(conn, adapter.config.TLS) if err := tlsConn.HandshakeContext(ctx); err != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + return nil, core.ErrorJoin(err, closeErr) + } return nil, err } return tlsConn, nil @@ -205,7 +221,9 @@ func (adapter *Adapter) dial(ctx context.Context) (net.Conn, error) { func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stream.Hub) { defer conn.Close() stopClose := context.AfterFunc(ctx, func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) defer stopClose() @@ -232,7 +250,9 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre peer.Claims = authResult.Claims } peer.SetCloseHook(func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) if !hub.Running() { return @@ -249,7 +269,9 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) if auth := adapter.config.ConnAuthenticator; auth == nil { - dispatchTCPFrame(hub, peer, channel, frame) + if err := dispatchTCPFrame(hub, peer, channel, frame); err != nil { + return + } } for { @@ -258,25 +280,30 @@ func (adapter *Adapter) handleConn(ctx context.Context, conn net.Conn, hub *stre return } if channel == "" { - _ = hub.BroadcastFromPeer(peer, frame) + if err := hub.BroadcastFromPeer(peer, frame); err != nil { + return + } continue } - _ = hub.PublishFromPeer(peer, channel, frame) + if err := hub.PublishFromPeer(peer, channel, frame); err != nil { + return + } } } -func dispatchTCPFrame(hub *stream.Hub, peer *stream.Peer, channel string, frame []byte) { +func dispatchTCPFrame(hub *stream.Hub, peer *stream.Peer, channel string, frame []byte) error { if channel == "" { - _ = hub.BroadcastFromPeer(peer, frame) - return + return hub.BroadcastFromPeer(peer, frame) } - _ = hub.PublishFromPeer(peer, channel, frame) + return hub.PublishFromPeer(peer, channel, frame) } func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *stream.Peer, hub *stream.Hub) { defer conn.Close() stopClose := context.AfterFunc(ctx, func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) defer stopClose() go adapter.writePump(ctx, conn, peer, hub.Config().WriteTimeout) @@ -286,7 +313,10 @@ func (adapter *Adapter) pipePeer(ctx context.Context, conn net.Conn, peer *strea hub.RemovePeer(peer) return } - dispatchTCPFrame(hub, peer, channel, frame) + if err := dispatchTCPFrame(hub, peer, channel, frame); err != nil { + hub.RemovePeer(peer) + return + } } } @@ -300,7 +330,9 @@ func (adapter *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stre return } if writeTimeout > 0 { - _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return + } } if err := writeAll(conn, frame); err != nil { return @@ -311,9 +343,13 @@ func (adapter *Adapter) writePump(ctx context.Context, conn net.Conn, peer *stre func readTCPFrame(conn net.Conn, timeout time.Duration, maxFrameSize int) (string, []byte, error) { if timeout > 0 { - _ = conn.SetReadDeadline(time.Now().Add(timeout)) + if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return "", nil, err + } } else { - _ = conn.SetReadDeadline(time.Time{}) + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return "", nil, err + } } var length uint32 if err := binary.Read(conn, binary.BigEndian, &length); err != nil { diff --git a/adapter/tcp/tcp_test.go b/adapter/tcp/tcp_test.go index 35520b2..944bf9f 100644 --- a/adapter/tcp/tcp_test.go +++ b/adapter/tcp/tcp_test.go @@ -581,7 +581,7 @@ func TestTCP_Dial_Handshake_Good(t *testing.T) { } } -func TestReconnectingTCP_State_Good(t *testing.T) { +func TestAX7_ReconnectingTCP_State_Good(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("Listen() error = %v", err) diff --git a/adapter/ws/ax7_more_test.go b/adapter/ws/ax7_more_test.go new file mode 100644 index 0000000..fe20a5b --- /dev/null +++ b/adapter/ws/ax7_more_test.go @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws + +import ( + "github.com/alicebob/miniredis/v2" + "github.com/gorilla/websocket" + + core "dappco.re/go" + "dappco.re/go/stream" + adapterredis "dappco.re/go/stream/adapter/redis" +) + +func ax7WebSocketPair(t *core.T) (*websocket.Conn, *websocket.Conn, func()) { + upgrader := websocket.Upgrader{CheckOrigin: func(*core.Request) bool { return true }} + serverConn := make(chan *websocket.Conn, 1) + server := core.NewHTTPTestServer(core.HandlerFunc(func(w core.ResponseWriter, r *core.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("Upgrade() error = %v", err) + return + } + serverConn <- conn + })) + clientConn, _, err := websocket.DefaultDialer.Dial("ws"+server.URL[len("http"):], nil) + core.RequireNoError(t, err) + conn := <-serverConn + cleanup := func() { + clientConn.Close() + conn.Close() + server.Close() + } + return clientConn, conn, cleanup +} + +func TestAX7_New_Good(t *core.T) { + adapter := New(Config{ReadBufferSize: 2048, WriteBufferSize: 4096}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, 2048, adapter.config.ReadBufferSize) + core.AssertEqual(t, 4096, adapter.config.WriteBufferSize) +} + +func TestAX7_New_Bad(t *core.T) { + adapter := New(Config{}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, 1024, adapter.config.ReadBufferSize) + core.AssertEqual(t, 1024, adapter.config.WriteBufferSize) +} + +func TestAX7_New_Ugly(t *core.T) { + allowed := false + adapter := New(Config{CheckOrigin: func(*core.Request) bool { allowed = true; return true }}) + + core.AssertTrue(t, adapter.config.CheckOrigin(nil)) + core.AssertTrue(t, allowed) +} + +func TestAX7_Adapter_Mount_Good(t *core.T) { + adapter := New(Config{}) + hub := stream.NewHub() + + adapter.Mount(hub) + core.AssertEqual(t, hub, adapter.hub) + core.AssertNotNil(t, adapter.Handler()) +} + +func TestAX7_Adapter_Mount_Bad(t *core.T) { + adapter := New(Config{}) + + adapter.Mount(nil) + core.AssertNil(t, adapter.hub) + core.AssertNotNil(t, adapter) +} + +func TestAX7_Adapter_Mount_Ugly(t *core.T) { + adapter := New(Config{}) + first := stream.NewHub() + second := stream.NewHub() + + adapter.Mount(first) + adapter.Mount(second) + core.AssertEqual(t, second, adapter.hub) +} + +func TestAX7_Adapter_ServeHTTP_Bad(t *core.T) { + adapter := New(Config{}) + recorder := core.NewHTTPTestRecorder() + request := core.NewHTTPTestRequest("GET", "/stream/ws", nil) + + adapter.ServeHTTP(recorder, request) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not mounted") +} + +func TestAX7_Adapter_ServeHTTP_Ugly(t *core.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + recorder := core.NewHTTPTestRecorder() + request := core.NewHTTPTestRequest("GET", "/stream/ws?channel=hashrate", nil) + + adapter.ServeHTTP(recorder, request) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not running") +} + +func TestAX7_Adapter_HandlerForChannel_Bad(t *core.T) { + adapter := New(Config{}) + handler := adapter.HandlerForChannel("hashrate") + recorder := core.NewHTTPTestRecorder() + + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/ws", nil)) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not mounted") +} + +func TestAX7_Adapter_HandlerForChannel_Ugly(t *core.T) { + adapter := New(Config{}) + adapter.Mount(stream.NewHub()) + handler := adapter.HandlerForChannel("hashrate") + recorder := core.NewHTTPTestRecorder() + + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/ws", nil)) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not running") +} + +func TestAX7_DefaultHubConfig_Good(t *core.T) { + config := DefaultHubConfig() + + core.AssertEqual(t, 30*core.Second, config.HeartbeatInterval) + core.AssertEqual(t, 60*core.Second, config.PongTimeout) + core.AssertEqual(t, 10*core.Second, config.WriteTimeout) +} + +func TestAX7_DefaultHubConfig_Bad(t *core.T) { + config := DefaultHubConfig() + + core.AssertNil(t, config.OnConnect) + core.AssertNil(t, config.OnDisconnect) + core.AssertNil(t, config.ChannelAuthoriser) +} + +func TestAX7_DefaultHubConfig_Ugly(t *core.T) { + config := DefaultHubConfig() + + core.AssertGreater(t, config.PongTimeout, config.HeartbeatInterval) + core.AssertGreater(t, config.WriteTimeout, core.Duration(0)) +} + +func TestAX7_NewAPIKeyAuth_Good(t *core.T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk": "user"}) + + core.AssertNotNil(t, authenticator) + core.AssertEqual(t, "user", authenticator.Keys["sk"]) +} + +func TestAX7_NewAPIKeyAuth_Bad(t *core.T) { + authenticator := NewAPIKeyAuth(nil) + + core.AssertNotNil(t, authenticator) + core.AssertEqual(t, 0, len(authenticator.Keys)) +} + +func TestAX7_NewAPIKeyAuth_Ugly(t *core.T) { + keys := map[string]string{"sk": "user"} + authenticator := NewAPIKeyAuth(keys) + keys["sk"] = "mutated" + + core.AssertEqual(t, "user", authenticator.Keys["sk"]) + core.AssertEqual(t, "mutated", keys["sk"]) +} + +func TestAX7_NewHub_Good(t *core.T) { + hub := NewHub() + + core.AssertNotNil(t, hub) + core.AssertFalse(t, hub.Running()) + core.AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_NewHub_Bad(t *core.T) { + hub := NewHub() + + core.AssertEqual(t, 30*core.Second, hub.Config().HeartbeatInterval) + core.AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_NewHub_Ugly(t *core.T) { + left := NewHub() + right := NewHub() + + core.AssertNotEqual(t, left, right) + core.AssertNoError(t, left.AddPeer(stream.NewPeer("ws"))) +} + +func TestAX7_NewHubWithConfig_Good(t *core.T) { + hub := NewHubWithConfig(stream.HubConfig{HeartbeatInterval: core.Second, PongTimeout: 3 * core.Second}) + + core.AssertEqual(t, core.Second, hub.Config().HeartbeatInterval) + core.AssertEqual(t, 3*core.Second, hub.Config().PongTimeout) +} + +func TestAX7_NewHubWithConfig_Bad(t *core.T) { + hub := NewHubWithConfig(stream.HubConfig{}) + + core.AssertEqual(t, 30*core.Second, hub.Config().HeartbeatInterval) + core.AssertEqual(t, 60*core.Second, hub.Config().PongTimeout) +} + +func TestAX7_NewHubWithConfig_Ugly(t *core.T) { + called := false + hub := NewHubWithConfig(stream.HubConfig{OnConnect: func(*stream.Peer) { called = true }}) + + core.AssertNoError(t, hub.AddPeer(stream.NewPeer("ws"))) + core.AssertTrue(t, called) +} + +func TestAX7_NewPeer_Good(t *core.T) { + peer := NewPeer("ws") + + core.AssertNotNil(t, peer) + core.AssertEqual(t, "ws", peer.Transport) + core.AssertNotEmpty(t, peer.ID) +} + +func TestAX7_NewPeer_Bad(t *core.T) { + peer := NewPeer("") + + core.AssertNotNil(t, peer) + core.AssertEqual(t, "", peer.Transport) + core.AssertNotNil(t, peer.SendQueue()) +} + +func TestAX7_NewPeer_Ugly(t *core.T) { + left := NewPeer("ws") + right := NewPeer("ws") + + core.AssertNotEqual(t, left.ID, right.ID) + core.AssertEqual(t, "ws", right.Transport) +} + +func TestAX7_Pipe_Good(t *core.T) { + source := stream.NewHub() + destination := stream.NewHub() + + stop := Pipe(source, destination) + core.AssertNotNil(t, stop) + stop() +} + +func TestAX7_Pipe_Bad(t *core.T) { + stop := Pipe(nil, stream.NewHub()) + + core.AssertNotNil(t, stop) + core.AssertNotPanics(t, stop) +} + +func TestAX7_Pipe_Ugly(t *core.T) { + hub := stream.NewHub() + stop := Pipe(hub, hub) + + core.AssertNotNil(t, stop) + core.AssertNotPanics(t, stop) +} + +func TestAX7_NewRedisBridge_Good(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewRedisBridge(stream.NewHub(), adapterredis.Config{Addr: redisServer.Addr(), Prefix: "pool"}) + + core.AssertNoError(t, err) + core.AssertNotNil(t, bridge) + core.AssertNotEmpty(t, bridge.SourceID()) +} + +func TestAX7_NewRedisBridge_Bad(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewRedisBridge(nil, adapterredis.Config{Addr: redisServer.Addr(), Prefix: "pool"}) + + core.AssertError(t, err) + core.AssertNil(t, bridge) +} + +func TestAX7_NewRedisBridge_Ugly(t *core.T) { + bridge, err := NewRedisBridge(stream.NewHub(), adapterredis.Config{}) + + core.AssertError(t, err) + core.AssertNil(t, bridge) +} + +func TestAX7_NewReconnectingClient_Good(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{URL: "ws://127.0.0.1/stream/ws"}) + + core.AssertNotNil(t, client) + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertNoError(t, client.Close()) +} + +func TestAX7_NewReconnectingClient_Bad(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{}) + + core.AssertEqual(t, 500*core.Millisecond, client.config.InitialBackoff) + core.AssertEqual(t, 30*core.Second, client.config.MaxBackoff) + core.AssertEqual(t, 2.0, client.config.BackoffMultiplier) +} + +func TestAX7_NewReconnectingClient_Ugly(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{InitialBackoff: core.Millisecond, MaxBackoff: core.Second, BackoffMultiplier: 3}) + + core.AssertEqual(t, core.Millisecond, client.config.InitialBackoff) + core.AssertEqual(t, core.Second, client.config.MaxBackoff) + core.AssertEqual(t, 3.0, client.config.BackoffMultiplier) +} + +func TestAX7_ReconnectingClient_Connect_Good(t *core.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*core.Request) bool { return true }} + connected := make(chan struct{}, 1) + server := core.NewHTTPTestServer(core.HandlerFunc(func(w core.ResponseWriter, r *core.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err == nil { + defer conn.Close() + <-r.Context().Done() + } + })) + defer server.Close() + ctx, cancel := core.WithCancel(core.Background()) + defer cancel() + client := NewReconnectingClient(ReconnectConfig{ + URL: "ws" + server.URL[len("http"):], + OnConnect: func() { connected <- struct{}{} }, + }) + errs := make(chan error, 1) + + go func() { errs <- client.Connect(ctx) }() + <-connected + core.AssertEqual(t, stream.StateConnected, client.State()) + core.AssertNoError(t, client.Close()) + cancel() + core.AssertNoError(t, <-errs) +} + +func TestAX7_ReconnectingClient_Connect_Bad(t *core.T) { + var client *ReconnectingClient + + err := client.Connect(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil reconnecting client") +} + +func TestAX7_ReconnectingClient_Connect_Ugly(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{URL: "://bad-url", MaxRetries: 1, InitialBackoff: core.Millisecond}) + + err := client.Connect(core.Background()) + core.AssertError(t, err) + core.AssertEqual(t, stream.StateDisconnected, client.State()) +} + +func TestAX7_ReconnectingClient_Send_Good(t *core.T) { + clientConn, serverConn, cleanup := ax7WebSocketPair(t) + defer cleanup() + client := NewReconnectingClient(ReconnectConfig{}) + client.mutex.Lock() + client.conn = clientConn + client.state = stream.StateConnected + client.mutex.Unlock() + + core.AssertNoError(t, client.Send(stream.Message{Type: stream.TypePing, Channel: "health"})) + _, payload, err := serverConn.ReadMessage() + core.AssertNoError(t, err) + core.AssertContains(t, string(payload), `"type":"ping"`) +} + +func TestAX7_ReconnectingClient_Send_Bad(t *core.T) { + var client *ReconnectingClient + + err := client.Send(stream.Message{Type: stream.TypePing}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nil reconnecting client") +} + +func TestAX7_ReconnectingClient_Send_Ugly(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{}) + + err := client.Send(stream.Message{Type: stream.TypePing}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not connected") +} + +func TestAX7_ReconnectingClient_State_Good(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{}) + client.mutex.Lock() + client.state = stream.StateConnected + client.mutex.Unlock() + + core.AssertEqual(t, stream.StateConnected, client.State()) + core.AssertNoError(t, client.Close()) +} + +func TestAX7_ReconnectingClient_State_Bad(t *core.T) { + var client *ReconnectingClient + + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertNil(t, client) +} + +func TestAX7_ReconnectingClient_State_Ugly(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{}) + + core.AssertNoError(t, client.Close()) + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertTrue(t, client.closed) +} + +func TestAX7_ReconnectingClient_Close_Good(t *core.T) { + clientConn, _, cleanup := ax7WebSocketPair(t) + defer cleanup() + client := NewReconnectingClient(ReconnectConfig{}) + client.mutex.Lock() + client.conn = clientConn + client.state = stream.StateConnected + client.mutex.Unlock() + + core.AssertNoError(t, client.Close()) + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertTrue(t, client.closed) +} + +func TestAX7_ReconnectingClient_Close_Bad(t *core.T) { + var client *ReconnectingClient + + core.AssertNoError(t, client.Close()) + core.AssertNil(t, client) +} + +func TestAX7_ReconnectingClient_Close_Ugly(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{}) + + core.AssertNoError(t, client.Close()) + core.AssertNoError(t, client.Close()) + core.AssertEqual(t, stream.StateDisconnected, client.State()) +} diff --git a/adapter/ws/reconnect.go b/adapter/ws/reconnect.go index 8282af2..48fb785 100644 --- a/adapter/ws/reconnect.go +++ b/adapter/ws/reconnect.go @@ -10,7 +10,7 @@ import ( "github.com/gorilla/websocket" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) @@ -108,7 +108,9 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { client.state = stream.StateConnected client.mutex.Unlock() stopClose := context.AfterFunc(ctx, func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) backoff = client.config.InitialBackoff attempt = 0 @@ -125,7 +127,9 @@ func (client *ReconnectingClient) Connect(ctx context.Context) error { } client.state = stream.StateDisconnected client.mutex.Unlock() - _ = conn.Close() + if err := conn.Close(); err != nil && readErr == nil { + readErr = err + } if client.config.OnDisconnect != nil { client.config.OnDisconnect() } diff --git a/adapter/ws/ws.go b/adapter/ws/ws.go index c0087d6..b0bd62b 100644 --- a/adapter/ws/ws.go +++ b/adapter/ws/ws.go @@ -12,7 +12,7 @@ import ( "github.com/gorilla/websocket" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) @@ -134,14 +134,18 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } if err := adapter.hub.AddPeer(peer); err != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + return + } http.Error(w, "stream hub not running", http.StatusInternalServerError) return } defer adapter.hub.RemovePeer(peer) peer.SetCloseHook(func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) for _, channel := range channels { if channel == "" { @@ -154,13 +158,18 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe } defer conn.Close() stopClose := context.AfterFunc(r.Context(), func() { - _ = conn.Close() + if err := conn.Close(); err != nil { + return + } }) defer stopClose() hubConfig := adapter.hub.Config() if hubConfig.PongTimeout > 0 { - _ = conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) + if err := conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)); err != nil { + peer.Close() + return + } conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(hubConfig.PongTimeout)) }) @@ -184,28 +193,36 @@ func (adapter *Adapter) serveHTTP(w http.ResponseWriter, r *http.Request, channe switch message.Type { case stream.TypeSubscribe: if err := adapter.hub.SubscribePeer(peer, message.Channel); err != nil { - _ = peer.Send(marshalMessage(stream.Message{ + if ok := peer.Send(marshalMessage(stream.Message{ Type: stream.TypeError, Channel: message.Channel, Data: errorPayload(err), Timestamp: time.Now().UTC(), - })) + })); !ok { + return + } } case stream.TypeUnsubscribe: adapter.hub.UnsubscribePeer(peer, message.Channel) case stream.TypePing: - _ = peer.Send([]byte(core.JSONMarshalString(stream.Message{ + if ok := peer.Send([]byte(core.JSONMarshalString(stream.Message{ Type: stream.TypePong, Channel: message.Channel, ProcessID: message.ProcessID, Timestamp: time.Now().UTC(), - }))) + }))); !ok { + return + } default: if message.Channel == "" { - _ = adapter.hub.BroadcastFromPeer(peer, payload) + if err := adapter.hub.BroadcastFromPeer(peer, payload); err != nil { + return + } continue } - _ = adapter.hub.PublishFromPeer(peer, message.Channel, payload) + if err := adapter.hub.PublishFromPeer(peer, message.Channel, payload); err != nil { + return + } } } @@ -230,7 +247,9 @@ func (adapter *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, write select { case <-heartbeat: if writeTimeout > 0 { - _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return + } } if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { return @@ -240,7 +259,9 @@ func (adapter *Adapter) writePump(conn *websocket.Conn, peer *stream.Peer, write return } if writeTimeout > 0 { - _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return + } } if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil { return diff --git a/adapter/ws/ws_test.go b/adapter/ws/ws_test.go index a5d1663..9e5be87 100644 --- a/adapter/ws/ws_test.go +++ b/adapter/ws/ws_test.go @@ -13,11 +13,11 @@ import ( "github.com/gorilla/websocket" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) -func TestAdapter_Handler_Good(t *testing.T) { +func TestAX7_Adapter_Handler_Good(t *testing.T) { hub := stream.NewHubWithConfig(stream.HubConfig{ HeartbeatInterval: 20 * time.Millisecond, PongTimeout: 100 * time.Millisecond, @@ -97,7 +97,7 @@ func TestAdapter_Handler_Good(t *testing.T) { } } -func TestAdapter_Handler_Bad(t *testing.T) { +func TestAX7_Adapter_Handler_Bad(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -205,7 +205,7 @@ func TestAdapter_Handler_QueryChannelAuthoriser_Bad(t *testing.T) { waitForPeerCount(t, hub, 0) } -func TestAdapter_Handler_Ugly(t *testing.T) { +func TestAX7_Adapter_Handler_Ugly(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -232,7 +232,7 @@ func TestAdapter_Handler_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } -func TestAdapter_ServeHTTP_Good(t *testing.T) { +func TestAX7_Adapter_ServeHTTP_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -272,7 +272,7 @@ func TestAdapter_ServeHTTP_Good(t *testing.T) { } } -func TestAdapter_HandlerForChannel_Good(t *testing.T) { +func TestAX7_Adapter_HandlerForChannel_Good(t *testing.T) { hub := stream.NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() diff --git a/adapter/zmq/ax7_more_test.go b/adapter/zmq/ax7_more_test.go new file mode 100644 index 0000000..f24f59c --- /dev/null +++ b/adapter/zmq/ax7_more_test.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package zmq + +import ( + core "dappco.re/go" + "dappco.re/go/stream" +) + +func TestAX7_Mode_String_Good(t *core.T) { + core.AssertEqual(t, "pubsub", ModePubSub.String()) + core.AssertEqual(t, "pushpull", ModePushPull.String()) + core.AssertNotEqual(t, ModePubSub.String(), ModePushPull.String()) +} + +func TestAX7_Mode_String_Bad(t *core.T) { + mode := Mode(99) + + core.AssertEqual(t, "unknown", mode.String()) + core.AssertNotEqual(t, "pubsub", mode.String()) +} + +func TestAX7_Mode_String_Ugly(t *core.T) { + mode := Mode(-1) + + core.AssertEqual(t, "unknown", mode.String()) + core.AssertNotPanics(t, func() { _ = mode.String() }) +} + +func TestAX7_Role_String_Good(t *core.T) { + core.AssertEqual(t, "publisher", RolePublisher.String()) + core.AssertEqual(t, "subscriber", RoleSubscriber.String()) + core.AssertEqual(t, "pusher", RolePusher.String()) + core.AssertEqual(t, "puller", RolePuller.String()) +} + +func TestAX7_Role_String_Bad(t *core.T) { + role := Role(99) + + core.AssertEqual(t, "unknown", role.String()) + core.AssertNotEqual(t, "publisher", role.String()) +} + +func TestAX7_Role_String_Ugly(t *core.T) { + role := Role(-1) + + core.AssertEqual(t, "unknown", role.String()) + core.AssertNotPanics(t, func() { _ = role.String() }) +} + +func TestAX7_New_Good(t *core.T) { + adapter := New(Config{Mode: ModePubSub, Endpoint: "tcp://127.0.0.1:1", Role: RolePublisher}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, ModePubSub, adapter.config.Mode) + core.AssertEqual(t, 5*core.Second, adapter.config.HandshakeTimeout) +} + +func TestAX7_New_Bad(t *core.T) { + adapter := New(Config{}) + + core.AssertNotNil(t, adapter) + core.AssertEqual(t, ModePubSub, adapter.config.Mode) + core.AssertEqual(t, "", adapter.config.Endpoint) +} + +func TestAX7_New_Ugly(t *core.T) { + adapter := New(Config{HandshakeTimeout: core.Millisecond, Topics: []string{"block"}}) + + core.AssertEqual(t, core.Millisecond, adapter.config.HandshakeTimeout) + core.AssertEqual(t, []string{"block"}, adapter.config.Topics) +} + +func TestAX7_Adapter_Mount_Good(t *core.T) { + adapter := New(Config{}) + hub := stream.NewHub() + + adapter.Mount(hub) + core.AssertEqual(t, hub, adapter.hub) + core.AssertFalse(t, adapter.running) +} + +func TestAX7_Adapter_Mount_Bad(t *core.T) { + adapter := New(Config{}) + + adapter.Mount(nil) + core.AssertNil(t, adapter.hub) + core.AssertNotNil(t, adapter) +} + +func TestAX7_Adapter_Mount_Ugly(t *core.T) { + adapter := New(Config{}) + first := stream.NewHub() + second := stream.NewHub() + + adapter.Mount(first) + adapter.Mount(second) + core.AssertEqual(t, second, adapter.hub) +} + +func TestAX7_Adapter_Start_Good(t *core.T) { + hub := stream.NewHub() + ctx, cancel := core.WithCancel(core.Background()) + defer cancel() + go hub.Run(ctx) + adapter := New(Config{Mode: ModePubSub, Endpoint: randomTCPEndpoint(t), Role: RolePublisher}) + adapter.Mount(hub) + + go func() { + if err := adapter.Start(ctx); err != nil { + t.Errorf("Start() error = %v", err) + } + }() + waitForAdapterRunning(t, adapter) + core.AssertTrue(t, adapter.running) +} + +func TestAX7_Adapter_Start_Bad(t *core.T) { + adapter := New(Config{Mode: Mode(99), Endpoint: randomTCPEndpoint(t), Role: RolePublisher}) + adapter.Mount(stream.NewHub()) + + err := adapter.Start(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid mode") +} + +func TestAX7_Adapter_Stop_Good(t *core.T) { + hub := stream.NewHub() + ctx, cancel := core.WithCancel(core.Background()) + defer cancel() + go hub.Run(ctx) + adapter := New(Config{Mode: ModePubSub, Endpoint: randomTCPEndpoint(t), Role: RolePublisher}) + adapter.Mount(hub) + go func() { _ = adapter.Start(ctx) }() + waitForAdapterRunning(t, adapter) + + core.AssertNoError(t, adapter.Stop()) + core.Sleep(50 * core.Millisecond) + core.AssertFalse(t, adapter.running) +} + +func TestAX7_Adapter_Stop_Bad(t *core.T) { + var adapter *Adapter + + core.AssertNoError(t, adapter.Stop()) + core.AssertNil(t, adapter) +} + +func TestAX7_Adapter_Stop_Ugly(t *core.T) { + adapter := New(Config{Mode: ModePubSub, Endpoint: randomTCPEndpoint(t), Role: RolePublisher}) + + core.AssertNoError(t, adapter.Stop()) + core.AssertFalse(t, adapter.running) +} + +func TestAX7_Adapter_Publish_Ugly(t *core.T) { + adapter := New(Config{Mode: ModePubSub, Endpoint: randomTCPEndpoint(t), Role: RolePublisher}) + adapter.Mount(stream.NewHub()) + + err := adapter.Publish("block", []byte("template")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not started") +} diff --git a/adapter/zmq/zmq.go b/adapter/zmq/zmq.go index 3f09cee..e2a610d 100644 --- a/adapter/zmq/zmq.go +++ b/adapter/zmq/zmq.go @@ -20,7 +20,7 @@ import ( "github.com/go-zeromq/zmq4" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) @@ -141,7 +141,10 @@ func (adapter *Adapter) Start(ctx context.Context) error { return err } if err := adapter.connectSocket(socket); err != nil { - _ = socket.Close() + if closeErr := socket.Close(); closeErr != nil { + runCancel() + return core.ErrorJoin(err, closeErr) + } runCancel() return err } @@ -149,7 +152,10 @@ func (adapter *Adapter) Start(ctx context.Context) error { adapter.mutex.Lock() if adapter.running { adapter.mutex.Unlock() - _ = socket.Close() + if err := socket.Close(); err != nil { + runCancel() + return err + } runCancel() return nil } @@ -165,7 +171,9 @@ func (adapter *Adapter) Start(ctx context.Context) error { adapter.cancel = nil adapter.mutex.Unlock() runCancel() - _ = socket.Close() + if err := socket.Close(); err != nil { + return + } }() if !adapter.isReceiver() { @@ -213,10 +221,14 @@ func (adapter *Adapter) Start(ctx context.Context) error { continue } if channel == "" { - _ = adapter.hub.Broadcast(frame) + if err := adapter.hub.Broadcast(frame); err != nil { + return err + } continue } - _ = adapter.hub.Publish(channel, frame) + if err := adapter.hub.Publish(channel, frame); err != nil { + return err + } } } @@ -231,7 +243,9 @@ func (adapter *Adapter) registerPeer(socket zmq4.Socket, authResult stream.AuthR } if socket != nil { peer.SetCloseHook(func() { - _ = socket.Close() + if err := socket.Close(); err != nil { + return + } }) } if err := adapter.hub.AddPeer(peer); err != nil { @@ -379,12 +393,16 @@ func (adapter *Adapter) recvWithTimeout(ctx context.Context, socket zmq4.Socket, select { case <-ctx.Done(): - _ = socket.Close() + if err := socket.Close(); err != nil { + return zmq4.Msg{}, err + } return zmq4.Msg{}, ctx.Err() case outcome := <-receive: return outcome.message, outcome.err case <-timer.C: - _ = socket.Close() + if err := socket.Close(); err != nil { + return zmq4.Msg{}, err + } return zmq4.Msg{}, stream.ErrHandshakeTimeout } } diff --git a/adapter/zmq/zmq_test.go b/adapter/zmq/zmq_test.go index 8d5a7b8..c4f97e0 100644 --- a/adapter/zmq/zmq_test.go +++ b/adapter/zmq/zmq_test.go @@ -12,7 +12,7 @@ import ( "dappco.re/go/stream" ) -func TestAdapter_Publish_Good(t *testing.T) { +func TestAX7_Adapter_Publish_Good(t *testing.T) { publisherHub := stream.NewHub() subscriberHub := stream.NewHub() @@ -70,7 +70,7 @@ func TestAdapter_Publish_Good(t *testing.T) { t.Fatal("timed out waiting for zmq frame") } -func TestAdapter_Publish_Bad(t *testing.T) { +func TestAX7_Adapter_Publish_Bad(t *testing.T) { hub := stream.NewHub() adapter := New(Config{ Mode: ModePubSub, @@ -84,7 +84,7 @@ func TestAdapter_Publish_Bad(t *testing.T) { } } -func TestAdapter_Start_Ugly(t *testing.T) { +func TestAX7_Adapter_Start_Ugly(t *testing.T) { pusherHub := stream.NewHub() pullerHub := stream.NewHub() diff --git a/auth.go b/auth.go index bb7c850..7a565aa 100644 --- a/auth.go +++ b/auth.go @@ -9,7 +9,7 @@ package stream import ( "net/http" - "dappco.re/go/core" + "dappco.re/go" ) // auth := stream.AuthenticatorFunc(func(request *http.Request) stream.AuthResult { diff --git a/ax7_more_test.go b/ax7_more_test.go new file mode 100644 index 0000000..38109cc --- /dev/null +++ b/ax7_more_test.go @@ -0,0 +1,893 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream + +import core "dappco.re/go" + +type T = core.T +type CancelFunc = core.CancelFunc +type Duration = core.Duration +type Request = core.Request + +const ( + Millisecond = core.Millisecond + Second = core.Second +) + +var ( + Background = core.Background + NewHTTPTestRequest = core.NewHTTPTestRequest + Sleep = core.Sleep + WithCancel = core.WithCancel + WithTimeout = core.WithTimeout + + AssertContains = core.AssertContains + AssertEqual = core.AssertEqual + AssertError = core.AssertError + AssertFalse = core.AssertFalse + AssertNil = core.AssertNil + AssertNoError = core.AssertNoError + AssertNotEqual = core.AssertNotEqual + AssertNotNil = core.AssertNotNil + AssertNotPanics = core.AssertNotPanics + AssertTrue = core.AssertTrue +) + +func ax7RunningHub(t *T) (*Hub, CancelFunc) { + hub := NewHub() + ctx, cancel := WithCancel(Background()) + go hub.Run(ctx) + waitForRunningHub(t, hub) + return hub, cancel +} + +func ax7Timeout(duration Duration) <-chan struct{} { + ctx, cancel := WithTimeout(Background(), duration) + done := make(chan struct{}) + go func() { + defer cancel() + <-ctx.Done() + close(done) + }() + return done +} + +func TestAX7_APIKeyAuthenticator_Authenticate_Good(t *T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer sk-live") + + result := authenticator.Authenticate(request) + AssertTrue(t, result.Valid) + AssertEqual(t, "user-42", result.UserID) + AssertNotNil(t, result.Claims) +} + +func TestAX7_APIKeyAuthenticator_Authenticate_Bad(t *T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertEqual(t, ErrMissingAuthHeader, result.Error) +} + +func TestAX7_APIKeyAuthenticator_Authenticate_Ugly(t *T) { + var authenticator *APIKeyAuthenticator + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer sk-live") + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertEqual(t, "", result.UserID) +} + +func TestAX7_AuthenticatorFunc_Authenticate_Good(t *T) { + authenticator := AuthenticatorFunc(func(request *Request) AuthResult { + return AuthResult{Valid: request.URL.Path == "/stream/ws", UserID: "agent"} + }) + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + + result := authenticator.Authenticate(request) + AssertTrue(t, result.Valid) + AssertEqual(t, "agent", result.UserID) + AssertNotNil(t, result.Claims) +} + +func TestAX7_AuthenticatorFunc_Authenticate_Bad(t *T) { + authenticator := AuthenticatorFunc(func(request *Request) AuthResult { + return AuthResult{Valid: false, Error: ErrAuthRejected} + }) + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertEqual(t, ErrAuthRejected, result.Error) +} + +func TestAX7_AuthenticatorFunc_Authenticate_Ugly(t *T) { + var authenticator AuthenticatorFunc + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertNil(t, result.Claims) +} + +func TestAX7_BearerTokenAuth_Authenticate_Good(t *T) { + authenticator := &BearerTokenAuth{Validate: func(token string) AuthResult { + return AuthResult{Valid: token == "jwt-valid", UserID: "user-99"} + }} + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer jwt-valid") + + result := authenticator.Authenticate(request) + AssertTrue(t, result.Valid) + AssertEqual(t, "user-99", result.UserID) +} + +func TestAX7_BearerTokenAuth_Authenticate_Bad(t *T) { + authenticator := &BearerTokenAuth{Validate: func(token string) AuthResult { + return AuthResult{Valid: false, Error: ErrAuthRejected} + }} + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer jwt-invalid") + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertEqual(t, ErrAuthRejected, result.Error) +} + +func TestAX7_BearerTokenAuth_Authenticate_Ugly(t *T) { + authenticator := &BearerTokenAuth{} + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + request.Header.Set("Authorization", "Bearer jwt-valid") + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertEqual(t, "", result.UserID) +} + +func TestAX7_ConnAuthenticatorFunc_AuthenticateConn_Good(t *T) { + authenticator := ConnAuthenticatorFunc(func(handshake []byte) AuthResult { + return AuthResult{Valid: string(handshake) == "hello", UserID: "peer-1"} + }) + result := authenticator.AuthenticateConn([]byte("hello")) + + AssertTrue(t, result.Valid) + AssertEqual(t, "peer-1", result.UserID) + AssertNotNil(t, result.Claims) +} + +func TestAX7_ConnAuthenticatorFunc_AuthenticateConn_Bad(t *T) { + authenticator := ConnAuthenticatorFunc(func(handshake []byte) AuthResult { + return AuthResult{Valid: false, Error: ErrAuthRejected} + }) + result := authenticator.AuthenticateConn([]byte("bad")) + + AssertFalse(t, result.Valid) + AssertEqual(t, ErrAuthRejected, result.Error) + AssertNil(t, result.Claims) +} + +func TestAX7_ConnAuthenticatorFunc_AuthenticateConn_Ugly(t *T) { + var authenticator ConnAuthenticatorFunc + result := authenticator.AuthenticateConn(nil) + + AssertFalse(t, result.Valid) + AssertEqual(t, "", result.UserID) + AssertNil(t, result.Claims) +} + +func TestAX7_DefaultHubConfig_Good(t *T) { + config := DefaultHubConfig() + + AssertEqual(t, 30*Second, config.HeartbeatInterval) + AssertEqual(t, 60*Second, config.PongTimeout) + AssertEqual(t, 10*Second, config.WriteTimeout) +} + +func TestAX7_DefaultHubConfig_Bad(t *T) { + config := DefaultHubConfig() + + AssertNil(t, config.OnConnect) + AssertNil(t, config.OnDisconnect) + AssertNil(t, config.ChannelAuthoriser) +} + +func TestAX7_DefaultHubConfig_Ugly(t *T) { + config := normalizeHubConfig(HubConfig{HeartbeatInterval: Second, PongTimeout: Millisecond}) + + AssertEqual(t, Second, config.HeartbeatInterval) + AssertEqual(t, 2*Second, config.PongTimeout) + AssertEqual(t, 10*Second, config.WriteTimeout) +} + +func TestAX7_Hub_AddPeer_Good(t *T) { + hub := NewHub() + peer := NewPeer("ws") + + AssertNoError(t, hub.AddPeer(peer)) + AssertEqual(t, 1, hub.PeerCount()) + AssertEqual(t, 0, len(peer.Subscriptions())) +} + +func TestAX7_Hub_AddPeer_Bad(t *T) { + hub := NewHub() + + err := hub.AddPeer(nil) + AssertError(t, err) + AssertContains(t, err.Error(), "nil peer") +} + +func TestAX7_Hub_AddPeer_Ugly(t *T) { + hub := NewHub() + peer := &Peer{Transport: "ws"} + + AssertNoError(t, hub.AddPeer(peer)) + AssertNotNil(t, peer.SendQueue()) + AssertEqual(t, 1, hub.PeerCount()) +} + +func TestAX7_Hub_AllChannels_Good(t *T) { + hub := NewHub() + stopA := hub.Subscribe("block", func([]byte) {}) + defer stopA() + stopB := hub.Subscribe("hashrate", func([]byte) {}) + defer stopB() + + var channels []string + for channel := range hub.AllChannels() { + channels = append(channels, channel) + } + AssertEqual(t, []string{"block", "hashrate"}, channels) +} + +func TestAX7_Hub_AllChannels_Bad(t *T) { + var hub *Hub + count := 0 + + for range hub.AllChannels() { + count++ + } + AssertEqual(t, 0, count) +} + +func TestAX7_Hub_AllChannels_Ugly(t *T) { + hub := NewHub() + stop := hub.Subscribe("events", func([]byte) {}) + seq := hub.AllChannels() + stop() + + var channels []string + for channel := range seq { + channels = append(channels, channel) + } + AssertEqual(t, []string{"events"}, channels) +} + +func TestAX7_Hub_AllPeers_Good(t *T) { + hub := NewHub() + AssertNoError(t, hub.AddPeer(NewPeer("ws"))) + AssertNoError(t, hub.AddPeer(NewPeer("sse"))) + + count := 0 + for range hub.AllPeers() { + count++ + } + AssertEqual(t, 2, count) +} + +func TestAX7_Hub_AllPeers_Bad(t *T) { + var hub *Hub + count := 0 + + for range hub.AllPeers() { + count++ + } + AssertEqual(t, 0, count) +} + +func TestAX7_Hub_AllPeers_Ugly(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + seq := hub.AllPeers() + hub.RemovePeer(peer) + + count := 0 + for range seq { + count++ + } + AssertEqual(t, 1, count) +} + +func TestAX7_Hub_BroadcastFromBridge_Good(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + waitForPeerCount(t, hub, 1) + + AssertNoError(t, hub.BroadcastFromBridge([]byte("bridge"))) + frame := <-peer.SendQueue() + AssertEqual(t, "bridge", string(frame)) +} + +func TestAX7_Hub_BroadcastFromBridge_Bad(t *T) { + hub := NewHub() + + err := hub.BroadcastFromBridge([]byte("bridge")) + AssertEqual(t, ErrHubNotRunning, err) + AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_Hub_BroadcastFromBridge_Ugly(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + seen := false + stop := hub.SubscribeBroadcast(func([]byte) { seen = true }) + defer stop() + + AssertNoError(t, hub.BroadcastFromBridge([]byte("bridge"))) + Sleep(20 * Millisecond) + AssertFalse(t, seen) +} + +func TestAX7_Hub_BroadcastFromPeer_Bad(t *T) { + hub := NewHub() + peer := NewPeer("ws") + + err := hub.BroadcastFromPeer(peer, []byte("frame")) + AssertEqual(t, ErrHubNotRunning, err) + AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_Hub_BroadcastFromPeer_Ugly(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + source := NewPeer("ws") + receiver := NewPeer("ws") + AssertNoError(t, hub.AddPeer(source)) + AssertNoError(t, hub.AddPeer(receiver)) + waitForPeerCount(t, hub, 2) + + AssertNoError(t, hub.BroadcastFromPeer(source, []byte("fanout"))) + AssertEqual(t, "fanout", string(<-receiver.SendQueue())) +} + +func TestAX7_Hub_CanSubscribePeer_Good(t *T) { + hub := NewHubWithConfig(HubConfig{ChannelAuthoriser: func(peer *Peer, channel string) bool { + return peer.UserID == "agent" && channel == "private" + }}) + peer := NewPeer("ws") + peer.UserID = "agent" + + AssertNoError(t, hub.CanSubscribePeer(peer, "private")) + AssertNoError(t, hub.CanSubscribePeer(peer, "*")) +} + +func TestAX7_Hub_CanSubscribePeer_Ugly(t *T) { + hub := NewHub() + + err := hub.CanSubscribePeer(NewPeer("ws"), "") + AssertEqual(t, ErrEmptyChannel, err) + AssertError(t, err) +} + +func TestAX7_Hub_ChannelCount_Good(t *T) { + hub := NewHub() + stop := hub.Subscribe("events", func([]byte) {}) + defer stop() + + AssertEqual(t, 1, hub.ChannelCount()) + AssertEqual(t, 1, hub.ChannelSubscriberCount("events")) +} + +func TestAX7_Hub_ChannelCount_Bad(t *T) { + var hub *Hub + + AssertEqual(t, 0, hub.ChannelCount()) + AssertEqual(t, 0, hub.ChannelSubscriberCount("missing")) +} + +func TestAX7_Hub_ChannelCount_Ugly(t *T) { + hub := NewHub() + stop := hub.Subscribe("*", func([]byte) {}) + defer stop() + + AssertEqual(t, 0, hub.ChannelCount()) + AssertEqual(t, 1, hub.ChannelSubscriberCount("*")) +} + +func TestAX7_Hub_ChannelSubscriberCount_Good(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + AssertNoError(t, hub.SubscribePeer(peer, "hashrate")) + stop := hub.Subscribe("hashrate", func([]byte) {}) + defer stop() + + AssertEqual(t, 2, hub.ChannelSubscriberCount("hashrate")) + AssertEqual(t, 1, hub.PeerCount()) +} + +func TestAX7_Hub_ChannelSubscriberCount_Bad(t *T) { + hub := NewHub() + + AssertEqual(t, 0, hub.ChannelSubscriberCount("missing")) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_Hub_ChannelSubscriberCount_Ugly(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + AssertNoError(t, hub.SubscribePeer(peer, "*")) + + AssertEqual(t, 1, hub.ChannelSubscriberCount("*")) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_Hub_Config_Good(t *T) { + hub := NewHubWithConfig(HubConfig{HeartbeatInterval: Second, PongTimeout: 3 * Second}) + + config := hub.Config() + AssertEqual(t, Second, config.HeartbeatInterval) + AssertEqual(t, 3*Second, config.PongTimeout) +} + +func TestAX7_Hub_Config_Bad(t *T) { + var hub *Hub + + config := hub.Config() + AssertEqual(t, 30*Second, config.HeartbeatInterval) + AssertEqual(t, 60*Second, config.PongTimeout) +} + +func TestAX7_Hub_Config_Ugly(t *T) { + hub := NewHubWithConfig(HubConfig{HeartbeatInterval: Second, PongTimeout: Second}) + + config := hub.Config() + AssertEqual(t, Second, config.HeartbeatInterval) + AssertEqual(t, 2*Second, config.PongTimeout) +} + +func TestAX7_Hub_PeerCount_Good(t *T) { + hub := NewHub() + AssertNoError(t, hub.AddPeer(NewPeer("ws"))) + + AssertEqual(t, 1, hub.PeerCount()) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_Hub_PeerCount_Bad(t *T) { + var hub *Hub + + AssertEqual(t, 0, hub.PeerCount()) + AssertEqual(t, HubStats{}, hub.Stats()) +} + +func TestAX7_Hub_PeerCount_Ugly(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + hub.RemovePeer(peer) + + AssertEqual(t, 0, hub.PeerCount()) + AssertEqual(t, []string{}, peer.Subscriptions()) +} + +func TestAX7_Hub_PublishFromBridge_Good(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + received := make(chan []byte, 1) + stop := hub.Subscribe("block", func(frame []byte) { received <- append([]byte(nil), frame...) }) + defer stop() + + AssertNoError(t, hub.PublishFromBridge("block", []byte("template"))) + AssertEqual(t, "template", string(<-received)) +} + +func TestAX7_Hub_PublishFromBridge_Bad(t *T) { + hub := NewHub() + + err := hub.PublishFromBridge("block", []byte("template")) + AssertEqual(t, ErrHubNotRunning, err) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_Hub_PublishFromBridge_Ugly(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + seen := false + stop := hub.SubscribePublished(func(string, []byte) { seen = true }) + defer stop() + + AssertNoError(t, hub.PublishFromBridge("block", []byte("template"))) + Sleep(20 * Millisecond) + AssertFalse(t, seen) +} + +func TestAX7_Hub_PublishFromPeer_Bad(t *T) { + hub := NewHub() + peer := NewPeer("ws") + + err := hub.PublishFromPeer(peer, "block", []byte("template")) + AssertEqual(t, ErrHubNotRunning, err) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_Hub_PublishFromPeer_Ugly(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + source := NewPeer("ws") + receiver := NewPeer("ws") + AssertNoError(t, hub.AddPeer(source)) + AssertNoError(t, hub.AddPeer(receiver)) + AssertNoError(t, hub.SubscribePeer(receiver, "block")) + + AssertNoError(t, hub.PublishFromPeer(source, "block", []byte("template"))) + AssertEqual(t, "template", string(<-receiver.SendQueue())) +} + +func TestAX7_Hub_RemovePeer_Good(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + + hub.RemovePeer(peer) + AssertEqual(t, 0, hub.PeerCount()) + AssertEqual(t, []string{}, peer.Subscriptions()) +} + +func TestAX7_Hub_RemovePeer_Bad(t *T) { + var hub *Hub + peer := NewPeer("ws") + + AssertNotPanics(t, func() { hub.RemovePeer(peer) }) + AssertEqual(t, "ws", peer.Transport) +} + +func TestAX7_Hub_RemovePeer_Ugly(t *T) { + hub := NewHub() + peer := NewPeer("ws") + + AssertNotPanics(t, func() { hub.RemovePeer(peer) }) + AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_Hub_SendToChannel_Good(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + received := make(chan []byte, 1) + stop := hub.Subscribe("hashrate", func(frame []byte) { received <- append([]byte(nil), frame...) }) + defer stop() + + AssertNoError(t, hub.SendToChannel("hashrate", []byte("123"))) + AssertEqual(t, "123", string(<-received)) +} + +func TestAX7_Hub_SendToChannel_Bad(t *T) { + var hub *Hub + + err := hub.SendToChannel("hashrate", []byte("123")) + AssertError(t, err) + AssertContains(t, err.Error(), "nil hub") +} + +func TestAX7_Hub_SendToChannel_Ugly(t *T) { + hub := NewHub() + + err := hub.SendToChannel("hashrate", []byte("123")) + AssertEqual(t, ErrHubNotRunning, err) + AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_Hub_Stats_Good(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + AssertNoError(t, hub.SubscribePeer(peer, "hashrate")) + + stats := hub.Stats() + AssertEqual(t, 1, stats.Peers) + AssertEqual(t, 1, stats.SubscriberCount["hashrate"]) +} + +func TestAX7_Hub_Stats_Bad(t *T) { + var hub *Hub + + stats := hub.Stats() + AssertEqual(t, 0, stats.Peers) + AssertEqual(t, 0, stats.Channels) +} + +func TestAX7_Hub_Stats_Ugly(t *T) { + hub := NewHub() + stop := hub.Subscribe("events", func([]byte) {}) + defer stop() + + stats := hub.Stats() + AssertEqual(t, 1, stats.Channels) + AssertEqual(t, 1, stats.SubscriberCount["events"]) +} + +func TestAX7_Hub_SubscribeBroadcast_Good(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + received := make(chan []byte, 1) + stop := hub.SubscribeBroadcast(func(frame []byte) { received <- append([]byte(nil), frame...) }) + defer stop() + + AssertNoError(t, hub.Broadcast([]byte("shutdown"))) + AssertEqual(t, "shutdown", string(<-received)) +} + +func TestAX7_Hub_SubscribeBroadcast_Bad(t *T) { + var hub *Hub + + stop := hub.SubscribeBroadcast(func([]byte) {}) + AssertNotNil(t, stop) + AssertNotPanics(t, stop) +} + +func TestAX7_Hub_SubscribeBroadcast_Ugly(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + received := make(chan []byte, 1) + stop := hub.SubscribeBroadcast(func(frame []byte) { received <- frame }) + stop() + + AssertNoError(t, hub.Broadcast([]byte("shutdown"))) + select { + case frame := <-received: + t.Fatalf("received after unsubscribe: %q", string(frame)) + case <-ax7Timeout(20 * Millisecond): + } +} + +func TestAX7_Hub_SubscribePeer_Good(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + + AssertNoError(t, hub.SubscribePeer(peer, "hashrate")) + AssertEqual(t, []string{"hashrate"}, peer.Subscriptions()) +} + +func TestAX7_Hub_SubscribePeer_Bad(t *T) { + hub := NewHub() + + err := hub.SubscribePeer(nil, "hashrate") + AssertError(t, err) + AssertContains(t, err.Error(), "nil peer") +} + +func TestAX7_Hub_SubscribePeer_Ugly(t *T) { + hub := NewHubWithConfig(HubConfig{ChannelAuthoriser: func(*Peer, string) bool { return false }}) + peer := NewPeer("ws") + + err := hub.SubscribePeer(peer, "private") + AssertEqual(t, ErrAuthRejected, err) + AssertEqual(t, []string{}, peer.Subscriptions()) +} + +func TestAX7_Hub_SubscribePublished_Good(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + received := make(chan string, 1) + stop := hub.SubscribePublished(func(channel string, frame []byte) { received <- channel + ":" + string(frame) }) + defer stop() + + AssertNoError(t, hub.Publish("block", []byte("template"))) + AssertEqual(t, "block:template", <-received) +} + +func TestAX7_Hub_SubscribePublished_Bad(t *T) { + var hub *Hub + + stop := hub.SubscribePublished(func(string, []byte) {}) + AssertNotNil(t, stop) + AssertNotPanics(t, stop) +} + +func TestAX7_Hub_SubscribePublished_Ugly(t *T) { + hub, cancel := ax7RunningHub(t) + defer cancel() + received := make(chan string, 1) + stop := hub.SubscribePublished(func(channel string, frame []byte) { received <- channel }) + stop() + + AssertNoError(t, hub.Publish("block", []byte("template"))) + select { + case channel := <-received: + t.Fatalf("received after unsubscribe: %q", channel) + case <-ax7Timeout(20 * Millisecond): + } +} + +func TestAX7_Hub_SubscribeWithError_Bad(t *T) { + hub := NewHub() + + stop, err := hub.SubscribeWithError("", func([]byte) {}) + AssertEqual(t, ErrEmptyChannel, err) + AssertNotNil(t, stop) +} + +func TestAX7_Hub_SubscribeWithError_Ugly(t *T) { + hub := NewHub() + + stop, err := hub.SubscribeWithError("events", nil) + AssertError(t, err) + AssertNotNil(t, stop) +} + +func TestAX7_Hub_UnsubscribePeer_Good(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + AssertNoError(t, hub.SubscribePeer(peer, "block")) + + hub.UnsubscribePeer(peer, "block") + AssertEqual(t, []string{}, peer.Subscriptions()) + AssertEqual(t, 0, hub.ChannelSubscriberCount("block")) +} + +func TestAX7_Hub_UnsubscribePeer_Bad(t *T) { + hub := NewHub() + + AssertNotPanics(t, func() { hub.UnsubscribePeer(nil, "block") }) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_Hub_UnsubscribePeer_Ugly(t *T) { + hub := NewHub() + peer := NewPeer("ws") + AssertNoError(t, hub.AddPeer(peer)) + + AssertNotPanics(t, func() { hub.UnsubscribePeer(peer, "") }) + AssertEqual(t, 0, len(peer.Subscriptions())) +} + +func TestAX7_NewAPIKeyAuth_Good(t *T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk-live": "user-42"}) + + AssertNotNil(t, authenticator) + AssertEqual(t, "user-42", authenticator.Keys["sk-live"]) + AssertEqual(t, 1, len(authenticator.Keys)) +} + +func TestAX7_NewAPIKeyAuth_Bad(t *T) { + authenticator := NewAPIKeyAuth(nil) + + AssertNotNil(t, authenticator) + AssertNotNil(t, authenticator.Keys) + AssertEqual(t, 0, len(authenticator.Keys)) +} + +func TestAX7_NewAPIKeyAuth_Ugly(t *T) { + keys := map[string]string{"sk-live": "user-42"} + authenticator := NewAPIKeyAuth(keys) + keys["sk-live"] = "mutated" + + AssertEqual(t, "user-42", authenticator.Keys["sk-live"]) + AssertEqual(t, "mutated", keys["sk-live"]) +} + +func TestAX7_NewHub_Good(t *T) { + hub := NewHub() + + AssertNotNil(t, hub) + AssertFalse(t, hub.Running()) + AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_NewHub_Bad(t *T) { + hub := NewHub() + + AssertNotNil(t, hub.Config()) + AssertEqual(t, 30*Second, hub.Config().HeartbeatInterval) + AssertEqual(t, 0, hub.ChannelCount()) +} + +func TestAX7_NewHub_Ugly(t *T) { + left := NewHub() + right := NewHub() + + AssertNotEqual(t, left, right) + AssertNotNil(t, left.done) + AssertNotNil(t, right.done) +} + +func TestAX7_NewHubWithConfig_Good(t *T) { + hub := NewHubWithConfig(HubConfig{HeartbeatInterval: Second, PongTimeout: 3 * Second}) + + AssertNotNil(t, hub) + AssertEqual(t, Second, hub.Config().HeartbeatInterval) + AssertEqual(t, 3*Second, hub.Config().PongTimeout) +} + +func TestAX7_NewHubWithConfig_Bad(t *T) { + hub := NewHubWithConfig(HubConfig{}) + + AssertEqual(t, 30*Second, hub.Config().HeartbeatInterval) + AssertEqual(t, 60*Second, hub.Config().PongTimeout) + AssertEqual(t, 10*Second, hub.Config().WriteTimeout) +} + +func TestAX7_NewHubWithConfig_Ugly(t *T) { + called := false + hub := NewHubWithConfig(HubConfig{OnConnect: func(*Peer) { called = true }}) + + AssertNoError(t, hub.AddPeer(NewPeer("ws"))) + AssertTrue(t, called) +} + +func TestAX7_Peer_Close_Bad(t *T) { + var peer *Peer + + AssertNotPanics(t, func() { peer.Close() }) + AssertNil(t, peer) +} + +func TestAX7_Peer_SendQueue_Good(t *T) { + peer := NewPeer("ws") + queue := peer.SendQueue() + + AssertNotNil(t, queue) + AssertTrue(t, peer.Send([]byte("frame"))) + AssertEqual(t, "frame", string(<-queue)) +} + +func TestAX7_Peer_SendQueue_Ugly(t *T) { + peer := NewPeer("ws") + queue := peer.SendQueue() + peer.Close() + + _, ok := <-queue + AssertFalse(t, ok) + AssertFalse(t, peer.Send([]byte("late"))) +} + +func TestAX7_Peer_SetCloseHook_Ugly(t *T) { + peer := NewPeer("ws") + count := 0 + peer.SetCloseHook(func() { count++ }) + peer.SetCloseHook(func() { count += 10 }) + + peer.Close() + AssertEqual(t, 10, count) + AssertEqual(t, []string{}, peer.Subscriptions()) +} + +func TestAX7_QueryTokenAuth_Authenticate_Good(t *T) { + authenticator := &QueryTokenAuth{Validate: func(token string) AuthResult { + return AuthResult{Valid: token == "query-token", UserID: "browser"} + }} + request := NewHTTPTestRequest("GET", "/stream/ws?token=query-token", nil) + + result := authenticator.Authenticate(request) + AssertTrue(t, result.Valid) + AssertEqual(t, "browser", result.UserID) +} + +func TestAX7_QueryTokenAuth_Authenticate_Bad(t *T) { + authenticator := &QueryTokenAuth{Validate: func(token string) AuthResult { + return AuthResult{Valid: token == "query-token"} + }} + request := NewHTTPTestRequest("GET", "/stream/ws", nil) + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertEqual(t, "", result.UserID) +} + +func TestAX7_QueryTokenAuth_Authenticate_Ugly(t *T) { + var authenticator *QueryTokenAuth + request := NewHTTPTestRequest("GET", "/stream/ws?token=query-token", nil) + + result := authenticator.Authenticate(request) + AssertFalse(t, result.Valid) + AssertNil(t, result.Claims) +} diff --git a/errors.go b/errors.go index 0876ee6..784a015 100644 --- a/errors.go +++ b/errors.go @@ -2,7 +2,7 @@ package stream -import "dappco.re/go/core" +import "dappco.re/go" // if err := hub.Publish("hashrate", frame); err == ErrHubNotRunning { // return diff --git a/example_test.go b/example_test.go index 39e51a5..30d45eb 100644 --- a/example_test.go +++ b/example_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "time" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" ) diff --git a/go.mod b/go.mod index 339fff1..1daf5b6 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module dappco.re/go/stream go 1.26.0 -require dappco.re/go/core v0.8.0-alpha.1 - require ( github.com/alicebob/miniredis/v2 v2.37.0 github.com/go-zeromq/zmq4 v0.17.0 @@ -12,6 +10,7 @@ require ( ) require ( + dappco.re/go v0.9.0 github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-zeromq/goczmq/v4 v4.2.2 // indirect diff --git a/go.sum b/go.sum index 844aa11..a171336 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= -dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -8,8 +8,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-zeromq/goczmq/v4 v4.2.2 h1:HAJN+i+3NW55ijMJJhk7oWxHKXgAuSBkoFfvr8bYj4U= @@ -20,12 +20,12 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -36,5 +36,3 @@ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hub.go b/hub.go index a352a95..2640f83 100644 --- a/hub.go +++ b/hub.go @@ -12,7 +12,7 @@ import ( "sort" "sync" - "dappco.re/go/core" + "dappco.re/go" ) const defaultHubQueueSize = 256 @@ -606,10 +606,14 @@ func (hub *Hub) sendToPeer(peer *Peer, channel string, frame []byte) { return } if peer.Transport == "tcp" { - _ = peer.Send(encodeTCPFrame(channel, frame)) + if ok := peer.Send(encodeTCPFrame(channel, frame)); !ok { + return + } + return + } + if ok := peer.Send(frame); !ok { return } - _ = peer.Send(frame) } // hub.sendBroadcastToPeer(peer, []byte("shutdown")) @@ -618,10 +622,14 @@ func (hub *Hub) sendBroadcastToPeer(peer *Peer, frame []byte) { return } if peer.Transport == "tcp" { - _ = peer.Send(encodeTCPFrame("", frame)) + if ok := peer.Send(encodeTCPFrame("", frame)); !ok { + return + } + return + } + if ok := peer.Send(frame); !ok { return } - _ = peer.Send(frame) } // hub.invokeHandlers(handlers, frame) @@ -629,7 +637,9 @@ func (hub *Hub) invokeHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { func(handlerFunction func([]byte)) { defer func() { - _ = recover() + if recovered := recover(); recovered != nil { + return + } }() handlerFunction(frame) }(handler) @@ -806,7 +816,9 @@ func (hub *Hub) invokeBroadcastHandlers(handlers []func([]byte), frame []byte) { for _, handler := range handlers { func(handlerFunction func([]byte)) { defer func() { - _ = recover() + if recovered := recover(); recovered != nil { + return + } }() handlerFunction(frame) }(handler) @@ -818,7 +830,9 @@ func (hub *Hub) invokePublishHandlers(handlers []func(string, []byte), channel s for _, handler := range handlers { func(handlerFunction func(string, []byte)) { defer func() { - _ = recover() + if recovered := recover(); recovered != nil { + return + } }() handlerFunction(channel, frame) }(handler) diff --git a/hub_test.go b/hub_test.go index 5687362..accb1c7 100644 --- a/hub_test.go +++ b/hub_test.go @@ -112,7 +112,7 @@ func (streamValue *testStream) cloneHandlersLocked(channel string) []func([]byte return cloned } -func TestHub_Pipe_Good(t *testing.T) { +func TestAX7_Hub_Pipe_Good(t *testing.T) { sourceHub := NewHub() destinationHub := NewHub() @@ -186,7 +186,7 @@ func TestHub_Pipe_Broadcast_Good(t *testing.T) { } } -func TestHub_Pipe_Bad(t *testing.T) { +func TestAX7_Hub_Pipe_Bad(t *testing.T) { sourceHub := NewHub() destinationHub := NewHub() @@ -228,7 +228,7 @@ func TestHub_Pipe_Bad(t *testing.T) { } } -func TestHub_Pipe_Ugly(t *testing.T) { +func TestAX7_Hub_Pipe_Ugly(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -286,7 +286,7 @@ func TestHub_Pipe_GenericPublishFallback_Good(t *testing.T) { } } -func TestHub_Publish_Good(t *testing.T) { +func TestAX7_Hub_Publish_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -328,7 +328,7 @@ func TestHub_Publish_Good(t *testing.T) { } } -func TestHub_Publish_Bad(t *testing.T) { +func TestAX7_Hub_Publish_Bad(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -341,7 +341,7 @@ func TestHub_Publish_Bad(t *testing.T) { } } -func TestHub_PublishFromPeer_Good(t *testing.T) { +func TestAX7_Hub_PublishFromPeer_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -373,7 +373,7 @@ func TestHub_PublishFromPeer_Good(t *testing.T) { } } -func TestHub_BroadcastFromPeer_Good(t *testing.T) { +func TestAX7_Hub_BroadcastFromPeer_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -401,7 +401,7 @@ func TestHub_BroadcastFromPeer_Good(t *testing.T) { } } -func TestHub_Publish_Ugly(t *testing.T) { +func TestAX7_Hub_Publish_Ugly(t *testing.T) { hub := NewHub() if err := hub.Publish("hashrate", []byte("123456")); err != ErrHubNotRunning { @@ -409,7 +409,7 @@ func TestHub_Publish_Ugly(t *testing.T) { } } -func TestHub_Running_Good(t *testing.T) { +func TestAX7_Hub_Running_Good(t *testing.T) { hub := NewHub() if hub.Running() { t.Fatal("Running() = true before Run()") @@ -438,14 +438,14 @@ func TestHub_Running_Good(t *testing.T) { t.Fatal("Running() stayed true after context cancellation") } -func TestHub_Running_Bad(t *testing.T) { +func TestAX7_Hub_Running_Bad(t *testing.T) { var hub *Hub if hub.Running() { t.Fatal("nil hub Running() = true, want false") } } -func TestHub_Running_Ugly(t *testing.T) { +func TestAX7_Hub_Running_Ugly(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -470,7 +470,7 @@ func TestHub_Running_Ugly(t *testing.T) { hubCancel() } -func TestHub_Broadcast_Good(t *testing.T) { +func TestAX7_Hub_Broadcast_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -499,7 +499,7 @@ func TestHub_Broadcast_Good(t *testing.T) { } } -func TestHub_Broadcast_Bad(t *testing.T) { +func TestAX7_Hub_Broadcast_Bad(t *testing.T) { hub := NewHub() if err := hub.Broadcast([]byte("123456")); err != ErrHubNotRunning { @@ -507,7 +507,7 @@ func TestHub_Broadcast_Bad(t *testing.T) { } } -func TestHub_Broadcast_Ugly(t *testing.T) { +func TestAX7_Hub_Broadcast_Ugly(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -554,7 +554,7 @@ func TestHub_Broadcast_Ugly(t *testing.T) { waitForPeerCount(t, hub, 0) } -func TestHub_SubscribeE_Good(t *testing.T) { +func TestAX7_Hub_SubscribeE_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -585,7 +585,7 @@ func TestHub_SubscribeE_Good(t *testing.T) { } } -func TestHub_SubscribeWithError_Good(t *testing.T) { +func TestAX7_Hub_SubscribeWithError_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -653,7 +653,7 @@ func TestHub_Stats_IncludeHandlerOnlyChannels_Good(t *testing.T) { } } -func TestHub_SubscribeE_Bad(t *testing.T) { +func TestAX7_Hub_SubscribeE_Bad(t *testing.T) { hub := NewHub() unsubscribe, err := hub.SubscribeE("", func(frame []byte) {}) @@ -666,7 +666,7 @@ func TestHub_SubscribeE_Bad(t *testing.T) { unsubscribe() } -func TestHub_SubscribeE_Ugly(t *testing.T) { +func TestAX7_Hub_SubscribeE_Ugly(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -714,7 +714,7 @@ func TestHub_SubscribeE_Ugly(t *testing.T) { t.Fatalf("SubscribeE panic handler count = %d, want 1", panicked) } -func TestHub_CanSubscribePeer_Bad(t *testing.T) { +func TestAX7_Hub_CanSubscribePeer_Bad(t *testing.T) { hub := NewHubWithConfig(HubConfig{ ChannelAuthoriser: func(peer *Peer, channel string) bool { return channel == "public" @@ -730,7 +730,7 @@ func TestHub_CanSubscribePeer_Bad(t *testing.T) { } } -func TestPeer_Subscriptions_Good(t *testing.T) { +func TestAX7_Peer_Subscriptions_Good(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -766,7 +766,7 @@ func TestPeer_Subscriptions_Good(t *testing.T) { } } -func TestPeer_Subscriptions_Bad(t *testing.T) { +func TestAX7_Peer_Subscriptions_Bad(t *testing.T) { var peer *Peer if subscriptions := peer.Subscriptions(); subscriptions != nil { @@ -774,7 +774,7 @@ func TestPeer_Subscriptions_Bad(t *testing.T) { } } -func TestPeer_Subscriptions_Ugly(t *testing.T) { +func TestAX7_Peer_Subscriptions_Ugly(t *testing.T) { hub := NewHub() hubContext, hubCancel := context.WithCancel(context.Background()) defer hubCancel() @@ -832,7 +832,7 @@ func TestHub_SendToChannel_Wildcard_Good(t *testing.T) { t.Fatalf("wildcard handler count = %d, want 1", count) } -func TestPeer_Close_Good(t *testing.T) { +func TestAX7_Peer_Close_Good(t *testing.T) { peer := NewPeer("ws") closed := make(chan struct{}, 1) @@ -864,7 +864,7 @@ func TestPeer_Close_Good(t *testing.T) { } } -func TestHub_Run_Good(t *testing.T) { +func TestAX7_Hub_Run_Good(t *testing.T) { hub := NewHub() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -895,7 +895,7 @@ func TestHub_Run_Good(t *testing.T) { } } -func TestHub_Run_Bad(t *testing.T) { +func TestAX7_Hub_Run_Bad(t *testing.T) { hub := NewHub() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -921,7 +921,7 @@ func TestHub_Run_Bad(t *testing.T) { } } -func TestHub_Run_Ugly(t *testing.T) { +func TestAX7_Hub_Run_Ugly(t *testing.T) { hub := NewHub() ctx, cancel := context.WithCancel(context.Background()) @@ -957,7 +957,7 @@ func TestHub_Run_Ugly(t *testing.T) { } } -func TestHub_Subscribe_Good(t *testing.T) { +func TestAX7_Hub_Subscribe_Good(t *testing.T) { hub := NewHub() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -985,7 +985,7 @@ func TestHub_Subscribe_Good(t *testing.T) { } } -func TestHub_Subscribe_Bad(t *testing.T) { +func TestAX7_Hub_Subscribe_Bad(t *testing.T) { hub := NewHub() unsubscribe := hub.Subscribe("", func(frame []byte) {}) @@ -995,7 +995,7 @@ func TestHub_Subscribe_Bad(t *testing.T) { unsubscribe() } -func TestHub_Subscribe_Ugly(t *testing.T) { +func TestAX7_Hub_Subscribe_Ugly(t *testing.T) { hub := NewHub() ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/message.go b/message.go index 3c2f04c..38419fc 100644 --- a/message.go +++ b/message.go @@ -7,8 +7,8 @@ // Timestamp: time.Now().UTC(), // } // -// frame, _ := core.JSONMarshal(msg) -// hub.Publish("hashrate", frame.Value.([]byte)) +// frame, _ := core.JSONMarshal(msg) +// hub.Publish("hashrate", frame.Value.([]byte)) package stream import "time" diff --git a/message_test.go b/message_test.go index a239e66..e24803b 100644 --- a/message_test.go +++ b/message_test.go @@ -7,7 +7,7 @@ import ( "time" ) -func TestMessageType_String_Good(t *testing.T) { +func TestAX7_MessageType_String_Good(t *testing.T) { cases := []struct { messageType MessageType expected string @@ -28,7 +28,7 @@ func TestMessageType_String_Good(t *testing.T) { } } -func TestMessageType_String_Bad(t *testing.T) { +func TestAX7_MessageType_String_Bad(t *testing.T) { // Unknown MessageType returns its raw string value. unknown := MessageType("nonexistent") if unknown.String() != "nonexistent" { @@ -36,7 +36,7 @@ func TestMessageType_String_Bad(t *testing.T) { } } -func TestMessageType_String_Ugly(t *testing.T) { +func TestAX7_MessageType_String_Ugly(t *testing.T) { // Empty MessageType returns empty string. empty := MessageType("") if empty.String() != "" { diff --git a/stats_test.go b/stats_test.go index 1d849f1..8ebf7da 100644 --- a/stats_test.go +++ b/stats_test.go @@ -6,7 +6,7 @@ import ( "context" "testing" - "dappco.re/go/core" + "dappco.re/go" ) func TestStats_HubStats_Good(t *testing.T) { @@ -261,4 +261,3 @@ func TestStats_AllChannels_Bad(t *testing.T) { t.Fatalf("nil hub AllChannels() count = %d, want %d", count, 0) } } - diff --git a/stream.go b/stream.go index 43777af..1cb9f85 100644 --- a/stream.go +++ b/stream.go @@ -110,7 +110,9 @@ func (peer *Peer) Send(frame []byte) bool { return false } defer func() { - _ = recover() + if recovered := recover(); recovered != nil { + return + } }() peer.mutex.RLock() defer peer.mutex.RUnlock() @@ -239,19 +241,25 @@ func Pipe(src Stream, dst Stream) func() { stops := make([]func(), 0, 2) if publisher, ok := src.(publishedFrameSource); ok { stops = append(stops, onceFunction(publisher.SubscribePublished(func(channel string, frame []byte) { - _ = dst.Publish(channel, cloneFrame(frame)) + if err := dst.Publish(channel, cloneFrame(frame)); err != nil { + return + } }))) } if broadcaster, ok := src.(broadcastFrameSource); ok { stops = append(stops, onceFunction(broadcaster.SubscribeBroadcast(func(frame []byte) { - _ = dst.Broadcast(cloneFrame(frame)) + if err := dst.Broadcast(cloneFrame(frame)); err != nil { + return + } }))) } if len(stops) == 0 { // Generic Stream implementations do not expose channel names, so fall back // to publishing on the wildcard channel. stop := src.Subscribe("*", func(frame []byte) { - _ = dst.Publish("*", cloneFrame(frame)) + if err := dst.Publish("*", cloneFrame(frame)); err != nil { + return + } }) return onceFunction(stop) } diff --git a/stream_test.go b/stream_test.go index ac0ca00..af12bf3 100644 --- a/stream_test.go +++ b/stream_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestConnectionState_String_Good(t *testing.T) { +func TestAX7_ConnectionState_String_Good(t *testing.T) { cases := []struct { state ConnectionState expected string @@ -23,7 +23,7 @@ func TestConnectionState_String_Good(t *testing.T) { } } -func TestConnectionState_String_Bad(t *testing.T) { +func TestAX7_ConnectionState_String_Bad(t *testing.T) { // Unknown ConnectionState value falls through to default ("disconnected"). unknown := ConnectionState(99) if unknown.String() != "disconnected" { @@ -31,7 +31,7 @@ func TestConnectionState_String_Bad(t *testing.T) { } } -func TestConnectionState_String_Ugly(t *testing.T) { +func TestAX7_ConnectionState_String_Ugly(t *testing.T) { // Negative ConnectionState value still returns "disconnected". negative := ConnectionState(-1) if negative.String() != "disconnected" { @@ -78,7 +78,7 @@ func TestEnvelope_Fields_Ugly(t *testing.T) { } } -func TestNewPeer_Good(t *testing.T) { +func TestAX7_NewPeer_Good(t *testing.T) { peer := NewPeer("ws") if peer == nil { t.Fatal("NewPeer() = nil") @@ -97,7 +97,7 @@ func TestNewPeer_Good(t *testing.T) { } } -func TestNewPeer_Bad(t *testing.T) { +func TestAX7_NewPeer_Bad(t *testing.T) { // NewPeer with empty transport creates a valid peer. peer := NewPeer("") if peer == nil { @@ -108,7 +108,7 @@ func TestNewPeer_Bad(t *testing.T) { } } -func TestNewPeer_Ugly(t *testing.T) { +func TestAX7_NewPeer_Ugly(t *testing.T) { // Two peers created simultaneously have different IDs. peer1 := NewPeer("ws") peer2 := NewPeer("ws") @@ -117,7 +117,7 @@ func TestNewPeer_Ugly(t *testing.T) { } } -func TestPeer_Send_Good(t *testing.T) { +func TestAX7_Peer_Send_Good(t *testing.T) { peer := NewPeer("ws") ok := peer.Send([]byte("hello")) if !ok { @@ -133,7 +133,7 @@ func TestPeer_Send_Good(t *testing.T) { } } -func TestPeer_Send_Bad(t *testing.T) { +func TestAX7_Peer_Send_Bad(t *testing.T) { // Send to nil peer returns false without panic. var peer *Peer ok := peer.Send([]byte("hello")) @@ -142,7 +142,7 @@ func TestPeer_Send_Bad(t *testing.T) { } } -func TestPeer_Send_Ugly(t *testing.T) { +func TestAX7_Peer_Send_Ugly(t *testing.T) { // Send after Close returns false without panic. peer := NewPeer("ws") peer.Close() @@ -152,14 +152,14 @@ func TestPeer_Send_Ugly(t *testing.T) { } } -func TestPeer_Close_Ugly(t *testing.T) { +func TestAX7_Peer_Close_Ugly(t *testing.T) { // Double Close does not panic. peer := NewPeer("ws") peer.Close() peer.Close() } -func TestPeer_SetCloseHook_Good(t *testing.T) { +func TestAX7_Peer_SetCloseHook_Good(t *testing.T) { peer := NewPeer("ws") invoked := false peer.SetCloseHook(func() { invoked = true }) @@ -169,13 +169,16 @@ func TestPeer_SetCloseHook_Good(t *testing.T) { } } -func TestPeer_SetCloseHook_Bad(t *testing.T) { +func TestAX7_Peer_SetCloseHook_Bad(t *testing.T) { // SetCloseHook on nil peer does not panic. var peer *Peer peer.SetCloseHook(func() {}) + if peer != nil { + t.Fatal("nil peer changed after SetCloseHook") + } } -func TestPeer_SendQueue_Bad(t *testing.T) { +func TestAX7_Peer_SendQueue_Bad(t *testing.T) { // SendQueue on nil peer returns nil. var peer *Peer if peer.SendQueue() != nil { @@ -301,6 +304,9 @@ func TestOnceFunction_Good(t *testing.T) { func TestOnceFunction_Bad(t *testing.T) { // onceFunction with nil handler returns a no-op function. handler := onceFunction(nil) + if handler == nil { + t.Fatal("onceFunction(nil) returned nil") + } handler() // should not panic } diff --git a/ws/ax7_more_test.go b/ws/ax7_more_test.go new file mode 100644 index 0000000..753708e --- /dev/null +++ b/ws/ax7_more_test.go @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ws + +import ( + "github.com/alicebob/miniredis/v2" + "github.com/gorilla/websocket" + + core "dappco.re/go" + "dappco.re/go/stream" + adapterredis "dappco.re/go/stream/adapter/redis" +) + +func TestAX7_DefaultHubConfig_Good(t *core.T) { + config := DefaultHubConfig() + + core.AssertEqual(t, 30*core.Second, config.HeartbeatInterval) + core.AssertEqual(t, 60*core.Second, config.PongTimeout) + core.AssertEqual(t, 10*core.Second, config.WriteTimeout) +} + +func TestAX7_DefaultHubConfig_Bad(t *core.T) { + config := DefaultHubConfig() + + core.AssertNil(t, config.OnConnect) + core.AssertNil(t, config.OnDisconnect) + core.AssertNil(t, config.ChannelAuthoriser) +} + +func TestAX7_DefaultHubConfig_Ugly(t *core.T) { + config := DefaultHubConfig() + + core.AssertGreater(t, config.PongTimeout, config.HeartbeatInterval) + core.AssertGreater(t, config.WriteTimeout, core.Duration(0)) +} + +func TestAX7_New_Good(t *core.T) { + adapter := New(Config{ReadBufferSize: 2048, WriteBufferSize: 4096}) + + core.AssertNotNil(t, adapter) + core.AssertNotNil(t, adapter.Handler()) +} + +func TestAX7_New_Bad(t *core.T) { + adapter := New(Config{}) + + core.AssertNotNil(t, adapter) + core.AssertNotNil(t, adapter.HandlerForChannel("events")) +} + +func TestAX7_New_Ugly(t *core.T) { + called := false + adapter := New(Config{CheckOrigin: func(*core.Request) bool { called = true; return true }}) + + core.AssertTrue(t, adapter.Handler() != nil) + core.AssertTrue(t, adapter.HandlerForChannel("x") != nil) + core.AssertFalse(t, called) +} + +func TestAX7_NewAPIKeyAuth_Good(t *core.T) { + authenticator := NewAPIKeyAuth(map[string]string{"sk": "user"}) + + core.AssertNotNil(t, authenticator) + core.AssertEqual(t, "user", authenticator.Keys["sk"]) +} + +func TestAX7_NewAPIKeyAuth_Bad(t *core.T) { + authenticator := NewAPIKeyAuth(nil) + + core.AssertNotNil(t, authenticator) + core.AssertEqual(t, 0, len(authenticator.Keys)) +} + +func TestAX7_NewAPIKeyAuth_Ugly(t *core.T) { + keys := map[string]string{"sk": "user"} + authenticator := NewAPIKeyAuth(keys) + keys["sk"] = "mutated" + + core.AssertEqual(t, "user", authenticator.Keys["sk"]) + core.AssertEqual(t, "mutated", keys["sk"]) +} + +func TestAX7_NewHub_Good(t *core.T) { + hub := NewHub() + + core.AssertNotNil(t, hub) + core.AssertNotNil(t, hub.Hub) + core.AssertFalse(t, hub.Running()) +} + +func TestAX7_NewHub_Bad(t *core.T) { + hub := NewHub() + + core.AssertEqual(t, 30*core.Second, hub.Config().HeartbeatInterval) + core.AssertEqual(t, 0, hub.PeerCount()) +} + +func TestAX7_NewHub_Ugly(t *core.T) { + left := NewHub() + right := NewHub() + + core.AssertNotEqual(t, left, right) + core.AssertNotEqual(t, left.Hub, right.Hub) +} + +func TestAX7_NewHubWithConfig_Good(t *core.T) { + hub := NewHubWithConfig(HubConfig{HeartbeatInterval: core.Second, PongTimeout: 3 * core.Second}) + + core.AssertEqual(t, core.Second, hub.Config().HeartbeatInterval) + core.AssertEqual(t, 3*core.Second, hub.Config().PongTimeout) +} + +func TestAX7_NewHubWithConfig_Bad(t *core.T) { + hub := NewHubWithConfig(HubConfig{}) + + core.AssertEqual(t, 30*core.Second, hub.Config().HeartbeatInterval) + core.AssertEqual(t, 60*core.Second, hub.Config().PongTimeout) +} + +func TestAX7_NewHubWithConfig_Ugly(t *core.T) { + called := false + hub := NewHubWithConfig(HubConfig{OnConnect: func(*Peer) { called = true }}) + + core.AssertNoError(t, hub.AddPeer(NewPeer("ws"))) + core.AssertTrue(t, called) +} + +func TestAX7_NewPeer_Good(t *core.T) { + peer := NewPeer("ws") + + core.AssertNotNil(t, peer) + core.AssertEqual(t, "ws", peer.Transport) + core.AssertNotEmpty(t, peer.ID) +} + +func TestAX7_NewPeer_Bad(t *core.T) { + peer := NewPeer("") + + core.AssertNotNil(t, peer) + core.AssertEqual(t, "", peer.Transport) + core.AssertNotNil(t, peer.SendQueue()) +} + +func TestAX7_NewPeer_Ugly(t *core.T) { + left := NewPeer("ws") + right := NewPeer("ws") + + core.AssertNotEqual(t, left.ID, right.ID) + core.AssertEqual(t, "ws", right.Transport) +} + +func TestAX7_NewReconnectingClient_Good(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{URL: "ws://127.0.0.1/stream/ws"}) + + core.AssertNotNil(t, client) + core.AssertEqual(t, stream.StateDisconnected, client.State()) + core.AssertNoError(t, client.Close()) +} + +func TestAX7_NewReconnectingClient_Bad(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{}) + + core.AssertNotNil(t, client) + core.AssertEqual(t, StateDisconnected, client.State()) +} + +func TestAX7_NewReconnectingClient_Ugly(t *core.T) { + client := NewReconnectingClient(ReconnectConfig{URL: "://bad-url", MaxRetries: 1, InitialBackoff: core.Millisecond}) + + err := client.Connect(core.Background()) + core.AssertError(t, err) + core.AssertEqual(t, StateDisconnected, client.State()) +} + +func TestAX7_NewRedisBridge_Good(t *core.T) { + redisServer := miniredis.RunT(t) + bridge, err := NewRedisBridge(NewHub(), adapterredis.Config{Addr: redisServer.Addr(), Prefix: "pool"}) + + core.AssertNoError(t, err) + core.AssertNotNil(t, bridge) + core.AssertNotEmpty(t, bridge.SourceID()) +} + +func TestAX7_NewRedisBridge_Bad(t *core.T) { + bridge, err := NewRedisBridge("unsupported", adapterredis.Config{}) + + core.AssertError(t, err) + core.AssertNil(t, bridge) +} + +func TestAX7_NewRedisBridge_Ugly(t *core.T) { + var hub *Hub + redisServer := miniredis.RunT(t) + + bridge, err := NewRedisBridge(hub, adapterredis.Config{Addr: redisServer.Addr(), Prefix: "pool"}) + core.AssertError(t, err) + core.AssertNil(t, bridge) +} + +func TestAX7_Pipe_Good(t *core.T) { + source := NewHub() + destination := NewHub() + + stop := Pipe(source, destination) + core.AssertNotNil(t, stop) + stop() +} + +func TestAX7_Pipe_Bad(t *core.T) { + stop := Pipe(nil, NewHub()) + + core.AssertNotNil(t, stop) + core.AssertNotPanics(t, stop) +} + +func TestAX7_Pipe_Ugly(t *core.T) { + hub := NewHub() + stop := Pipe(hub, hub) + + core.AssertNotNil(t, stop) + core.AssertNotPanics(t, stop) +} + +func TestAX7_Hub_Handler_Good(t *core.T) { + hub := NewHub() + ctx, cancel := core.WithCancel(core.Background()) + defer cancel() + go hub.Run(ctx) + waitForRunningHub(t, hub) + server := core.NewHTTPTestServer(hub.Handler()) + defer server.Close() + + conn, _, err := websocket.DefaultDialer.Dial("ws"+server.URL[len("http"):], nil) + core.AssertNoError(t, err) + core.AssertNoError(t, conn.Close()) +} + +func TestAX7_Hub_Handler_Bad(t *core.T) { + var hub *Hub + handler := hub.Handler() + recorder := core.NewHTTPTestRecorder() + + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/ws", nil)) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not mounted") +} + +func TestAX7_Hub_Handler_Ugly(t *core.T) { + hub := NewHub() + handler := hub.Handler() + recorder := core.NewHTTPTestRecorder() + + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/ws", nil)) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not running") +} + +func TestAX7_Hub_HandlerForChannel_Good(t *core.T) { + hub := NewHub() + ctx, cancel := core.WithCancel(core.Background()) + defer cancel() + go hub.Run(ctx) + waitForRunningHub(t, hub) + server := core.NewHTTPTestServer(hub.HandlerForChannel("hashrate")) + defer server.Close() + + conn, _, err := websocket.DefaultDialer.Dial("ws"+server.URL[len("http"):], nil) + core.AssertNoError(t, err) + core.AssertNoError(t, conn.Close()) +} + +func TestAX7_Hub_HandlerForChannel_Bad(t *core.T) { + var hub *Hub + handler := hub.HandlerForChannel("hashrate") + recorder := core.NewHTTPTestRecorder() + + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/ws", nil)) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not mounted") +} + +func TestAX7_Hub_HandlerForChannel_Ugly(t *core.T) { + hub := NewHub() + handler := hub.HandlerForChannel("hashrate") + recorder := core.NewHTTPTestRecorder() + + handler.ServeHTTP(recorder, core.NewHTTPTestRequest("GET", "/stream/ws", nil)) + core.AssertEqual(t, 500, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "not running") +} diff --git a/ws/compat.go b/ws/compat.go index 087a010..1294e9e 100644 --- a/ws/compat.go +++ b/ws/compat.go @@ -1,15 +1,15 @@ // SPDX-License-Identifier: EUPL-1.2 -// hub := ws.NewHub() -// go hub.Run(ctx) -// http.Handle("/stream/ws", hub.Handler()) +// hub := ws.NewHub() +// go hub.Run(ctx) +// http.Handle("/stream/ws", hub.Handler()) package ws import ( "net/http" "sync" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/stream" adapterredis "dappco.re/go/stream/adapter/redis" adapterws "dappco.re/go/stream/adapter/ws"