Repository: hyperledger-labs/fabric-smart-client
Pull request #909: cleanup websockets multiplexing
Author: login: mbrandenburger
Created: 2025-05-13T07:55:15Z | Merged: 2025-05-21T13:31:36Z

This PR improves the current websocket implementation and fixes the issue report in #884.

- Introduce connects killall to cleanup all goroutines
- Reduce number of goroutines for shovel messages between several layers
- Add testing


Diff:
diff --git a/go.mod b/go.mod
index e6d0584b7..daabe1cf9 100644
--- a/go.mod
+++ b/go.mod
@@ -53,6 +53,7 @@ require (
 	go.opentelemetry.io/otel/trace v1.30.0
 	go.uber.org/atomic v1.11.0
 	go.uber.org/dig v1.18.0
+	go.uber.org/goleak v1.3.0
 	go.uber.org/zap v1.27.0
 	golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
 	golang.org/x/net v0.33.0
diff --git a/platform/view/services/comm/host/rest/websocket/multiplexed_provider.go b/platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
index ca2f1dc69..d01c1e46c 100644
--- a/platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
+++ b/platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
@@ -10,6 +10,8 @@ import (
 	"context"
 	"crypto/tls"
 	"encoding/json"
+	errors2 "errors"
+	"io"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -18,7 +20,6 @@ import (
 	"time"
 
 	"github.com/gorilla/websocket"
-	"github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/collections"
 	web2 "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/client/web"
 	host2 "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host"
 	"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/metrics"
@@ -34,13 +35,6 @@ const (
 
 type SubConnId = string
 
-var subConnId atomic.Uint64
-
-func NewSubConnId() SubConnId {
-	// Must be thread safe
-	return strconv.FormatUint(subConnId.Add(1), 10)
-}
-
 type MultiplexedMessage struct {
 	ID  SubConnId `json:"id"`
 	Msg []byte    `json:"msg"`
@@ -48,10 +42,30 @@ type MultiplexedMessage struct {
 }
 
 type MultiplexedProvider struct {
-	clients map[string]*multiplexedClientConn
-	tracer  trace.Tracer
-	m       *Metrics
+	// mu protects the clients map
 	mu      sync.RWMutex
+	clients map[string]*multiplexedClientConn
+
+	tracer      trace.Tracer
+	m           *Metrics
+	trackerDone chan struct{}
+}
+
+func (c *MultiplexedProvider) KillAll() error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	close(c.trackerDone)
+
+	var err error
+	for _, cl := range c.clients {
+		err = errors2.Join(err, cl.Kill())
+	}
+
+	// cleanup clients
+	c.clients = make(map[string]*multiplexedClientConn)
+
+	return err
 }
 
 func NewMultiplexedProvider(tracerProvider trace.TracerProvider, metricsProvider metrics.Provider) *MultiplexedProvider {
@@ -63,21 +77,38 @@ func NewMultiplexedProvider(tracerProvider trace.TracerProvider, metricsProvider
 		})),
 		m: newMetrics(metricsProvider),
 	}
+
+	// spawn ActiveSubConn tracker
+	p.trackerDone = StartTracker(func() {
+		sum := 0
+		p.mu.RLock()
+		for _, c := range p.clients {
+			c.mu.RLock()
+			sum += len(c.subConns)
+			c.mu.RUnlock()
+		}
+		p.mu.RUnlock()
+		p.m.ActiveSubConns.Set(float64(sum))
+	})
+
+	return p
+}
+
+func StartTracker(f func()) chan struct{} {
+	done := make(chan struct{})
 	go func() {
 		t := time.NewTicker(5 * time.Second)
-		for range t.C {
-			sum := 0
-			p.mu.RLock()
-			for _, c := range p.clients {
-				c.mu.RLock()
-				sum += len(c.subConns)
-				c.mu.RUnlock()
+		defer t.Stop()
+		for {
+			select {
+			case <-t.C:
+				f()
+			case <-done:
+				return
 			}
-			p.mu.RUnlock()
-			p.m.ActiveSubConns.Set(float64(sum))
 		}
 	}()
-	return p
+	return done
 }
 
 func (c *MultiplexedProvider) NewClientStream(info host2.StreamInfo, ctx context.Context, src host2.PeerID, config *tls.Config) (s host2.P2PStream, err error) {
@@ -111,6 +142,7 @@ func (c *MultiplexedProvider) NewClientStream(info host2.StreamInfo, ctx context
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to open websocket")
 	}
+
 	conn = newClientConn(wsConn, c.tracer, c.m, func() {
 		logger.Debugf("Closing websocket client for [%s@%s]...", src, info.RemotePeerAddress)
 		c.mu.Lock()
@@ -150,7 +182,7 @@ func newClientConn(conn *websocket.Conn, tracer trace.Tracer, m *Metrics, onClos
 
 func (c *multiplexedClientConn) newClientSubConn(ctx context.Context, src host2.PeerID, info host2.StreamInfo) (*stream, error) {
 	c.mu.Lock()
-	sc := c.newSubConn(NewSubConnId())
+	sc := c.newSubConn(strconv.FormatUint(c.subConnId.Add(1), 10))
 	c.subConns[sc.id] = sc
 	c.mu.Unlock()
 	logger.Debugf("Created client subconn with id [%s]", sc.id)
@@ -168,9 +200,8 @@ func (c *multiplexedClientConn) newClientSubConn(ctx context.Context, src host2.
 	if err != nil {
 		return nil, err
 	}
-	c.writeMu.Lock()
-	err = c.WriteJSON(MultiplexedMessage{ID: sc.id, Msg: payload})
-	c.writeMu.Unlock()
+
+	err = c.write(MultiplexedMessage{ID: sc.id, Msg: payload})
 
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to send meta message")
@@ -181,20 +212,13 @@ func (c *multiplexedClientConn) newClientSubConn(ctx context.Context, src host2.
 
 func (c *multiplexedClientConn) readIncoming() {
 	defer func() {
-		c.mu.RLock()
-		subConns := collections.Values(c.subConns)
-		c.mu.RUnlock()
-		for _, sc := range subConns {
-			sc.reads <- streamEOF
-		}
-		err := c.Close()
+		// close everything
+		err := c.Kill()
 		logger.Debugf("Client connection closed: %v", err)
 	}()
 	var mm MultiplexedMessage
 	for {
-		// c.writeMu.Lock()
-		err := c.ReadJSON(&mm)
-		// c.writeMu.Unlock()
+		err := c.conn.ReadJSON(&mm)
 		if err != nil {
 			logger.Debugf("Client connection errored: %v", err)
 			return
@@ -203,6 +227,7 @@ func (c *multiplexedClientConn) readIncoming() {
 		c.mu.RLock()
 		sc, ok := c.subConns[mm.ID]
 		c.mu.RUnlock()
+
 		if !ok && mm.Err == "" {
 			panic("subconn not found")
 		} else if !ok && mm.Err != "" {
@@ -210,7 +235,7 @@ func (c *multiplexedClientConn) readIncoming() {
 		} else if mm.Err != "" {
 			logger.Debugf("Client subconn errored: %v", mm.Err)
 		} else {
-			sc.reads <- result{value: mm.Msg}
+			sc.receiverChan <- result{value: mm.Msg}
 		}
 	}
 }
@@ -229,20 +254,13 @@ func newServerConn(conn *websocket.Conn, tracer trace.Tracer, m *Metrics, newStr
 
 func (c *multiplexedServerConn) readIncoming(newStreamCallback func(pStream host2.P2PStream)) {
 	defer func() {
-		c.mu.RLock()
-		subConns := collections.Values(c.subConns)
-		c.mu.RUnlock()
-		for _, sc := range subConns {
-			sc.reads <- streamEOF
-		}
-		err := c.Close()
-		logger.Debugf("Connection closed: %v", err)
+		// close everything
+		err := c.Kill()
+		logger.Debugf("Server connection closed: %v", err)
 	}()
 	var mm MultiplexedMessage
 	for {
-		// c.writeMu.Lock()
-		err := c.ReadJSON(&mm)
-		// c.writeMu.Unlock()
+		err := c.conn.ReadJSON(&mm)
 		if err != nil {
 			logger.Debugf("Connection errored: %v", err)
 
@@ -261,10 +279,10 @@ func (c *multiplexedServerConn) readIncoming(newStreamCallback func(pStream host
 			logger.Debugf("Server subconn [%s] errored: %v", mm.ID, mm.Err)
 			go func() {
 				time.Sleep(1 * time.Second) // TODO: Find the point when the connection must close
-				sc.close(false)
+				_ = sc.Close()
 			}()
 		} else {
-			sc.reads <- result{value: mm.Msg}
+			sc.receiverChan <- result{value: mm.Msg}
 		}
 	}
 }
@@ -296,107 +314,77 @@ func (c *multiplexedServerConn) newServerSubConn(newStreamCallback func(pStream
 	logger.Debugf("Received response with context: %v", spanContext)
 	newStreamCallback(NewWSStream(sc, ctx, host2.StreamInfo{
 		RemotePeerID:      meta.PeerID,
-		RemotePeerAddress: c.Conn.RemoteAddr().String(),
+		RemotePeerAddress: c.conn.RemoteAddr().String(),
 		ContextID:         meta.ContextID,
 		SessionID:         meta.SessionID,
 	}))
 }
 
 type multiplexedBaseConn struct {
-	*websocket.Conn
-	tracer trace.Tracer
+	writeMu sync.Mutex
+	conn    *websocket.Conn
 
-	subConns map[SubConnId]*subConn
-	writes   chan MultiplexedMessage
-	closes   chan SubConnId
-	mu       sync.RWMutex
-	writeMu  sync.Mutex
-	cancel   context.CancelFunc
+	// mu protects concurrent use of our subConns
+	mu        sync.RWMutex
+	subConns  map[SubConnId]*subConn
+	subConnId atomic.Uint64
 
-	m    *Metrics
-	side string
+	tracer trace.Tracer
+	m      *Metrics
+	side   string
 }
 
 func newBaseConn(conn *websocket.Conn, tracer trace.Tracer, metrics *Metrics, side string) *multiplexedBaseConn {
-	ctx, cancel := context.WithCancel(context.Background())
 	c := &multiplexedBaseConn{
-		Conn:     conn,
-		tracer:   tracer,
+		conn:     conn,
 		subConns: make(map[SubConnId]*subConn),
-		closes:   make(chan SubConnId, 1000),
-		writes:   make(chan MultiplexedMessage, 1000),
-		cancel:   cancel,
+		tracer:   tracer,
 		m:        metrics,
 		side:     side,
 	}
-	go c.readOutgoing(ctx)
-	go c.readCloses(ctx)
 	return c
 }
 
-func (c *multiplexedBaseConn) newSubConn(id SubConnId) *subConn {
-	c.m.OpenedSubConns.With(sideLabel, c.side).Add(1)
-	return &subConn{
-		id:        id,
-		reads:     make(chan result),
-		writes:    c.writes,
-		closes:    c.closes,
-		writeErrs: make(chan error),
+func (c *multiplexedBaseConn) Kill() error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	// close sub conns
+	var err error
+	for _, sc := range c.subConns {
+		err = errors2.Join(err, sc.Close())
 	}
+
+	// close websocket
+	err = errors2.Join(err, c.conn.Close())
+
+	return err
 }
 
-func (c *multiplexedBaseConn) readCloses(ctx context.Context) {
-	for {
-		select {
-		case <-ctx.Done():
-			logger.Debugf("Stop waiting for closes")
-			c.mu.Lock()
-			c.m.ClosedSubConns.With(sideLabel, c.side).Add(float64(len(c.subConns)))
-			c.subConns = make(map[SubConnId]*subConn)
-			c.mu.Unlock()
-			return
-		case id := <-c.closes:
-			logger.Debugf("Closing sub conn [%v]", id)
-			c.mu.Lock()
-			delete(c.subConns, id)
-			c.mu.Unlock()
-			c.m.ClosedSubConns.With(sideLabel, c.side).Add(1)
-			// TODO: Clean the connection if none left
-		}
+func (c *multiplexedBaseConn) newSubConn(id SubConnId) *subConn {
+	c.m.OpenedSubConns.With(sideLabel, c.side).Add(1)
+	return &subConn{
+		id:           id,
+		receiverChan: make(chan result),
+		parentConn:   c,
 	}
 }
 
-func (c *multiplexedBaseConn) readOutgoing(ctx context.Context) {
-	for {
-		select {
-		case <-ctx.Done():
-			logger.Debugf("Closing all outgoing connections")
-			return
-		case msg := <-c.writes:
-			c.writeMu.Lock()
-			err := c.WriteJSON(msg)
-			c.writeMu.Unlock()
-			c.mu.RLock()
-			sc, ok := c.subConns[msg.ID]
-			c.mu.RUnlock()
-			if ok {
-				sc.writeErrs <- err
-			} else {
-				panic("could not find sc with id " + msg.ID)
-			}
-		}
-	}
+func (c *multiplexedBaseConn) write(msg any) error {
+	c.writeMu.Lock()
+	defer c.writeMu.Unlock()
+	return c.conn.WriteJSON(msg)
 }
 
 // Sub connection
 
 type subConn struct {
-	id        SubConnId
-	reads     chan result
-	writes    chan<- MultiplexedMessage
-	closes    chan<- SubConnId
-	writeErrs chan error
-	once      sync.Once
+	id           SubConnId
+	receiverChan chan result
+	parentConn   *multiplexedBaseConn
+
+	mu       sync.Mutex
+	isClosed bool
 }
 
 func (c *subConn) ID() SubConnId {
@@ -404,27 +392,44 @@ func (c *subConn) ID() SubConnId {
 }
 
 func (c *subConn) ReadMessage() (messageType int, p []byte, err error) {
-	r := <-c.reads
+	r, ok := <-c.receiverChan
+	if !ok {
+		return websocket.TextMessage, nil, &websocket.CloseError{
+			Code: websocket.CloseAbnormalClosure,
+			Text: "Closed",
+		}
+	}
 	return websocket.TextMessage, r.value, r.err
 }
 
 func (c *subConn) WriteMessage(_ int, data []byte) error {
-	c.writes <- MultiplexedMessage{ID: c.id, Msg: data}
-	return <-c.writeErrs
+	return c.writeMultiplexedMessage(MultiplexedMessage{ID: c.id, Msg: data})
 }
 
-func (c *subConn) Close() error {
-	c.close(true)
-	return nil
+func (c *subConn) writeMultiplexedMessage(msg MultiplexedMessage) error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if c.isClosed {
+		return websocket.ErrCloseSent
+	}
+
+	return c.parentConn.write(msg)
 }
 
-func (c *subConn) close(client bool) {
-	c.once.Do(func() {
-		if client {
-			c.writes <- MultiplexedMessage{ID: c.id, Err: "EOF"}
-			<-c.writeErrs
-		}
-		c.reads <- streamEOF
-		c.closes <- c.id
-	})
+func (c *subConn) Close() error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if c.isClosed {
+		return nil
+	}
+
+	c.isClosed = true
+	close(c.receiverChan)
+
+	// try to send closing handshake but ignore any error (in case connection is already closed)
+	_ = c.parentConn.write(MultiplexedMessage{ID: c.id, Err: io.EOF.Error()})
+
+	return nil
 }
diff --git a/platform/view/services/comm/host/rest/websocket/multiplexed_provider_test.go b/platform/view/services/comm/host/rest/websocket/multiplexed_provider_test.go
new file mode 100644
index 000000000..f40d05b0a
--- /dev/null
+++ b/platform/view/services/comm/host/rest/websocket/multiplexed_provider_test.go
@@ -0,0 +1,236 @@
+/*
+Copyright IBM Corp All Rights Reserved.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package websocket
+
+import (
+	"cmp"
+	"context"
+	"crypto/tls"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/hyperledger-labs/fabric-smart-client/platform/common/services/logging"
+	"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host"
+	"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/metrics/disabled"
+	"github.com/stretchr/testify/assert"
+	"go.opentelemetry.io/otel/trace/noop"
+	"go.uber.org/goleak"
+)
+
+const (
+	totalMsg   = 1000
+	numStreams = 25
+	snoozeTime = time.Second / 200
+)
+
+var (
+	clientLogger = logging.MustGetLogger("client")
+	serverLogger = logging.MustGetLogger("server")
+)
+
+func TestConnections(t *testing.T) {
+	testSetup(t)
+
+	// let check that at the end of this test all our go routines are stopped
+	defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
+
+	p := NewMultiplexedProvider(noop.NewTracerProvider(), &disabled.Provider{})
+
+	var wg sync.WaitGroup
+
+	// server
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		err := p.NewServerStream(w, r, func(s host.P2PStream) {
+			wg.Add(1)
+			go func(srv host.P2PStream) {
+				defer wg.Done()
+				serverLogger.Info("[server] new stream established with %v (ID=%v) sessionID=%v", srv.RemotePeerID(), srv.RemotePeerID(), srv.Hash())
+				for {
+					serverLogger.Info("[server] reading ...")
+					answer, err := readMsg(srv)
+
+					// deal with EOF
+					if err != nil {
+						if errors.Is(err, io.EOF) {
+							return
+						}
+					}
+
+					// ping
+					assert.NoError(t, err)
+					assert.EqualValues(t, []byte("ping"), answer)
+
+					// pong
+					serverLogger.Info("[server] sending pong ...")
+					err = sendMsg(srv, []byte("pong"))
+					assert.NoError(t, err)
+				}
+			}(s)
+		})
+		assert.NoError(t, err)
+	}))
+
+	srvEndpoint := strings.TrimPrefix(srv.URL, "http://")
+
+	time.Sleep(snoozeTime)
+
+	t.Run("client cannot connect", func(t *testing.T) {
+		// we should have no clients at this point
+		p.mu.RLock()
+		assert.Equal(t, 0, len(p.clients))
+		p.mu.RUnlock()
+
+		// creating this client should fail
+		infoWithInvalidHostAddress := host.StreamInfo{
+			RemotePeerID:      "serverID",
+			RemotePeerAddress: "localhost:1234", // some wrong server address
+			ContextID:         "someContextID",
+			SessionID:         "testSessionID",
+		}
+
+		client, err := p.NewClientStream(infoWithInvalidHostAddress, context.Background(), "somePeerID", &tls.Config{})
+		assert.Error(t, err)
+		assert.Nil(t, client)
+
+		// we should have no clients
+		p.mu.RLock()
+		assert.Equal(t, 0, len(p.clients))
+		p.mu.RUnlock()
+	})
+
+	t.Run("many clients connect sequentially", func(t *testing.T) {
+		for i := range numStreams {
+			testClientRun(t, p, srvEndpoint, fmt.Sprintf("session-%d", i))
+		}
+	})
+
+	t.Run("many clients connect concurrently", func(t *testing.T) {
+		var wg sync.WaitGroup
+		for i := range numStreams {
+			wg.Add(1)
+			go func(i int) {
+				defer wg.Done()
+				testClientRun(t, p, srvEndpoint, fmt.Sprintf("session-%d", i))
+			}(i)
+		}
+		wg.Wait()
+	})
+
+	// we expect our client connection to still be open
+	p.mu.RLock()
+	assert.Equal(t, 1, len(p.clients))
+	p.mu.RUnlock()
+
+	wg.Wait()
+
+	// close server
+	srv.Close()
+
+	err := p.KillAll()
+	assert.NoError(t, err)
+
+	p.mu.RLock()
+	assert.Equal(t, 0, len(p.clients))
+	p.mu.RUnlock()
+}
+
+func testSetup(_ *testing.T) {
+	logSpec := cmp.Or(
+		os.Getenv("FABRIC_LOGGING_SPEC"),
+		"error",
+	)
+
+	logging.Init(logging.Config{
+		LogSpec: logSpec,
+		Writer:  os.Stdout,
+	})
+}
+
+func testClientRun(t *testing.T, p *MultiplexedProvider, srvEndpoint, sessionID string) {
+	ctx, cancel := context.WithCancel(context.Background())
+	info := host.StreamInfo{
+		RemotePeerID:      "serverID",
+		RemotePeerAddress: srvEndpoint,
+		ContextID:         "someContextID",
+		SessionID:         sessionID,
+	}
+	src := host.PeerID("somePeerID")
+	config := &tls.Config{}
+
+	client, err := p.NewClientStream(info, ctx, src, config)
+	assert.NoError(t, err)
+
+	// we should
+	p.mu.RLock()
+	assert.Equal(t, 1, len(p.clients))
+	p.mu.RUnlock()
+
+	for range totalMsg {
+		// send ping
+		clientLogger.Info("[client] sending ping ...")
+		err = sendMsg(client, []byte("ping"))
+		assert.NoError(t, err)
+
+		// expect pong
+		clientLogger.Info("[client] reading ...")
+		answer, err := readMsg(client)
+		assert.NoError(t, err)
+		assert.EqualValues(t, []byte("pong"), answer)
+	}
+
+	// gracefully shutdown our client
+	err = client.Close()
+	assert.NoError(t, err)
+
+	cancel()
+}
+
+func sendMsg(stream host.P2PStream, msg []byte) error {
+	// write length frame
+	lenBuf := make([]byte, binary.MaxVarintLen64)
+	_ = binary.PutUvarint(lenBuf, uint64(len(msg)))
+	_, err := stream.Write(lenBuf)
+	if err != nil {
+		return err
+	}
+
+	// write message
+	_, err = stream.Write(msg)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func readMsg(stream host.P2PStream) ([]byte, error) {
+	// read length frame
+	lenBuf := make([]byte, binary.MaxVarintLen64)
+	_, err := stream.Read(lenBuf)
+	if err != nil {
+		return nil, err
+	}
+
+	// read message
+	n, _ := binary.Uvarint(lenBuf)
+	msgBuf := make([]byte, n)
+	_, err = stream.Read(msgBuf)
+	if err != nil {
+		return nil, err
+	}
+
+	return msgBuf, nil
+}
diff --git a/platform/view/services/comm/host/rest/websocket/stream.go b/platform/view/services/comm/host/rest/websocket/stream.go
index a14777d2f..56e91f17a 100644
--- a/platform/view/services/comm/host/rest/websocket/stream.go
+++ b/platform/view/services/comm/host/rest/websocket/stream.go
@@ -11,7 +11,6 @@ import (
 	"io"
 
 	"github.com/gorilla/websocket"
-	"github.com/hyperledger-labs/fabric-smart-client/platform/common/utils"
 	host2 "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host"
 	"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host/rest"
 )
@@ -58,18 +57,19 @@ func (s *stream) readMessages(ctx context.Context) {
 	for {
 		select {
 		case <-ctx.Done():
-			logger.Warnf("Context for stream [%s] closed by us. Error: %w", s.Hash(), ctx.Err())
+			logger.Debugf("Context for stream [%s] closed by us. Error: %v", s.Hash(), ctx.Err())
 			s.reads <- streamEOF
+			_ = s.Close()
 			return
 		default:
 			_, msg, err := s.conn.ReadMessage()
 			if err != nil && (websocket.IsCloseError(err, websocket.CloseAbnormalClosure)) {
-				logger.Debugf("Websocket connection closed unexpectedly")
+				logger.Debugf("Websocket connection closed unexpectedly: %v", err)
 				s.reads <- streamEOF
-				utils.IgnoreErrorFunc(s.Close)
+				_ = s.Close()
 				return
 			}
-			logger.Debugf("Read message of length [%d] on [%s]. Error encountered: %w", len(msg), s.Hash(), err)
+			logger.Debugf("Read message of length [%d] on [%s]", len(msg), s.Hash())
 			s.reads <- result{value: msg, err: err}
 		}
 	}
@@ -133,7 +133,7 @@ func (s *stream) Write(p []byte) (int, error) {
 }
 
 func (s *stream) Close() error {
-	logger.Debugf("Close connection for context [%s]: %s", s.ContextID())
+	logger.Debugf("Close connection for context [%s]", s.ContextID())
 	s.cancel()
 	return s.conn.Close()
 }
diff --git a/platform/view/services/comm/host/rest/websocket/stream_test.go b/platform/view/services/comm/host/rest/websocket/stream_test.go
index 59b3227db..2d7354029 100644
--- a/platform/view/services/comm/host/rest/websocket/stream_test.go
+++ b/platform/view/services/comm/host/rest/websocket/stream_test.go
@@ -29,6 +29,8 @@ func newMockStream(conn *mockConn) host.P2PStream {
 type mockConn struct {
 	written chan []byte
 	read    chan []byte
+
+	once sync.Once
 }
 
 func (c *mockConn) ReadMessage() (int, []byte, error) {
@@ -39,8 +41,10 @@ func (c *mockConn) WriteMessage(_ int, data []byte) error {
 	return nil
 }
 func (c *mockConn) Close() error {
-	close(c.read)
-	close(c.written)
+	c.once.Do(func() {
+		close(c.read)
+		close(c.written)
+	})
 	return nil
 }
 func (c *mockConn) ReadValue(message proto.Message) error {


Reviews:
user: login: alexandrosfilios
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:53:25Z
user: login: alexandrosfilios
body: Left some comments
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T16:10:09Z
user: login: mbrandenburger
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T17:00:13Z
user: login: mbrandenburger
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T17:10:15Z
user: login: alexandrosfilios
body: some comments
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T13:38:58Z
user: login: alexandrosfilios
body: LGTM but please check that it works with FTS first
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:36:09Z

Review comments:
diff_hunk: @@ -11,7 +11,7 @@ import (
 	host2 "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host"
 )
 
-var logger = logging.MustGetLogger("view-sdk.services.comm.rest.host")
+var logger = logging.MustGetLogger("view-sdk.services.comm.host.rest.websocket")
path: platform/view/services/comm/host/rest/websocket/common.go
user: login: alexandrosfilios
body: you don't need to pass any params anymore
created_at: 2025-05-13T15:53:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,21 +77,38 @@ func NewMultiplexedProvider(tracerProvider trace.TracerProvider, metricsProvider
 		})),
 		m: newMetrics(metricsProvider),
 	}
+
+	// spawn ActiveSubConn tracker
+	p.trackerDone = StartTracker(func() {
+		sum := 0
+		p.mu.RLock()
+		for _, c := range p.clients {
+			c.mu.RLock()
+			sum += len(c.subConns)
path: platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
user: login: alexandrosfilios
body: I would avoid the locks here. You only need eventually consistent results, so I think it would be better to keep an atomic.Int for each client that keeps the length of the subConns. Then here you sum them. These results are just for tracking purposes (or for sanity checks in our tests), so using locks in production may add unnecessary overhead.
created_at: 2025-05-13T16:01:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,7 +182,7 @@ func newClientConn(conn *websocket.Conn, tracer trace.Tracer, m *Metrics, onClos
 
 func (c *multiplexedClientConn) newClientSubConn(ctx context.Context, src host2.PeerID, info host2.StreamInfo) (*stream, error) {
 	c.mu.Lock()
-	sc := c.newSubConn(NewSubConnId())
+	sc := c.newSubConn(strconv.FormatUint(c.subConnId.Add(1), 10))
path: platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
user: login: alexandrosfilios
body: at this point we could extract this to a method `c.newSubConnId() SubConnId` and define SubConnId as int64 instead of string to avoid the conversion. What do you think?
created_at: 2025-05-13T16:04:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,21 +77,38 @@ func NewMultiplexedProvider(tracerProvider trace.TracerProvider, metricsProvider
 		})),
 		m: newMetrics(metricsProvider),
 	}
+
+	// spawn ActiveSubConn tracker
+	p.trackerDone = StartTracker(func() {
+		sum := 0
+		p.mu.RLock()
+		for _, c := range p.clients {
+			c.mu.RLock()
+			sum += len(c.subConns)
path: platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
user: login: mbrandenburger
body: I did not touch the existing logic of the metric tracker; just moved it in order to be able to cancel the go routine - if needed. I would improve the actual tracking in another PR
created_at: 2025-05-13T17:00:13Z
author_association: MEMBER
diff_hunk: @@ -150,7 +182,7 @@ func newClientConn(conn *websocket.Conn, tracer trace.Tracer, m *Metrics, onClos
 
 func (c *multiplexedClientConn) newClientSubConn(ctx context.Context, src host2.PeerID, info host2.StreamInfo) (*stream, error) {
 	c.mu.Lock()
-	sc := c.newSubConn(NewSubConnId())
+	sc := c.newSubConn(strconv.FormatUint(c.subConnId.Add(1), 10))
path: platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
user: login: mbrandenburger
body: yip, we could change this -do you think we should do this as part of this PR?
The main reason why I touched the subConIDs at all is to avoid the package variable and thus keep the scope unique per base connection.
created_at: 2025-05-13T17:10:15Z
author_association: MEMBER
diff_hunk: @@ -150,7 +182,7 @@ func newClientConn(conn *websocket.Conn, tracer trace.Tracer, m *Metrics, onClos
 
 func (c *multiplexedClientConn) newClientSubConn(ctx context.Context, src host2.PeerID, info host2.StreamInfo) (*stream, error) {
 	c.mu.Lock()
-	sc := c.newSubConn(NewSubConnId())
+	sc := c.newSubConn(strconv.FormatUint(c.subConnId.Add(1), 10))
path: platform/view/services/comm/host/rest/websocket/multiplexed_provider.go
user: login: alexandrosfilios
body: we can do it on another pr. not a problem
created_at: 2025-05-19T13:41:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: mbrandenburger
created_at: 2025-05-20T14:52:40Z
body: > LGTM but please check that it works with FTS first

Thanks @alexandrosfilios - I've tested this with FTS successfully. See https://github.com/hyperledger-labs/fabric-token-sdk/actions/runs/15136645966
author_association: MEMBER
user: login: mbrandenburger
created_at: 2025-05-21T09:40:20Z
body: Let's create another issue to improve the websocket package further, for instance, by replacing mutexes by channels.
author_association: MEMBER