Move NATS client to nats package.

This commit is contained in:
Joachim Bauch 2025-12-10 20:44:51 +01:00
commit 5543046305
No known key found for this signature in database
GPG key ID: 77C1D22D53E15F02
17 changed files with 484 additions and 219 deletions

136
nats/client.go Normal file
View file

@ -0,0 +1,136 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2025 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"os"
"os/signal"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/strukturag/nextcloud-spreed-signaling/async"
"github.com/strukturag/nextcloud-spreed-signaling/log"
)
const (
initialConnectInterval = time.Second
maxConnectInterval = 8 * time.Second
LoopbackUrl = "nats://loopback"
DefaultURL = nats.DefaultURL
)
var (
ErrConnectionClosed = nats.ErrConnectionClosed
)
type Msg = nats.Msg
type Subscription interface {
Unsubscribe() error
}
type Client interface {
Close(ctx context.Context) error
Subscribe(subject string, ch chan *Msg) (Subscription, error)
Publish(subject string, message any) error
}
// The NATS client doesn't work if a subject contains spaces. As the room id
// can have an arbitrary format, we need to make sure the subject is valid.
// See "https://github.com/nats-io/nats.js/issues/158" for a similar report.
func GetEncodedSubject(prefix string, suffix string) string {
return prefix + "." + base64.StdEncoding.EncodeToString([]byte(suffix))
}
func NewClient(ctx context.Context, url string, options ...nats.Option) (Client, error) {
logger := log.LoggerFromContext(ctx)
if url == ":loopback:" {
logger.Printf("WARNING: events url %s is deprecated, please use %s instead", url, LoopbackUrl)
url = LoopbackUrl
}
if url == LoopbackUrl {
logger.Println("Using internal NATS loopback client")
return NewLoopbackClient(logger)
}
backoff, err := async.NewExponentialBackoff(initialConnectInterval, maxConnectInterval)
if err != nil {
return nil, err
}
client := &NativeClient{
logger: logger,
closed: make(chan struct{}),
}
options = append([]nats.Option{
nats.ClosedHandler(client.onClosed),
nats.DisconnectHandler(client.onDisconnected),
nats.ReconnectHandler(client.onReconnected),
nats.MaxReconnects(-1),
}, options...)
client.conn, err = nats.Connect(url, options...)
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
// The initial connect must succeed, so we retry in the case of an error.
for err != nil {
logger.Printf("Could not create connection (%s), will retry in %s", err, backoff.NextWait())
backoff.Wait(ctx)
if ctx.Err() != nil {
return nil, errors.New("interrupted")
}
client.conn, err = nats.Connect(url)
}
logger.Printf("Connection established to %s (%s)", removeURLCredentials(client.conn.ConnectedUrl()), client.conn.ConnectedServerId())
return client, nil
}
func Decode(msg *nats.Msg, vPtr any) (err error) {
switch arg := vPtr.(type) {
case *string:
// If they want a string and it is a JSON string, strip quotes
// This allows someone to send a struct but receive as a plain string
// This cast should be efficient for Go 1.3 and beyond.
str := string(msg.Data)
if strings.HasPrefix(str, `"`) && strings.HasSuffix(str, `"`) {
*arg = str[1 : len(str)-1]
} else {
*arg = str
}
case *[]byte:
*arg = msg.Data
default:
err = json.Unmarshal(msg.Data, arg)
}
return
}

159
nats/client_test.go Normal file
View file

@ -0,0 +1,159 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2026 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"testing"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
)
func TestGetEncodedSubject(t *testing.T) {
t.Parallel()
assert := assert.New(t)
encoded := GetEncodedSubject("foo", "this is the subject")
assert.NotContains(encoded, " ")
encoded = GetEncodedSubject("foo", "this-is-the-subject")
assert.NotContains(encoded, "this-is")
}
func TestDecodeToString(t *testing.T) {
t.Parallel()
assert := assert.New(t)
testcases := []struct {
data []byte
expected string
}{
{
[]byte(`""`),
"",
},
{
[]byte(`"foo"`),
"foo",
},
{
[]byte(`{"type":"foo"}`),
`{"type":"foo"}`,
},
{
[]byte(`1234`),
"1234",
},
}
for idx, tc := range testcases {
var dest string
if assert.NoError(Decode(&nats.Msg{
Data: tc.data,
}, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) {
assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data))
}
}
}
func TestDecodeToByteSlice(t *testing.T) {
t.Parallel()
assert := assert.New(t)
testcases := []struct {
data []byte
expected []byte
}{
{
[]byte(``),
[]byte{},
},
{
[]byte(`""`),
[]byte(`""`),
},
{
[]byte(`"foo"`),
[]byte(`"foo"`),
},
{
[]byte(`{"type":"foo"}`),
[]byte(`{"type":"foo"}`),
},
{
[]byte(`1234`),
[]byte(`1234`),
},
}
for idx, tc := range testcases {
var dest []byte
if assert.NoError(Decode(&nats.Msg{
Data: tc.data,
}, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) {
assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data))
}
}
}
func TestDecodeRegular(t *testing.T) {
t.Parallel()
assert := assert.New(t)
type testdata struct {
Type string `json:"type"`
Value any `json:"value"`
}
testcases := []struct {
data []byte
expected *testdata
}{
{
[]byte(`null`),
nil,
},
{
[]byte(`{"value":"bar","type":"foo"}`),
&testdata{
Type: "foo",
Value: "bar",
},
},
{
[]byte(`{"value":123,"type":"foo"}`),
&testdata{
Type: "foo",
Value: float64(123),
},
},
}
for idx, tc := range testcases {
var dest *testdata
if assert.NoError(Decode(&nats.Msg{
Data: tc.data,
}, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) {
assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data))
}
}
}

