refactor(worker): collapse session goroutines into single runSession loop

Replace the five-goroutine session model (RunDialer, RunKeepalive,
RunReader, RunHeartbeatForwarder, RunStopMonitor, Session) with a single
DefaultWorker.runSession method containing two select loops: one
pre-connection and one connected. Ephemeral dial goroutines replace
RunDialer; the keepalive timer and heartbeat reset are inlined. No
exported building-block symbols remain.

Consolidate worker_dialer_test.go, worker_session_test.go, and
worker_start_test.go into worker_test.go. Add seven new behavioral
tests covering dial failure, keepalive-driven dial replacement,
pre-connection stop, message delivery with timestamp, sustained
activity and pong resetting the keepalive timer, keepalive-triggered
reconnect, and nil connection pointer after disconnect.
Update EXTEND.md and README.md to remove references to the deleted

building blocks and document the single worker replacement pattern
This commit is contained in:
Jay
2026-05-20 14:01:01 -04:00
parent b44a46ed2f
commit cda6d286ab
10 changed files with 811 additions and 1659 deletions
+4
View File
@@ -0,0 +1,4 @@
# go-honeybee
## Build
- Run `go fmt` on every edited file before staging.
+3 -19
View File
@@ -65,27 +65,11 @@ The pool calls the factory under its write lock when `Connect` is called. The fa
The factory is set via `honeybee.WithWorkerFactory` on the pool config. The factory is set via `honeybee.WithWorkerFactory` on the pool config.
### Building Blocks ### Replacing the Worker
**`RunDialer(id, ctx, pool, dial, newConn, logger)`** Listens on `dial` for connection requests. On each signal, calls `connect` to dial a new `*transport.Connection`. While a dial is in progress, drains additional `dial` signals so that at most one dial runs at a time. On failure, logs the error and waits for the next `dial` signal. On success, sends the connection on `newConn`. Exits when `ctx` is cancelled. Satisfy the `Worker` interface and register your implementation via `honeybee.WithWorkerFactory`. Your worker is responsible for the full connection lifecycle: dialing and redialing, managing connection state, forwarding received messages to `pool.Inbox`, emitting `EventConnected` and `EventDisconnected` to `pool.Events`, and incrementing `pool.InboxCounter` for each message forwarded.
**`RunKeepalive(ctx, heartbeat, keepalive, timeout, logger)`** Monitors `heartbeat`. Resets a timer on each signal. When the timer fires, sends a signal on `keepalive` to notify the session that the connection should be replaced. When `timeout` is zero, keepalive is disabled: it drains `heartbeat` without acting and exits when `ctx` is cancelled. `DefaultWorker`'s source is the authoritative reference for how those responsibilities are met.
**`RunReader(id, ctx, onStop, conn, inbox, heartbeat, logger)`** Reads from `conn.Incoming()` until the channel closes or `ctx` is cancelled. Builds an `InboxMessage` inline with the peer ID and writes it directly to `inbox`. Sends a signal on `heartbeat` for each message. On exit, calls `conn.Close()` and then `onStop`.
**`RunHeartbeatForwarder(ctx, conn, heartbeat, logger)`** Reads from `conn.Heartbeat()` and forwards each signal to `heartbeat`. Propagates pong replies into the worker's heartbeat channel so pongs reset the keepalive timer alongside data messages and sends.
**`RunStopMonitor(ctx, onStop, conn, keepalive, logger)`** Waits for either `ctx.Done` or a signal on `keepalive`. On either, calls `conn.Close()` and then `onStop`. This is how a keepalive expiry propagates into a session tear-down.
**`Session`** The coordination struct that ties the above blocks together for one connection lifecycle. `Session.Start` runs a loop: request a dial, wait for a connection, run `RunReader`, `RunHeartbeatForwarder`, and `RunStopMonitor` concurrently, wait for them to finish, emit `EventDisconnected`, sleep for `ReconnectDelay`, then repeat. `Session` is exported so it can be embedded or used directly in a custom worker.
### Replacement Patterns
**Swap one block.** The most common case is replacing `RunReader` to intercept or annotate inbound messages, or replacing `RunKeepalive` with a different activity metric. Reuse `Session` for the connection lifecycle and substitute the one goroutine you need to change.
**Replace the session loop.** Construct your own loop using `RunDialer`, `RunKeepalive`, and the session-level blocks. This gives you control over reconnection logic, back-off behavior, or multi-connection strategies while keeping the lower-level I/O blocks intact.
**Implement from scratch.** Satisfy the `Worker` interface directly. You are responsible for dialing, managing connection state, forwarding messages to `pool.Inbox`, emitting `honeybee.EventConnected` and `honeybee.EventDisconnected` to `pool.Events`, and incrementing `pool.InboxCounter`.
## Factory Constraints ## Factory Constraints
+2 -2
View File
@@ -266,9 +266,9 @@ connStats := conn.Stats() // conn is a *transport.Connection
## Extending Pools ## Extending Pools
The pool owns peer registration, event plumbing, and lifecycle. The worker owns what happens on the wire. The default worker can be replaced entirely or composed from the exported `Run*` building blocks that Honeybee provides. The pool owns peer registration, event plumbing, and lifecycle. The worker owns what happens on the wire. The default worker can be replaced entirely via `WorkerFactory`.
See EXTEND.md for the worker interface contract, the `PoolPlugin` fields, and the available building blocks for the pool worker. See EXTEND.md for the worker interface contract, the `PoolPlugin` fields, and extension patterns.
## Configuration ## Configuration
+162 -350
View File
@@ -97,39 +97,10 @@ func (w *DefaultWorker) Start(pool PoolPlugin) {
w.logger.Debug("starting") w.logger.Debug("starting")
} }
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
keepalive := make(chan struct{}, 1)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(3) wg.Go(func() {
w.runSession(w.ctx, pool)
go func() { })
defer wg.Done()
RunDialer(w.id, w.ctx, pool, dial, newConn, w.handler, w.logger)
}()
go func() {
defer wg.Done()
RunKeepalive(w.ctx, w.heartbeat, keepalive, w.config.KeepaliveTimeout, w.logger)
}()
go func() {
defer wg.Done()
session := &Session{
id: w.id,
connPtr: &w.conn,
poolInbox: pool.Inbox,
heartbeat: w.heartbeat,
dial: dial,
keepalive: keepalive,
newConn: newConn,
reconnectDelay: w.config.ReconnectDelay,
restartCount: w.restartCount,
logger: w.logger,
}
session.Start(w.ctx, pool)
}()
if w.logger != nil { if w.logger != nil {
w.logger.Info("started") w.logger.Info("started")
@@ -142,6 +113,165 @@ func (w *DefaultWorker) Start(pool PoolPlugin) {
} }
} }
func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
newConn := make(chan *transport.Connection, 1)
var timer *time.Timer
if w.config.KeepaliveTimeout > 0 {
if w.logger != nil {
w.logger.Debug("keepalive: enabled", "timeout", w.config.KeepaliveTimeout)
}
timer = time.NewTimer(w.config.KeepaliveTimeout)
defer timer.Stop()
} else {
if w.logger != nil {
w.logger.Debug("keepalive: disabled")
}
}
resetTimer := func() {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(w.config.KeepaliveTimeout)
}
timerC := func() <-chan time.Time {
if timer == nil {
return nil
}
return timer.C
}
var dialCancel context.CancelFunc
spawnDial := func() {
if dialCancel != nil {
dialCancel()
}
var dialCtx context.Context
dialCtx, dialCancel = context.WithCancel(ctx)
if w.logger != nil {
w.logger.Debug("session: requesting connection")
}
go func() {
conn, err := connect(w.id, dialCtx, pool, w.handler)
if err != nil {
if w.logger != nil {
w.logger.Warn("dialer: dial failed")
}
return
}
select {
case newConn <- conn:
case <-dialCtx.Done():
conn.Close()
}
}()
}
for {
// spawn initial dial for this reconnect cycle
spawnDial()
// obtain new connection
var conn *transport.Connection
preConn:
for {
select {
case <-ctx.Done():
if dialCancel != nil {
dialCancel()
}
return
case <-w.heartbeat:
resetTimer()
case <-timerC():
if w.logger != nil {
w.logger.Info("keepalive: no activity observed")
}
timer.Reset(w.config.KeepaliveTimeout)
spawnDial()
case conn = <-newConn:
if w.logger != nil {
w.logger.Debug("session: connected")
}
break preConn
}
}
// set up new connection
w.conn.Store(conn)
pool.Events <- PoolEvent{ID: w.id, Kind: EventConnected, At: time.Now()}
if w.logger != nil {
w.logger.Info("session: started")
}
// run session loop
conn_loop:
for {
select {
case <-ctx.Done():
break conn_loop
case <-w.heartbeat:
resetTimer()
case <-timerC():
if w.logger != nil {
w.logger.Info("keepalive: no activity observed")
}
timer.Reset(w.config.KeepaliveTimeout)
break conn_loop
case data, ok := <-conn.Incoming():
if !ok {
if w.logger != nil {
w.logger.Debug("reader: disconnected")
}
break conn_loop
}
pool.Inbox <- types.InboxMessage{
ID: w.id,
Data: data,
ReceivedAt: time.Now(),
}
resetTimer()
case <-conn.Heartbeat():
if w.logger != nil {
w.logger.Debug("ping-pong heartbeat")
}
resetTimer()
}
}
conn.Close()
if w.logger != nil {
w.logger.Info("session: ended")
}
// tear down connection
w.conn.Store(nil)
pool.Events <- PoolEvent{ID: w.id, Kind: EventDisconnected, At: time.Now()}
// exit if worker is shutting down
select {
case <-ctx.Done():
return
default:
}
// refresh session
time.Sleep(w.config.ReconnectDelay)
w.restartCount.Add(1)
}
}
func (w *DefaultWorker) Stop() { func (w *DefaultWorker) Stop() {
if w.logger != nil { if w.logger != nil {
w.logger.Debug("shutting down") w.logger.Debug("shutting down")
@@ -195,269 +325,6 @@ func (w *DefaultWorker) Stats() WorkerStats {
} }
} }
type Session struct {
id string
connPtr *atomic.Pointer[transport.Connection]
poolInbox chan<- types.InboxMessage
heartbeat chan<- struct{}
dial chan<- struct{}
keepalive <-chan struct{}
newConn <-chan *transport.Connection
reconnectDelay time.Duration
restartCount *atomic.Uint64
logger *slog.Logger
}
func (s *Session) Start(
ctx context.Context,
pool PoolPlugin,
) {
for {
if s.logger != nil {
s.logger.Debug("session: requesting connection")
}
// request new connection
select {
case s.dial <- struct{}{}:
default:
}
// obtain new connection
var conn *transport.Connection
preConn:
for {
select {
case <-ctx.Done():
return
case <-s.keepalive:
select {
case s.dial <- struct{}{}:
if s.logger != nil {
s.logger.Debug("session: requesting connection")
}
default:
}
case conn = <-s.newConn:
if s.logger != nil {
s.logger.Debug("session: connected")
}
break preConn
}
}
// set up new connection
s.connPtr.Store(conn)
pool.Events <- PoolEvent{ID: s.id, Kind: EventConnected, At: time.Now()}
// set up session context
sctx, scancel := context.WithCancel(ctx)
onStop := func() { scancel() }
// start session
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
RunReader(s.id, sctx, onStop, conn, s.poolInbox, s.heartbeat, s.logger)
}()
go func() {
defer wg.Done()
RunHeartbeatForwarder(sctx, conn, s.heartbeat, s.logger)
}()
go func() {
defer wg.Done()
RunStopMonitor(sctx, onStop, conn, s.keepalive, s.logger)
}()
if s.logger != nil {
s.logger.Info("session: started")
}
// complete session
wg.Wait()
if s.logger != nil {
s.logger.Info("session: ended")
}
// tear down connection
s.connPtr.Store(nil)
pool.Events <- PoolEvent{ID: s.id, Kind: EventDisconnected, At: time.Now()}
// exit if worker is shutting down
select {
case <-ctx.Done():
return
default:
}
// refresh session
time.Sleep(s.reconnectDelay)
s.restartCount.Add(1)
}
}
func RunReader(
id string,
ctx context.Context,
onStop func(),
conn *transport.Connection,
poolInbox chan<- types.InboxMessage,
heartbeat chan<- struct{},
logger *slog.Logger,
) {
defer func() {
if logger != nil {
logger.Debug("reader: stopping")
}
conn.Close()
onStop()
}()
for {
select {
case <-ctx.Done():
return
case data, ok := <-conn.Incoming():
if !ok {
// connection has closed
if logger != nil {
logger.Debug("reader: disconnected")
}
return
}
// send message forward
poolInbox <- types.InboxMessage{
ID: id,
Data: data,
ReceivedAt: time.Now(),
}
// send heartbeat
select {
case heartbeat <- struct{}{}:
case <-ctx.Done():
return
}
}
}
}
func RunHeartbeatForwarder(
ctx context.Context,
conn *transport.Connection,
heartbeat chan<- struct{},
logger *slog.Logger,
) {
for {
select {
case <-ctx.Done():
return
case <-conn.Heartbeat():
select {
case heartbeat <- struct{}{}:
if logger != nil {
logger.Debug("ping-pong heartbeat")
}
case <-ctx.Done():
return
}
}
}
}
func RunStopMonitor(
ctx context.Context,
onStop func(),
conn *transport.Connection,
keepalive <-chan struct{},
logger *slog.Logger,
) {
defer func() {
if logger != nil {
logger.Debug("stop monitor: stopping")
}
conn.Close()
onStop()
}()
select {
case <-ctx.Done():
case <-keepalive:
if logger != nil {
logger.Debug("stop monitor: stopping: keepalive")
}
}
}
func RunKeepalive(
ctx context.Context,
heartbeat <-chan struct{},
keepalive chan<- struct{},
timeout time.Duration,
logger *slog.Logger,
) {
// disable keepalive timeout if not configured
if timeout <= 0 {
if logger != nil {
logger.Debug("keepalive: disabled")
}
// drain heartbeats
// wait for cancel and exit
for {
select {
case <-heartbeat:
case <-ctx.Done():
return
}
}
}
if logger != nil {
logger.Debug("keepalive: enabled", "timeout", timeout)
}
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-heartbeat:
// drain the timer channel and reset
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(timeout)
// timer completed
case <-timer.C:
// send keepalive signal, then reset the timer
if logger != nil {
logger.Info("keepalive: no activity observed")
}
select {
case keepalive <- struct{}{}:
default:
}
timer.Reset(timeout)
}
}
}
func connect( func connect(
id string, id string,
ctx context.Context, ctx context.Context,
@@ -472,58 +339,3 @@ func connect(
conn.SetDialer(pool.Dialer) conn.SetDialer(pool.Dialer)
return conn, conn.Connect(ctx) return conn, conn.Connect(ctx)
} }
func RunDialer(
id string,
ctx context.Context,
pool PoolPlugin,
dial <-chan struct{},
newConn chan<- *transport.Connection,
handler slog.Handler,
logger *slog.Logger,
) {
for {
select {
case <-ctx.Done():
return
case <-dial:
if logger != nil {
logger.Debug("dialer: dialing")
}
// dial a new connection
conn, err := connect(id, ctx, pool, handler)
// send error if dial failed and continue
if err != nil {
if logger != nil {
logger.Warn("dialer: dial failed")
}
continue
}
if logger != nil {
logger.Debug("dialer: connected")
}
// drain any redundant signals that arrived during the dial
for {
select {
case <-dial:
default:
goto drained
}
}
drained:
// send the new connection or close and exit
select {
case newConn <- conn:
case <-ctx.Done():
conn.Close()
return
}
}
}
}
-220
View File
@@ -1,220 +0,0 @@
package honeybee
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"github.com/stretchr/testify/assert"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestRunDialer(t *testing.T) {
t.Run("successful dial delivers connection to newConn", func(t *testing.T) {
url := "wss://test"
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
ctx := t.Context()
mockSocket := honeybeetest.NewMockSocket()
pool := PoolPlugin{
Dialer: &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return mockSocket, nil, nil
},
},
}
go RunDialer(url, ctx, pool, dial, newConn, nil, nil)
dial <- struct{}{}
honeybeetest.Eventually(t, func() bool {
select {
case <-newConn:
return true
default:
return false
}
}, "expected new connection")
})
t.Run("concurrent dial signals are drained; only one connection produced.",
func(t *testing.T) {
url := "wss://test"
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
ctx := t.Context()
gate := make(chan struct{})
dialCount := atomic.Int32{}
mockSocket := honeybeetest.NewMockSocket()
connConfig := &transport.ConnectionConfig{Retry: nil} // disable retry
started := make(chan struct{})
startOnce := sync.Once{}
pool := PoolPlugin{
Dialer: &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
dialCount.Add(1)
startOnce.Do(func() { close(started) })
<-gate
return mockSocket, nil, nil
},
},
ConnectionConfig: connConfig,
}
go RunDialer(url, ctx, pool, dial, newConn, nil, nil)
dial <- struct{}{}
// wait for dial to start blocking on gate
<-started
// flood dial while dialer is blocked
for range 5 {
select {
case dial <- struct{}{}:
default:
}
}
close(gate)
// connection is cleared to connect
honeybeetest.Eventually(t, func() bool {
select {
case <-newConn:
return true
default:
return false
}
}, "expected new connection")
// number of dials < number of dial requests
honeybeetest.Never(t, func() bool {
return dialCount.Load() >= 5
}, "expected fewer dials than requests")
// dial channel still writable
select {
case dial <- struct{}{}:
default:
t.Fatal("dial channel should still accept sends")
}
})
t.Run("dial failure emits error, succeeds on next signal", func(t *testing.T) {
url := "wss://test"
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
ctx := t.Context()
// use atomic counter to fail first dial and pass second
dialCount := atomic.Int32{}
mockSocket := honeybeetest.NewMockSocket()
connConfig := &transport.ConnectionConfig{Retry: nil} // disable retry
pool := PoolPlugin{
Dialer: &honeybeetest.MockDialer{
DialContextFunc: func(
context.Context, string, http.Header,
) (types.Socket, *http.Response, error) {
if dialCount.Add(1) == 1 {
// fail first
return nil, nil, fmt.Errorf("dial failed")
}
// pass second
return mockSocket, nil, nil
},
},
ConnectionConfig: connConfig,
}
go RunDialer(url, ctx, pool, dial, newConn, nil, nil)
dial <- struct{}{}
dial <- struct{}{}
honeybeetest.Eventually(t, func() bool {
select {
case <-newConn:
return true
default:
return false
}
}, "expected new connection")
})
t.Run("exits on context cancellation", func(t *testing.T) {
url := "wss://test"
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
ctx, cancel := context.WithCancel(context.Background())
pool := PoolPlugin{}
done := make(chan struct{})
go func() {
RunDialer(url, ctx, pool, dial, newConn, nil, nil)
close(done)
}()
cancel()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected done signal")
})
t.Run("context cancelled during in-progress dial exits without delivering connection", func(t *testing.T) {
url := "wss://test"
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
ctx, cancel := context.WithCancel(context.Background())
pool := PoolPlugin{
ConnectionConfig: &transport.ConnectionConfig{Retry: nil},
Dialer: &honeybeetest.MockDialer{
DialContextFunc: func(ctx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
// block until context is cancelled
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
}
},
},
}
done := make(chan struct{})
go func() {
RunDialer(url, ctx, pool, dial, newConn, nil, nil)
close(done)
}()
dial <- struct{}{}
// wait for dialer to block
time.Sleep(20 * time.Millisecond)
cancel()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected done signal")
// no connection was sent
assert.Empty(t, newConn)
})
}
-99
View File
@@ -1,99 +0,0 @@
package honeybee
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"testing"
"time"
)
func TestRunKeepalive(t *testing.T) {
t.Run("heartbeat resets timer, no keepalive signal fired", func(t *testing.T) {
heartbeat := make(chan struct{})
keepalive := make(chan struct{}, 1)
timeout := 200 * time.Millisecond
ctx := t.Context()
go RunKeepalive(ctx, heartbeat, keepalive, timeout, nil)
// send heartbeats faster than the timeout
for range 5 {
time.Sleep(20 * time.Millisecond)
heartbeat <- struct{}{}
}
// because the timer is being reset, keepalive signal should not be sent
honeybeetest.Never(t, func() bool {
select {
case <-keepalive:
return true
default:
return false
}
}, "unexpected keepalive signal")
})
t.Run("keepalive timeout fires signal", func(t *testing.T) {
heartbeat := make(chan struct{}, 1)
keepalive := make(chan struct{}, 1)
timeout := 20 * time.Millisecond
ctx := t.Context()
go RunKeepalive(ctx, heartbeat, keepalive, timeout, nil)
// send no heartbeats, wait for timeout and keepalive signal
honeybeetest.Eventually(t, func() bool {
select {
case <-keepalive:
return true
default:
return false
}
}, "expected keepalive signal")
})
t.Run("exits on context cancellation", func(t *testing.T) {
heartbeat := make(chan struct{}, 1)
keepalive := make(chan struct{}, 1)
timeout := 20 * time.Second
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunKeepalive(ctx, heartbeat, keepalive, timeout, nil)
close(done)
}()
cancel()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected done signal")
})
t.Run("disabled keepalive drains heartbeats without blocking", func(t *testing.T) {
heartbeat := make(chan struct{})
keepalive := make(chan struct{}, 1)
ctx := t.Context()
go RunKeepalive(ctx, heartbeat, keepalive, 0, nil)
// these must not block
for range 5 {
heartbeat <- struct{}{}
}
honeybeetest.Never(t, func() bool {
select {
case <-keepalive:
return true
default:
return false
}
}, "keepalive signal should not fire when disabled")
})
}
-229
View File
@@ -1,229 +0,0 @@
package honeybee
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"io"
"sync/atomic"
"testing"
"time"
)
func TestRunReader(t *testing.T) {
t.Run("message arrives with correct data and non-zero receivedAt", func(t *testing.T) {
conn, _, incomingData, _ := setupTestConnection(t)
defer conn.Close()
inbox := make(chan types.InboxMessage, 1)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for range heartbeat {
}
}()
go RunReader("wss://test", ctx, cancel, conn, inbox, heartbeat, nil)
before := time.Now()
incomingData <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: []byte("hello"),
}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-inbox:
return string(msg.Data) == "hello" && msg.ReceivedAt.After(before)
default:
return false
}
}, "expected message")
})
t.Run("heartbeat receives one signal per message", func(t *testing.T) {
conn, _, incomingData, _ := setupTestConnection(t)
defer conn.Close()
inbox := make(chan types.InboxMessage, 10)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
received := atomic.Int32{}
go func() {
for range heartbeat {
received.Add(1)
}
}()
go func() {
for range inbox {
}
}()
go RunReader("wss://test", ctx, cancel, conn, inbox, heartbeat, nil)
const count = 3
for i := range count {
incomingData <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: fmt.Appendf(nil, "msg-%d", i),
}
}
honeybeetest.Eventually(t, func() bool {
return received.Load() == count
}, fmt.Sprintf("expected %d messages", count))
})
t.Run("incoming channel close calls conn.Close and onStop", func(t *testing.T) {
conn, _, incomingData, _ := setupTestConnection(t)
inbox := make(chan types.InboxMessage, 1)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for range heartbeat {
}
}()
go func() {
for range inbox {
}
}()
go RunReader("wss://test", ctx, cancel, conn, inbox, heartbeat, nil)
// induce connection closure via reader
incomingData <- honeybeetest.MockIncomingData{Err: io.EOF}
err := <-conn.Errors()
assert.ErrorIs(t, err, io.EOF)
honeybeetest.Eventually(t, func() bool {
return conn.State() == transport.StateClosed
}, "expected closed state")
honeybeetest.Eventually(t, func() bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}, "expected context to cancel")
})
t.Run("sessionDone close calls conn.Close and onStop", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
inbox := make(chan types.InboxMessage, 1)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
go RunReader("wss://test", ctx, cancel, conn, inbox, heartbeat, nil)
cancel()
honeybeetest.Eventually(t, func() bool {
return conn.State() == transport.StateClosed
}, "expected closed state")
honeybeetest.Eventually(t, func() bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}, "expected context to cancel")
})
}
func TestHeartbeatForwarder(t *testing.T) {
t.Run("connection level heartbeat propagates", func(t *testing.T) {
socket, _, _ := honeybeetest.SetupTestSocket(t)
var pongHandler func(string) error
socket.SetPongHandlerFunc = func(h func(string) error) { pongHandler = h }
conn, err := transport.NewConnectionFromSocket(context.Background(), socket, nil, nil)
assert.NoError(t, err)
heartbeat := make(chan struct{}, 1)
ctx := t.Context()
go RunHeartbeatForwarder(ctx, conn, heartbeat, nil)
honeybeetest.Eventually(t, func() bool {
return pongHandler != nil
}, "expected Connection to register PongHandler")
if pongHandler == nil {
t.Fatal("pong handler was never set")
}
pongHandler("") // Trigger pong
select {
case <-heartbeat:
case <-time.After(time.Second):
t.Fatal("pong did not propagate to worker heartbeat")
}
})
}
func TestRunStopMonitor(t *testing.T) {
t.Run("keepalive signal calls conn.Close and cancel", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
keepalive := make(chan struct{}, 1)
go RunStopMonitor(ctx, cancel, conn, keepalive, nil)
keepalive <- struct{}{}
honeybeetest.Eventually(t, func() bool {
return conn.State() == transport.StateClosed
}, "expected closed state")
honeybeetest.Eventually(t, func() bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}, "expected context to cancel")
})
t.Run("ctx.Done calls conn.Close and cancel", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
keepalive := make(chan struct{})
go RunStopMonitor(ctx, cancel, conn, keepalive, nil)
cancel()
honeybeetest.Eventually(t, func() bool {
return conn.State() == transport.StateClosed
}, "expected closed state")
honeybeetest.Eventually(t, func() bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}, "expected context to cancel")
})
}
-441
View File
@@ -1,441 +0,0 @@
package honeybee
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"sync/atomic"
"testing"
)
func drainEvent(t *testing.T, events <-chan PoolEvent, kind PoolEventKind) {
t.Helper()
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == kind
default:
return false
}
}, fmt.Sprintf("expected %s event", kind))
}
type testVars struct {
id string
dial chan struct{}
keepalive chan struct{}
heartbeat chan struct{}
newConn chan *transport.Connection
poolInbox chan types.InboxMessage
conn *transport.Connection
mockSocket *honeybeetest.MockSocket
incomingData chan honeybeetest.MockIncomingData
outgoingData chan honeybeetest.MockOutgoingData
connPtr *atomic.Pointer[transport.Connection]
}
func setup(t *testing.T) (
ctx context.Context,
cancel context.CancelFunc,
vars testVars,
) {
t.Helper()
ctx, cancel = context.WithCancel(context.Background())
conn, mockSocket, incomingData, outgoingData := setupTestConnection(t)
vars = testVars{
id: "wss://test",
dial: make(chan struct{}, 1),
keepalive: make(chan struct{}, 1),
heartbeat: make(chan struct{}, 1),
newConn: make(chan *transport.Connection, 1),
poolInbox: make(chan types.InboxMessage, 256),
conn: conn,
mockSocket: mockSocket,
incomingData: incomingData,
outgoingData: outgoingData,
connPtr: &atomic.Pointer[transport.Connection]{},
}
return
}
func expectDial(t *testing.T, dial <-chan struct{}) {
t.Helper()
honeybeetest.Eventually(t, func() bool {
select {
case <-dial:
return true
default:
return false
}
}, "expected dial signal")
}
func TestRunSessionDial(t *testing.T) {
t.Run("fires dial immediately on entry", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
pool := PoolPlugin{Events: make(chan PoolEvent, 10)}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
go session.Start(ctx, pool)
expectDial(t, v.dial)
})
t.Run("keepalive fires dial", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
pool := PoolPlugin{Events: make(chan PoolEvent, 10)}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
go session.Start(ctx, pool)
// drain initial dial
expectDial(t, v.dial)
v.keepalive <- struct{}{}
expectDial(t, v.dial)
})
t.Run("multiple keepalive signals each fire dial", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
pool := PoolPlugin{Events: make(chan PoolEvent, 10)}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
go session.Start(ctx, pool)
// drain initial dial
expectDial(t, v.dial)
for range 3 {
v.keepalive <- struct{}{}
expectDial(t, v.dial)
}
})
}
func TestRunSessionConnect(t *testing.T) {
t.Run("connection pointer set after newConn received", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
pool := PoolPlugin{Events: make(chan PoolEvent, 10)}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
go session.Start(ctx, pool)
v.newConn <- v.conn
honeybeetest.Eventually(t, func() bool {
return v.connPtr.Load() != nil
}, "expected connection pointer to be set")
})
t.Run("EventConnected emitted", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
go session.Start(ctx, pool)
v.newConn <- v.conn
honeybeetest.Eventually(t, func() bool {
select {
case event := <-events:
return event.ID == v.id && event.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
})
}
func TestRunSessionDisconnect(t *testing.T) {
t.Run("EventDisconnected emitted on connection close", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
restartCount: &atomic.Uint64{},
}
go session.Start(ctx, pool)
v.newConn <- v.conn
drainEvent(t, events, EventConnected)
close(v.incomingData)
drainEvent(t, events, EventDisconnected)
})
t.Run("connection pointer cleared after disconnect", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
restartCount: &atomic.Uint64{},
}
go session.Start(ctx, pool)
v.newConn <- v.conn
drainEvent(t, events, EventConnected)
close(v.incomingData)
drainEvent(t, events, EventDisconnected)
honeybeetest.Eventually(t, func() bool {
return v.connPtr.Load() == nil
}, "expected connection pointer to be nil")
})
t.Run("dial fires again after disconnect", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
restartCount: &atomic.Uint64{},
}
go session.Start(ctx, pool)
v.newConn <- v.conn
drainEvent(t, events, EventConnected)
// drain the initial dial signal before disconnecting
<-v.dial
close(v.incomingData)
drainEvent(t, events, EventDisconnected)
honeybeetest.Eventually(t, func() bool {
select {
case <-v.dial:
return true
default:
return false
}
}, "expected dial signal after disconnect")
})
t.Run("second connection cycle emits EventConnected", func(t *testing.T) {
ctx, cancel, v := setup(t)
defer cancel()
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
restartCount: &atomic.Uint64{},
}
go session.Start(ctx, pool)
v.newConn <- v.conn
drainEvent(t, events, EventConnected)
close(v.incomingData)
drainEvent(t, events, EventDisconnected)
conn2, _, _, _ := setupTestConnection(t)
v.newConn <- conn2
drainEvent(t, events, EventConnected)
})
}
func TestRunSessionCancellation(t *testing.T) {
t.Run("ctx cancelled pre-connection exits without emitting events", func(t *testing.T) {
ctx, cancel, v := setup(t)
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
done := make(chan struct{})
go func() {
defer close(done)
session.Start(ctx, pool)
}()
cancel()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected runSession to exit")
honeybeetest.Never(t, func() bool {
select {
case <-events:
return true
default:
return false
}
}, "expected no events emitted")
})
t.Run("ctx cancelled post-connection emits EventDisconnected", func(t *testing.T) {
ctx, cancel, v := setup(t)
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
done := make(chan struct{})
go func() {
defer close(done)
session.Start(ctx, pool)
}()
v.newConn <- v.conn
drainEvent(t, events, EventConnected)
cancel()
drainEvent(t, events, EventDisconnected)
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected runSession to exit")
})
t.Run("ctx cancelled post-connection clears connection pointer", func(t *testing.T) {
ctx, cancel, v := setup(t)
events := make(chan PoolEvent, 10)
pool := PoolPlugin{Events: events}
session := &Session{
id: v.id,
connPtr: v.connPtr,
poolInbox: v.poolInbox,
heartbeat: v.heartbeat,
dial: v.dial,
keepalive: v.keepalive,
newConn: v.newConn,
}
done := make(chan struct{})
go func() {
defer close(done)
session.Start(ctx, pool)
}()
v.newConn <- v.conn
drainEvent(t, events, EventConnected)
cancel()
drainEvent(t, events, EventDisconnected)
honeybeetest.Eventually(t, func() bool {
return v.connPtr.Load() == nil
}, "expected connection pointer to be nil")
})
}
-299
View File
@@ -1,299 +0,0 @@
package honeybee
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/types"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
)
func makeWorkerContext(t *testing.T) (
inbox chan types.InboxMessage,
events chan PoolEvent,
pool PoolPlugin,
) {
t.Helper()
inbox = make(chan types.InboxMessage, 256)
events = make(chan PoolEvent, 10)
pool = PoolPlugin{
Inbox: inbox,
Events: events,
InboxCounter: &atomic.Uint64{},
}
return
}
func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *DefaultWorker {
t.Helper()
config, _ := NewWorkerConfig(
WithReconnectDelay(0 * time.Second),
)
return &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
heartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
}
func mockDialer(socket *honeybeetest.MockSocket) *honeybeetest.MockDialer {
return &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return socket, nil, nil
},
}
}
func TestWorkerStart(t *testing.T) {
t.Run("EventConnected emitted after dial succeeds", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
mockSocket := honeybeetest.NewMockSocket()
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.ID == w.id && e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
})
t.Run("Send delivers data to socket", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
_, mockSocket, _, outgoingData := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
err := w.Send([]byte("hello"))
assert.NoError(t, err)
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-outgoingData:
return string(msg.Data) == "hello"
default:
return false
}
}, "expected data on socket")
})
t.Run("socket data arrives on Inbox", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
inbox, events, pool := makeWorkerContext(t)
incomingData := make(chan honeybeetest.MockIncomingData, 10)
mockSocket := honeybeetest.NewMockSocket()
mockSocket.CloseFunc = func() error {
mockSocket.Once.Do(func() { close(mockSocket.Closed) })
return nil
}
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
select {
case data := <-incomingData:
return data.MsgType, data.Data, data.Err
}
}
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
incomingData <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: []byte("hello"),
}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-inbox:
return msg.ID == w.id && string(msg.Data) == "hello"
default:
return false
}
}, "expected message on Inbox")
})
t.Run("socket close produces EventDisconnected then EventConnected", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
_, mockSocket, incomingData, _ := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
close(incomingData)
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected EventDisconnected")
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected second EventConnected")
})
t.Run("Stop produces EventDisconnected and wg drains", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
mockSocket := honeybeetest.NewMockSocket()
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
w.Stop()
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected EventDisconnected")
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected wg to drain")
})
t.Run("parent context cancel exits cleanly and wg drains", func(t *testing.T) {
parentCtx, parentCancel := context.WithCancel(context.Background())
workerCtx, workerCancel := context.WithCancel(parentCtx)
w := makeWorker(t, workerCtx, workerCancel)
_, events, pool := makeWorkerContext(t)
mockSocket := honeybeetest.NewMockSocket()
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
// drain events after parent cancel — we don't assert what they are,
// only that the worker exits
parentCancel()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected wg to drain after parent cancel")
})
}
+640
View File
@@ -0,0 +1,640 @@
package honeybee
import (
"context"
"errors"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
)
func makeWorkerContext(t *testing.T) (
inbox chan types.InboxMessage,
events chan PoolEvent,
pool PoolPlugin,
) {
t.Helper()
inbox = make(chan types.InboxMessage, 256)
events = make(chan PoolEvent, 10)
pool = PoolPlugin{
Inbox: inbox,
Events: events,
InboxCounter: &atomic.Uint64{},
}
return
}
func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *DefaultWorker {
t.Helper()
config, _ := NewWorkerConfig(
WithReconnectDelay(0 * time.Second),
)
return &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
heartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
}
func mockDialer(socket *honeybeetest.MockSocket) *honeybeetest.MockDialer {
return &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return socket, nil, nil
},
}
}
func TestWorkerSession(t *testing.T) {
t.Run("EventConnected emitted after dial succeeds", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
mockSocket := honeybeetest.NewMockSocket()
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.ID == w.id && e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
})
t.Run("dial failure exhausted - session stays alive, no events emitted", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
pool.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return nil, nil, errors.New("connection refused")
},
}
cc, _ := transport.NewConnectionConfig(transport.WithoutRetry())
pool.ConnectionConfig = cc
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
honeybeetest.Never(t, func() bool {
select {
case <-events:
return true
default:
return false
}
}, "expected no events when dial fails")
// worker goroutine is still running
assert.False(t, func() bool {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(50 * time.Millisecond):
return false
}
}(), "expected worker to still be running after dial failure")
})
t.Run("keepalive fires before connection - dial is cancelled and replaced", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
config, _ := NewWorkerConfig(
WithReconnectDelay(0),
WithKeepaliveTimeout(20*time.Millisecond),
)
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
heartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, _, pool := makeWorkerContext(t)
var dialCount atomic.Uint64
pool.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
dialCount.Add(1)
<-dialCtx.Done()
return nil, nil, dialCtx.Err()
},
}
cc, _ := transport.NewConnectionConfig(transport.WithoutRetry())
pool.ConnectionConfig = cc
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
// keepalive fires after 20ms; a second dial goroutine must be spawned
honeybeetest.Eventually(t, func() bool {
return dialCount.Load() >= 2
}, "expected at least two dial attempts after keepalive fired")
})
t.Run("Stop before connection established - exits cleanly, no events", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
pool.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
<-dialCtx.Done()
return nil, nil, dialCtx.Err()
},
}
cc, _ := transport.NewConnectionConfig(transport.WithoutRetry())
pool.ConnectionConfig = cc
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
w.Stop()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected Start to return after Stop")
assert.Empty(t, events, "expected no events when stopped before connection")
})
t.Run("Send delivers data to socket", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
_, mockSocket, _, outgoingData := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
err := w.Send([]byte("hello"))
assert.NoError(t, err)
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-outgoingData:
return string(msg.Data) == "hello"
default:
return false
}
}, "expected data on socket")
})
t.Run("socket data arrives on Inbox", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
inbox, events, pool := makeWorkerContext(t)
incomingData := make(chan honeybeetest.MockIncomingData, 10)
mockSocket := honeybeetest.NewMockSocket()
mockSocket.CloseFunc = func() error {
mockSocket.Once.Do(func() { close(mockSocket.Closed) })
return nil
}
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
select {
case data := <-incomingData:
return data.MsgType, data.Data, data.Err
}
}
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
incomingData <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: []byte("hello"),
}
var received types.InboxMessage
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-inbox:
received = msg
return true
default:
return false
}
}, "expected message on Inbox")
assert.Equal(t, w.id, received.ID)
assert.Equal(t, []byte("hello"), received.Data)
assert.False(t, received.ReceivedAt.IsZero(), "expected non-zero ReceivedAt")
})
t.Run("sustained incoming messages reset keepalive - no disconnect", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
config, _ := NewWorkerConfig(
WithReconnectDelay(0),
WithKeepaliveTimeout(60*time.Millisecond),
)
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
heartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, events, pool := makeWorkerContext(t)
_, mockSocket, incomingData, _ := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
// send messages every 20ms for 100ms — well within the 60ms timeout each cycle
go func() {
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
select {
case incomingData <- honeybeetest.MockIncomingData{MsgType: websocket.TextMessage, Data: []byte("ping")}:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
honeybeetest.Never(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected no EventDisconnected while messages are arriving")
})
t.Run("pong heartbeat resets keepalive - no disconnect", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
config, _ := NewWorkerConfig(
WithReconnectDelay(0),
WithKeepaliveTimeout(60*time.Millisecond),
)
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
heartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, events, pool := makeWorkerContext(t)
// socket whose pong handler fires every 20ms; no incoming messages
var pongHandler func(string) error
mockSocket, incomingData, _ := honeybeetest.SetupTestSocket(t)
mockSocket.SetPongHandlerFunc = func(h func(string) error) { pongHandler = h }
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
// fire pong every 20ms — well within the 60ms keepalive window
go func() {
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if pongHandler != nil {
_ = pongHandler("")
}
case <-ctx.Done():
return
}
}
}()
honeybeetest.Never(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected no EventDisconnected while pongs are arriving")
_ = incomingData // kept open to prevent reader EOF
})
t.Run("keepalive fires while connected - EventDisconnected emitted and redial begins", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
config, _ := NewWorkerConfig(
WithReconnectDelay(0),
WithKeepaliveTimeout(30*time.Millisecond),
)
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
heartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, events, pool := makeWorkerContext(t)
_, mockSocket, _, _ := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
// no activity — keepalive fires after 30ms
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected EventDisconnected after keepalive timeout")
// session must redial — a second EventConnected follows
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected after redial")
})
t.Run("socket close produces EventDisconnected then EventConnected", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
_, mockSocket, incomingData, _ := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
close(incomingData)
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected EventDisconnected")
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected second EventConnected")
})
t.Run("connection pointer is nil between disconnect and reconnect", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
_, mockSocket, incomingData, _ := setupTestConnection(t)
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
close(incomingData)
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected EventDisconnected")
// conn.Store(nil) happens before EventDisconnected is sent
assert.Nil(t, w.conn.Load(), "expected connection pointer to be nil after disconnect")
})
t.Run("Stop produces EventDisconnected and wg drains", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
_, events, pool := makeWorkerContext(t)
mockSocket := honeybeetest.NewMockSocket()
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
w.Stop()
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDisconnected
default:
return false
}
}, "expected EventDisconnected")
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected wg to drain")
})
t.Run("parent context cancel exits cleanly and wg drains", func(t *testing.T) {
parentCtx, parentCancel := context.WithCancel(context.Background())
workerCtx, workerCancel := context.WithCancel(parentCtx)
w := makeWorker(t, workerCtx, workerCancel)
_, events, pool := makeWorkerContext(t)
mockSocket := honeybeetest.NewMockSocket()
pool.Dialer = mockDialer(mockSocket)
var wg sync.WaitGroup
wg.Go(func() {
w.Start(pool)
})
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
// drain events after parent cancel — we don't assert what they are,
// only that the worker exits
parentCancel()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected wg to drain after parent cancel")
})
}