194
nats/loopback.go Normal file
View file

@ -0,0 +1,194 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2017 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"container/list"
"context"
"encoding/json"
"strings"
"sync"
"github.com/nats-io/nats.go"
"github.com/strukturag/nextcloud-spreed-signaling/log"
)
type LoopbackClient struct {
logger log.Logger
mu sync.Mutex
closed chan struct{}
// +checklocks:mu
subscriptions map[string]map[*loopbackSubscription]bool
// +checklocks:mu
wakeup sync.Cond
// +checklocks:mu
incoming list.List
}
func NewLoopbackClient(logger log.Logger) (Client, error) {
client := &LoopbackClient{
logger: logger,
closed: make(chan struct{}),
subscriptions: make(map[string]map[*loopbackSubscription]bool),
}
client.wakeup.L = &client.mu
go client.processMessages()
return client, nil
}
func (c *LoopbackClient) processMessages() {
defer close(c.closed)
c.mu.Lock()
defer c.mu.Unlock()
for {
for c.subscriptions != nil && c.incoming.Len() == 0 {
c.wakeup.Wait()
}
if c.subscriptions == nil {
// Client was closed.
break
}
msg := c.incoming.Remove(c.incoming.Front()).(*Msg)
c.processMessage(msg)
}
}
// +checklocks:c.mu
func (c *LoopbackClient) processMessage(msg *Msg) {
subs, found := c.subscriptions[msg.Subject]
if !found {
return
}
channels := make([]chan *Msg, 0, len(subs))
for sub := range subs {
channels = append(channels, sub.ch)
}
c.mu.Unlock()
defer c.mu.Lock()
for _, ch := range channels {
select {
case ch <- msg:
default:
c.logger.Printf("Slow consumer %s, dropping message", msg.Subject)
}
}
}
func (c *LoopbackClient) doClose() {
c.mu.Lock()
defer c.mu.Unlock()
c.subscriptions = nil
c.incoming.Init()
c.wakeup.Signal()
}
func (c *LoopbackClient) Close(ctx context.Context) error {
c.doClose()
select {
case <-c.closed:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
type loopbackSubscription struct {
subject string
client *LoopbackClient
ch chan *Msg
}
func (s *loopbackSubscription) Unsubscribe() error {
s.client.unsubscribe(s)
return nil
}
func (c *LoopbackClient) Subscribe(subject string, ch chan *Msg) (Subscription, error) {
if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") {
return nil, nats.ErrBadSubject
}
c.mu.Lock()
defer c.mu.Unlock()
if c.subscriptions == nil {
return nil, nats.ErrConnectionClosed
}
s := &loopbackSubscription{
subject: subject,
client: c,
ch: ch,
}
subs, found := c.subscriptions[subject]
if !found {
subs = make(map[*loopbackSubscription]bool)
c.subscriptions[subject] = subs
}
subs[s] = true
return s, nil
}
func (c *LoopbackClient) unsubscribe(s *loopbackSubscription) {
c.mu.Lock()
defer c.mu.Unlock()
if subs, found := c.subscriptions[s.subject]; found {
delete(subs, s)
if len(subs) == 0 {
delete(c.subscriptions, s.subject)
}
}
}
func (c *LoopbackClient) Publish(subject string, message any) error {
if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") {
return nats.ErrBadSubject
}
c.mu.Lock()
defer c.mu.Unlock()
if c.subscriptions == nil {
return nats.ErrConnectionClosed
}
msg := &Msg{
Subject: subject,
}
var err error
if msg.Data, err = json.Marshal(message); err != nil {
return err
}
c.incoming.PushBack(msg)
c.wakeup.Signal()
return nil
}

73
nats/loopback_test.go Normal file
View file

@ -0,0 +1,73 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2018 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/strukturag/nextcloud-spreed-signaling/log"
)
func CreateLoopbackClientForTest(t *testing.T) Client {
logger := log.NewLoggerForTest(t)
result, err := NewLoopbackClient(logger)
require.NoError(t, err)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
assert.NoError(t, result.Close(ctx))
})
return result
}
func TestLoopbackClient_Subscribe(t *testing.T) {
t.Parallel()
client := CreateLoopbackClientForTest(t)
testClient_Subscribe(t, client)
}
func TestLoopbackClient_PublishAfterClose(t *testing.T) {
t.Parallel()
client := CreateLoopbackClientForTest(t)
test_PublishAfterClose(t, client)
}
func TestLoopbackClient_SubscribeAfterClose(t *testing.T) {
t.Parallel()
client := CreateLoopbackClientForTest(t)
testClient_SubscribeAfterClose(t, client)
}
func TestLoopbackClient_BadSubjects(t *testing.T) {
t.Parallel()
client := CreateLoopbackClientForTest(t)
testClient_BadSubjects(t, client)
}

114
nats/native.go Normal file
View file

@ -0,0 +1,114 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2017 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"context"
"encoding/json"
"net/url"
"github.com/nats-io/nats.go"
"github.com/strukturag/nextcloud-spreed-signaling/log"
)
type NativeClient struct {
logger log.Logger
conn *nats.Conn
closed chan struct{}
}
func (c *NativeClient) URLs() []string {
return c.conn.Servers()
}
func (c *NativeClient) IsConnected() bool {
return c.conn.IsConnected()
}
func (c *NativeClient) ConnectedUrl() string {
return c.conn.ConnectedUrl()
}
func (c *NativeClient) ConnectedServerId() string {
return c.conn.ConnectedServerId()
}
func (c *NativeClient) ConnectedServerVersion() string {
return c.conn.ConnectedServerVersion()
}
func (c *NativeClient) ConnectedClusterName() string {
return c.conn.ConnectedClusterName()
}
func (c *NativeClient) Close(ctx context.Context) error {
c.conn.Close()
select {
case <-c.closed:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *NativeClient) FlushWithContext(ctx context.Context) error {
return c.conn.FlushWithContext(ctx)
}
func (c *NativeClient) onClosed(conn *nats.Conn) {
if err := conn.LastError(); err != nil {
c.logger.Printf("NATS client closed, last error %s", conn.LastError())
} else {
c.logger.Println("NATS client closed")
}
close(c.closed)
}
func (c *NativeClient) onDisconnected(conn *nats.Conn) {
c.logger.Println("NATS client disconnected")
}
func (c *NativeClient) onReconnected(conn *nats.Conn) {
c.logger.Printf("NATS client reconnected to %s (%s)", conn.ConnectedUrl(), conn.ConnectedServerId())
}
func (c *NativeClient) Subscribe(subject string, ch chan *Msg) (Subscription, error) {
return c.conn.ChanSubscribe(subject, ch)
}
func (c *NativeClient) Publish(subject string, message any) error {
data, err := json.Marshal(message)
if err != nil {
return err
}
return c.conn.Publish(subject, data)
}
func removeURLCredentials(u string) string {
if u, err := url.Parse(u); err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
return u
}

190
nats/native_test.go Normal file
View file

@ -0,0 +1,190 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2021 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/strukturag/nextcloud-spreed-signaling/log"
"github.com/strukturag/nextcloud-spreed-signaling/test"
)
func CreateLocalClientForTest(t *testing.T, options ...nats.Option) (*server.Server, int, Client) {
t.Helper()
server, port := StartLocalServer(t)
logger := log.NewLoggerForTest(t)
ctx := log.NewLoggerContext(t.Context(), logger)
result, err := NewClient(ctx, server.ClientURL(), options...)
require.NoError(t, err)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
assert.NoError(t, result.Close(ctx))
})
return server, port, result
}
func testClient_Subscribe(t *testing.T, client Client) {
require := require.New(t)
assert := assert.New(t)
dest := make(chan *Msg)
sub, err := client.Subscribe("foo", dest)
require.NoError(err)
ch := make(chan struct{})
var received atomic.Int32
maxPublish := int32(20)
ready := make(chan struct{})
quit := make(chan struct{})
defer close(quit)
go func() {
close(ready)
for {
select {
case <-dest:
total := received.Add(1)
if total == maxPublish {
if err := sub.Unsubscribe(); !assert.NoError(err) {
return
}
close(ch)
}
case <-quit:
return
}
}
}()
<-ready
for range maxPublish {
assert.NoError(client.Publish("foo", []byte("hello")))
// Allow NATS goroutines to process messages.
time.Sleep(10 * time.Millisecond)
}
<-ch
require.Equal(maxPublish, received.Load(), "Received wrong # of messages")
}
func TestClient_Subscribe(t *testing.T) { // nolint:paralleltest
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
_, _, client := CreateLocalClientForTest(t)
testClient_Subscribe(t, client)
})
}
func test_PublishAfterClose(t *testing.T, client Client) {
assert.NoError(t, client.Close(t.Context()))
assert.ErrorIs(t, client.Publish("foo", "bar"), nats.ErrConnectionClosed)
}
func TestClient_PublishAfterClose(t *testing.T) { // nolint:paralleltest
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
_, _, client := CreateLocalClientForTest(t)
test_PublishAfterClose(t, client)
})
}
func testClient_SubscribeAfterClose(t *testing.T, client Client) {
assert.NoError(t, client.Close(t.Context()))
ch := make(chan *Msg)
_, err := client.Subscribe("foo", ch)
assert.ErrorIs(t, err, nats.ErrConnectionClosed)
}
func TestClient_SubscribeAfterClose(t *testing.T) { // nolint:paralleltest
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
_, _, client := CreateLocalClientForTest(t)
testClient_SubscribeAfterClose(t, client)
})
}
func testClient_BadSubjects(t *testing.T, client Client) {
assert := assert.New(t)
subjects := []string{
"foo bar",
"foo.",
}
ch := make(chan *Msg)
for _, s := range subjects {
_, err := client.Subscribe(s, ch)
assert.ErrorIs(err, nats.ErrBadSubject, "Expected error for subject %s", s)
}
}
func TestClient_BadSubjects(t *testing.T) { // nolint:paralleltest
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
_, _, client := CreateLocalClientForTest(t)
testClient_BadSubjects(t, client)
})
}
func TestClient_MaxReconnects(t *testing.T) { // nolint:paralleltest
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
reconnectWait := time.Millisecond
server, port, client := CreateLocalClientForTest(t,
nats.ReconnectWait(reconnectWait),
nats.ReconnectJitter(0, 0),
)
c, ok := client.(*NativeClient)
require.True(ok, "wrong class: %T", client)
require.True(c.conn.IsConnected(), "not connected initially")
assert.Equal(server.ID(), c.conn.ConnectedServerId())
server.Shutdown()
server.WaitForShutdown()
// The NATS client tries to reconnect a maximum of 100 times by default.
time.Sleep(100 * reconnectWait)
for i := 0; i < 1000 && c.conn.IsConnected(); i++ {
time.Sleep(time.Millisecond)
}
require.False(c.conn.IsConnected(), "should be disconnected after server shutdown")
server, _ = StartLocalServerPort(t, port)
// Wait for automatic reconnection
for i := 0; i < 1000 && !c.conn.IsConnected(); i++ {
time.Sleep(time.Millisecond)
}
require.True(c.conn.IsConnected(), "not connected after restart")
assert.Equal(server.ID(), c.conn.ConnectedServerId())
})
}

74
nats/test_helpers.go Normal file
View file

@ -0,0 +1,74 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2025 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nats
import (
"context"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats-server/v2/test"
"github.com/stretchr/testify/assert"
)
func StartLocalServer(t *testing.T) (*server.Server, int) {
t.Helper()
return StartLocalServerPort(t, server.RANDOM_PORT)
}
func StartLocalServerPort(t *testing.T, port int) (*server.Server, int) {
t.Helper()
opts := test.DefaultTestOptions
opts.Port = port
opts.Cluster.Name = "testing"
srv := test.RunServer(&opts)
t.Cleanup(func() {
srv.Shutdown()
srv.WaitForShutdown()
})
return srv, opts.Port
}
func WaitForSubscriptionsEmpty(ctx context.Context, t *testing.T, client Client) {
t.Helper()
if c, ok := client.(*LoopbackClient); assert.True(t, ok, "expected LoopbackNatsClient, got %T", client) {
for {
c.mu.Lock()
count := len(c.subscriptions)
c.mu.Unlock()
if count == 0 {
break
}
select {
case <-ctx.Done():
c.mu.Lock()
assert.NoError(t, ctx.Err(), "Error waiting for subscriptions %+v to terminate", c.subscriptions)
c.mu.Unlock()
return
default:
time.Sleep(time.Millisecond)
}
}
}
}