refactor: phase 2 integrated new dialer into worker

This commit is contained in:
Jay
2026-06-06 17:32:03 -04:00
parent 7bc576ed98
commit a46e9f7ad8
13 changed files with 366 additions and 1026 deletions
-81
View File
@@ -1,81 +0,0 @@
# Extending the Pool
The pool owns peer registration, event plumbing, and lifecycle management. The worker owns everything that happens on the wire between registration and the terminal event. Everything between `pool.Connect` and the final disconnect event is the worker's responsibility, and it is fully replaceable.
## The Worker Interface
The pool accepts any type that satisfies:
```go
type Worker interface {
Start(pool PoolPlugin)
Stop()
Send(data []byte) error
Stats() WorkerStats
}
```
The behavioral contract for each method:
**`Start(pool PoolPlugin)`** Called by the pool in a goroutine it owns. Must block until the worker is finished. The pool monitors this goroutine via a `sync.WaitGroup`; `Start` returning is the signal that the worker is done. All I/O, goroutine management, and event emission happen inside `Start`.
**`Stop()`** Must cause `Start` to return in bounded time. Typically cancels a context. May be called from any goroutine, including concurrently with `Start`.
**`Send(data []byte) error`** Writes data to the remote peer and returns an error if it cannot. Must be safe for concurrent callers. The pool calls `Send` from whatever goroutine the consumer calls `pool.Send` from.
**`Stats() WorkerStats`** Returns a snapshot of the worker's internal counters and channel depths. Must be safe for concurrent callers and must not block. The pool calls this from `pool.Stats()` and `pool.PeerStats()` while holding a read lock.
## The PoolPlugin
The pool constructs a `PoolPlugin` and passes it to `Start`. It gives the worker access to pool-level channels and the logging handler.
```go
type PoolPlugin struct {
ID string
Inbox chan<- honeybee.InboxMessage
Events chan<- honeybee.PoolEvent
InboxCounter *atomic.Uint64
ConnectionConfig transport.ConnectionConfig
Handler slog.Handler
}
```
**`Inbox`** The shared channel that delivers received messages to the pool's consumer. All peers in the pool deliver to the same inbox channel. Workers must include their peer ID in each `InboxMessage`.
**`Events`** The shared channel for lifecycle events. Workers emit `honeybee.EventConnected`, `honeybee.EventDisconnected`, and `honeybee.EventDialFailed` directly. All events include a timestamp in the `At` field. `EventDialFailed` events also carry a non-nil `Err` field containing the dialer error.
**`InboxCounter`** An atomic counter owned by the pool. Workers must increment this once for each message forwarded to `Inbox`. The pool reads it in `Stats()`.
**`Handler`** The `slog.Handler` passed to the pool constructor. The pool constructs and injects scoped loggers before calling the factory, so most workers will use the logger they receive from the factory rather than constructing one from `Handler` directly. `Handler` is available for workers that manage sub-components that need their own loggers.
## Extending the Pool
### Factory Signature
```go
type honeybee.WorkerFactory func(
ctx context.Context,
id string,
logger *slog.Logger,
) (honeybee.Worker, error)
```
The pool calls the factory under its write lock when `Connect` is called. The factory must return without blocking. The worker is responsible for dialing and managing its own connections. The pool constructs `logger` from the worker logging config before calling the factory.
The factory is set via `honeybee.WithWorkerFactory` on the pool config.
### Replacing the Worker
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`, `EventDisconnected`, and `EventDialFailed` to `pool.Events`, and incrementing `pool.InboxCounter` for each message forwarded.
`DefaultWorker`'s source is the authoritative reference for how those responsibilities are met.
## Factory Constraints
The factory is called while the pool holds its write lock. Two constraints follow from this directly.
**Factories must not block.** Any operation that could wait — dialing a connection, acquiring another lock, reading from a channel — will deadlock or stall the pool. All blocking work belongs inside `Start`, not inside the factory.
**Factories must not call pool methods.** `pool.Connect`, `pool.Send`, `pool.Remove`, and similar methods all acquire the same lock the factory is called under. Calling them from the factory will deadlock.
The factory's only job is to construct and return a worker. If construction itself can fail — for example, because a config value is invalid — return the error and the pool will propagate it to the caller of `Connect`.
-222
View File
@@ -1,222 +0,0 @@
package honeybee
import (
"git.wisehodl.dev/jay/go-honeybee/transport"
"time"
)
// ----------------------------------------------------------------------------
// Pool Config
// ----------------------------------------------------------------------------
// Types
type PoolConfig struct {
InboxBufferSize int
EventsBufferSize int
ConnectionConfig transport.ConnectionConfig
WorkerFactory WorkerFactory
WorkerConfig WorkerConfig
}
type PoolOption func(*PoolConfig) error
// Constructor
func NewPoolConfig(options ...PoolOption) (*PoolConfig, error) {
conf := GetDefaultPoolConfig()
if err := applyPoolOptions(conf, options...); err != nil {
return nil, err
}
if err := ValidatePoolConfig(conf); err != nil {
return nil, err
}
return conf, nil
}
func GetDefaultPoolConfig() *PoolConfig {
return &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
WorkerFactory: nil,
WorkerConfig: *GetDefaultWorkerConfig(),
}
}
func applyPoolOptions(config *PoolConfig, options ...PoolOption) error {
for _, option := range options {
if err := option(config); err != nil {
return err
}
}
return nil
}
// Validation
func ValidatePoolConfig(config *PoolConfig) error {
var err error
err = transport.ValidateConnectionConfig(&config.ConnectionConfig)
if err != nil {
return err
}
err = ValidateWorkerConfig(&config.WorkerConfig)
if err != nil {
return err
}
return nil
}
func validateBufferSize(value int) error {
if value < 1 {
return InvalidBufferSize
}
return nil
}
// Options
func WithInboxBufferSize(value int) PoolOption {
return func(c *PoolConfig) error {
if err := validateBufferSize(value); err != nil {
return err
}
c.InboxBufferSize = value
return nil
}
}
func WithEventsBufferSize(value int) PoolOption {
return func(c *PoolConfig) error {
if err := validateBufferSize(value); err != nil {
return err
}
c.EventsBufferSize = value
return nil
}
}
func WithConnectionConfig(cc transport.ConnectionConfig) PoolOption {
return func(c *PoolConfig) error {
err := transport.ValidateConnectionConfig(&cc)
if err != nil {
return err
}
c.ConnectionConfig = cc
return nil
}
}
func WithWorkerConfig(wc WorkerConfig) PoolOption {
return func(c *PoolConfig) error {
err := ValidateWorkerConfig(&wc)
if err != nil {
return err
}
c.WorkerConfig = wc
return nil
}
}
func WithWorkerFactory(wf WorkerFactory) PoolOption {
return func(c *PoolConfig) error {
c.WorkerFactory = wf
return nil
}
}
// ----------------------------------------------------------------------------
// Worker Config
// ----------------------------------------------------------------------------
// Types
type WorkerConfig struct {
KeepaliveTimeout time.Duration
ReconnectDelay time.Duration
}
type WorkerOption func(*WorkerConfig) error
// Constructor
func NewWorkerConfig(options ...WorkerOption) (*WorkerConfig, error) {
conf := GetDefaultWorkerConfig()
if err := applyWorkerOptions(conf, options...); err != nil {
return nil, err
}
if err := ValidateWorkerConfig(conf); err != nil {
return nil, err
}
return conf, nil
}
func GetDefaultWorkerConfig() *WorkerConfig {
return &WorkerConfig{
KeepaliveTimeout: 60 * time.Second,
ReconnectDelay: 2 * time.Second,
}
}
func applyWorkerOptions(config *WorkerConfig, options ...WorkerOption) error {
for _, option := range options {
if err := option(config); err != nil {
return err
}
}
return nil
}
// Validation
func ValidateWorkerConfig(config *WorkerConfig) error {
err := validateKeepaliveTimeout(config.KeepaliveTimeout)
if err != nil {
return err
}
return nil
}
func validateKeepaliveTimeout(value time.Duration) error {
if value < 0 {
return InvalidKeepaliveTimeout
}
return nil
}
func validateReconnectDelay(value time.Duration) error {
if value < 0 {
return InvalidReconnectDelay
}
return nil
}
// Options
// When KeepaliveTimeout is set to zero, keepalive timeouts are disabled.
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
return func(c *WorkerConfig) error {
err := validateKeepaliveTimeout(value)
if err != nil {
return err
}
c.KeepaliveTimeout = value
return nil
}
}
func WithReconnectDelay(value time.Duration) WorkerOption {
return func(c *WorkerConfig) error {
err := validateReconnectDelay(value)
if err != nil {
return err
}
c.ReconnectDelay = value
return nil
}
}
-140
View File
@@ -1,140 +0,0 @@
package honeybee
import (
"git.wisehodl.dev/jay/go-honeybee/transport"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestNewPoolConfig(t *testing.T) {
conf, err := NewPoolConfig()
assert.NoError(t, err)
assert.Equal(t, conf, &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
WorkerConfig: *GetDefaultWorkerConfig(),
WorkerFactory: nil,
})
}
func TestDefaultPoolConfig(t *testing.T) {
conf := GetDefaultPoolConfig()
assert.Equal(t, conf, &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
WorkerConfig: *GetDefaultWorkerConfig(),
WorkerFactory: nil,
})
}
func TestApplyPoolOptions(t *testing.T) {
conf := &PoolConfig{}
err := applyPoolOptions(
conf,
WithConnectionConfig(transport.ConnectionConfig{
Retry: transport.RetryConfig{Disabled: true},
}),
)
assert.NoError(t, err)
assert.Equal(t, 0*time.Second, conf.ConnectionConfig.WriteTimeout)
}
func TestWithBufferSizes(t *testing.T) {
conf := &PoolConfig{}
err := applyPoolOptions(conf,
WithInboxBufferSize(100),
WithEventsBufferSize(20),
)
assert.NoError(t, err)
assert.Equal(t, 100, conf.InboxBufferSize)
assert.Equal(t, 20, conf.EventsBufferSize)
}
func TestWithConnectionConfig(t *testing.T) {
conf := &PoolConfig{}
opt := WithConnectionConfig(transport.ConnectionConfig{
WriteTimeout: 1 * time.Second,
Retry: transport.RetryConfig{Disabled: true},
})
err := applyPoolOptions(conf, opt)
assert.NoError(t, err)
assert.Equal(t, 1*time.Second, conf.ConnectionConfig.WriteTimeout)
// invalid config is rejected
conf = &PoolConfig{}
opt = WithConnectionConfig(
transport.ConnectionConfig{
WriteTimeout: -1 * time.Second,
Retry: transport.RetryConfig{Disabled: true},
})
err = applyPoolOptions(conf, opt)
assert.Error(t, err)
}
func TestValidatePoolConfig(t *testing.T) {
cases := []struct {
name string
conf PoolConfig
wantErr error
wantErrText string
}{
{
name: "valid empty (retry disabled)",
conf: PoolConfig{
ConnectionConfig: transport.ConnectionConfig{
Retry: transport.RetryConfig{Disabled: true},
},
},
},
{
name: "valid defaults",
conf: *GetDefaultPoolConfig(),
},
{
name: "valid complete",
conf: PoolConfig{
ConnectionConfig: transport.ConnectionConfig{
Retry: transport.RetryConfig{Disabled: true},
},
},
},
{
name: "invalid connection config",
conf: PoolConfig{
ConnectionConfig: transport.ConnectionConfig{
Retry: transport.RetryConfig{
InitialDelay: 10 * time.Second,
MaxDelay: 1 * time.Second,
},
},
},
wantErrText: "initial delay may not exceed maximum delay",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePoolConfig(&tc.conf)
if tc.wantErr != nil || tc.wantErrText != "" {
if tc.wantErr != nil {
assert.ErrorIs(t, err, tc.wantErr)
}
if tc.wantErrText != "" {
assert.ErrorContains(t, err, tc.wantErrText)
}
return
}
assert.NoError(t, err)
})
}
}
-31
View File
@@ -1,31 +0,0 @@
package honeybee
import "errors"
import "fmt"
var (
// Config errors
InvalidKeepaliveTimeout = errors.New("keepalive timeout cannot be negative")
InvalidReconnectDelay = errors.New("reconnect delay cannot be negative")
InvalidBufferSize = errors.New("buffer size must be greater than zero")
// Pool errors
ErrPoolClosed = errors.New("pool is closed")
ErrPeerNotFound = errors.New("peer not found")
ErrPeerExists = errors.New("peer already exists")
// Worker errors
ErrConnectionUnavailable = errors.New("connection unavailable")
)
func NewConfigError(err error) error {
return fmt.Errorf("configuration error: %w", err)
}
func NewPoolError(err error) error {
return fmt.Errorf("pool error: %w", err)
}
func NewWorkerError(id string, err error) error {
return fmt.Errorf("worker %q error: %w", id, err)
}
+97 -39
View File
@@ -2,6 +2,8 @@ package honeybee
import (
"context"
"errors"
"fmt"
"log/slog"
"git.wisehodl.dev/jay/go-honeybee/transport"
@@ -19,6 +21,16 @@ type Dialer = types.Dialer
var NormalizeURL = transport.NormalizeURL
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
var (
ErrPoolClosed = errors.New("pool is closed")
ErrPeerNotFound = errors.New("peer not found")
ErrPeerExists = errors.New("peer already exists")
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
@@ -55,10 +67,68 @@ type PeerStats struct {
}
type PoolPlugin struct {
Inbox chan<- types.InboxMessage
Events chan<- PoolEvent
InboxCounter *atomic.Uint64
ConnectionConfig transport.ConnectionConfig
Inbox chan<- types.InboxMessage
Events chan<- PoolEvent
InboxCounter *atomic.Uint64
}
// ----------------------------------------------------------------------------
// Config
// ----------------------------------------------------------------------------
type PoolConfig struct {
InboxBufferSize int
EventsBufferSize int
WorkerConfig WorkerConfig
}
type PoolOption func(*PoolConfig)
func NewPoolConfig(opts ...PoolOption) (*PoolConfig, error) {
workerCfg, _ := NewWorkerConfig()
cfg := &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
WorkerConfig: *workerCfg,
}
for _, o := range opts {
o(cfg)
}
if err := ValidatePoolConfig(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func WithInboxBufferSize(value int) PoolOption {
return func(c *PoolConfig) {
c.InboxBufferSize = value
}
}
func WithEventsBufferSize(value int) PoolOption {
return func(c *PoolConfig) {
c.EventsBufferSize = value
}
}
func WithWorkerConfig(wc WorkerConfig) PoolOption {
return func(c *PoolConfig) {
c.WorkerConfig = wc
}
}
func ValidatePoolConfig(c *PoolConfig) error {
if c.InboxBufferSize < 1 {
return fmt.Errorf("invalid inbox buffer size: %d", c.InboxBufferSize)
}
if c.EventsBufferSize < 1 {
return fmt.Errorf("invalid events buffer size: %d", c.EventsBufferSize)
}
if err := ValidateWorkerConfig(&c.WorkerConfig); err != nil {
return err
}
return nil
}
// ----------------------------------------------------------------------------
@@ -93,18 +163,7 @@ type Pool struct {
func NewPool(ctx context.Context, config *PoolConfig, handler slog.Handler,
) (*Pool, error) {
if config == nil {
config = GetDefaultPoolConfig()
}
// If a custom factory is supplied, config.WorkerConfig is not used.
// The factory function should be non-blocking or else Connect() may cause
// deadlocks.
if config.WorkerFactory == nil {
config.WorkerFactory = func(
ctx context.Context, id string, handler slog.Handler) (Worker, error) {
wc := config.WorkerConfig
return NewWorker(ctx, id, &wc, handler)
}
config, _ = NewPoolConfig()
}
if err := ValidatePoolConfig(config); err != nil {
@@ -120,11 +179,11 @@ func NewPool(ctx context.Context, config *PoolConfig, handler slog.Handler,
}
var dialer types.Dialer
if config.ConnectionConfig.Dialer != nil {
dialer = config.ConnectionConfig.Dialer
} else {
dialer = transport.NewDialer()
}
// if config.ConnectionConfig.Dialer != nil {
// dialer = config.ConnectionConfig.Dialer
// } else {
// dialer = transport.NewDialer()
// }
return &Pool{
peers: make(map[string]*Peer),
@@ -262,15 +321,15 @@ func (p *Pool) Connect(id string, opts ...ConnectOption) error {
defer p.mu.Unlock()
if p.closed {
return NewPoolError(ErrPoolClosed)
return ErrPoolClosed
}
if _, exists := p.peers[id]; exists {
return NewPoolError(ErrPeerExists)
return ErrPeerExists
}
// The worker factory must be non-blocking to avoid deadlocks
worker, err := p.config.WorkerFactory(p.ctx, id, p.handler)
wc := p.config.WorkerConfig
worker, err := NewWorker(p.ctx, id, nil, &wc, p.handler)
if err != nil {
return err
}
@@ -279,19 +338,18 @@ func (p *Pool) Connect(id string, opts ...ConnectOption) error {
for _, opt := range opts {
opt(o)
}
effectiveDialer := p.dialer
if o.dialer != nil {
effectiveDialer = o.dialer
}
// effectiveDialer := p.dialer
// if o.dialer != nil {
// effectiveDialer = o.dialer
// }
cc := p.config.ConnectionConfig.Clone()
cc.Dialer = effectiveDialer
// cc := p.config.ConnectionConfig.Clone()
// cc.Dialer = effectiveDialer
pool := PoolPlugin{
Inbox: p.inbox,
Events: p.events,
InboxCounter: p.inboxCounter,
ConnectionConfig: cc,
Inbox: p.inbox,
Events: p.events,
InboxCounter: p.inboxCounter,
}
p.wg.Go(func() {
@@ -321,12 +379,12 @@ func (p *Pool) Remove(id string) error {
defer p.mu.Unlock()
if p.closed {
return NewPoolError(ErrPoolClosed)
return ErrPoolClosed
}
peer, exists := p.peers[id]
if !exists {
return NewPoolError(ErrPeerNotFound)
return ErrPeerNotFound
}
delete(p.peers, id)
@@ -349,12 +407,12 @@ func (p *Pool) Send(id string, data []byte) error {
defer p.mu.RUnlock()
if p.closed {
return NewPoolError(ErrPoolClosed)
return ErrPoolClosed
}
peer, exists := p.peers[id]
if !exists {
return NewPoolError(ErrPeerNotFound)
return ErrPeerNotFound
}
err = peer.worker.Send(data)
+23 -15
View File
@@ -15,6 +15,21 @@ import (
"github.com/gorilla/websocket"
)
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
var (
ErrConnectionClosed = errors.New("connection closed")
ErrWriteFailed = errors.New("write failed")
ErrNilSocket = errors.New("socket cannot be nil")
ErrSocketExists = errors.New("socket already exists")
ErrFailedWriteDeadline = errors.New("failed to set write deadline")
ErrPeerClosedClean = errors.New("peer closed connection cleanly")
ErrPeerClosedUnexpected = errors.New("peer closed connection unexpectedly")
ErrReadError = errors.New("read error")
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
@@ -40,22 +55,19 @@ type ConnectionConfig struct {
}
func NewConnectionConfig(opts ...ConnectionOption) (*ConnectionConfig, error) {
conf := &ConnectionConfig{
cfg := &ConnectionConfig{
WriteTimeout: 30 * time.Second,
PingInterval: 20 * time.Second,
IncomingBufferSize: 100,
ErrorsBufferSize: 10,
}
for _, o := range opts {
o(conf)
o(cfg)
}
err := ValidateConnectionConfig(*conf)
if err != nil {
if err := ValidateConnectionConfig(*cfg); err != nil {
return nil, err
}
return conf, nil
return cfg, nil
}
type ConnectionOption func(*ConnectionConfig)
@@ -90,21 +102,17 @@ func ValidateConnectionConfig(c ConnectionConfig) error {
if c.WriteTimeout < 0 {
return fmt.Errorf("invalid write timeout: %v", c.WriteTimeout)
}
if c.PingInterval < 0 {
return fmt.Errorf("invalid ping interval: %v", c.PingInterval)
}
if c.IncomingBufferSize < 1 {
return fmt.Errorf("invalid incoming buffer size: %d",
c.IncomingBufferSize)
}
if c.ErrorsBufferSize < 1 {
return fmt.Errorf("invalid errors buffer size: %d",
c.ErrorsBufferSize)
}
return nil
}
@@ -142,7 +150,7 @@ func NewConnection(
ctx context.Context, socket types.Socket, config *ConnectionConfig, handler slog.Handler,
) (*Connection, error) {
if socket == nil {
return nil, NewConnectionError(ErrNilSocket)
return nil, ErrNilSocket
}
if config == nil {
@@ -197,7 +205,7 @@ func (c *Connection) Send(data []byte) error {
defer c.writeMu.Unlock()
if c.closed {
return NewConnectionError(ErrConnectionClosed)
return ErrConnectionClosed
}
// setup
@@ -206,7 +214,7 @@ func (c *Connection) Send(data []byte) error {
if c.logger != nil {
c.logger.Error("write deadline error", "error", err)
}
return NewConnectionError(fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err))
return fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err)
}
}
@@ -217,7 +225,7 @@ func (c *Connection) Send(data []byte) error {
if c.logger != nil {
c.logger.Error("write error", "error", err)
}
return NewConnectionError(fmt.Errorf("%w: %w", ErrWriteFailed, err))
return fmt.Errorf("%w: %w", ErrWriteFailed, err)
}
c.outgoingCount.Add(1)
+4 -4
View File
@@ -112,7 +112,7 @@ func TestNewConnection(t *testing.T) {
// Accessors
// ----------------------------------------------------------------------------
func TestConnectionIncoming(t *testing.T) {
func TestConnection_Incoming(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
incoming := conn.Incoming()
@@ -125,7 +125,7 @@ func TestConnectionIncoming(t *testing.T) {
assert.Equal(t, testData, received)
}
func TestConnectionErrors(t *testing.T) {
func TestConnection_Errors(t *testing.T) {
t.Run("clean close by peer", func(t *testing.T) {
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
@@ -194,7 +194,7 @@ func TestConnectionErrors(t *testing.T) {
})
}
func TestConnectionHeartbeat(t *testing.T) {
func TestConnection_Heartbeat(t *testing.T) {
t.Run("pinger sends ping frames", func(t *testing.T) {
pingCount := atomic.Int32{}
socket, _, _ := honeybeetest.SetupTestSocket(t)
@@ -248,7 +248,7 @@ func TestConnectionHeartbeat(t *testing.T) {
// Send
// ----------------------------------------------------------------------------
func TestConnectionSend(t *testing.T) {
func TestConnection_Send(t *testing.T) {
t.Run("writes message to socket", func(t *testing.T) {
conn, _, _, outgoingData := setupTestConnection(t)
defer conn.Close()
-41
View File
@@ -1,41 +0,0 @@
package transport
import "errors"
import "fmt"
var (
// URL Errors
InvalidProtocol = errors.New("URL must use ws:// or wss:// scheme")
// Configuration Errors
InvalidWriteTimeout = errors.New("write timeout cannot be negative")
InvalidPingInterval = errors.New("ping interval cannot be negative")
InvalidBufferSize = errors.New("buffer size must be greater than zero")
InvalidRetryMaxRetries = errors.New("max retry count cannot be negative")
InvalidRetryInitialDelay = errors.New("initial delay must be positive")
InvalidRetryMaxDelay = errors.New("max delay must be positive")
InvalidRetryJitterFactor = errors.New("jitter factor must be between 0.0 and 1.0")
InvalidDelays = errors.New("initial delay may not exceed maximum delay")
// Socket Errors
ErrNilDialFunc = errors.New("dial func cannot be nil")
// Connection Errors
ErrConnectionClosed = errors.New("connection closed")
ErrWriteFailed = errors.New("write failed")
ErrNilSocket = errors.New("socket cannot be nil")
ErrSocketExists = errors.New("socket already exists")
ErrFailedWriteDeadline = errors.New("failed to set write deadline")
ErrPeerClosedClean = errors.New("peer closed connection cleanly")
ErrPeerClosedUnexpected = errors.New("peer closed connection unexpectedly")
ErrReadError = errors.New("read error")
)
func NewConfigError(err error) error {
return fmt.Errorf("configuration error: %w", err)
}
func NewConnectionError(err error) error {
return fmt.Errorf("connection error: %w", err)
}
+14 -9
View File
@@ -2,6 +2,7 @@ package transport
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
@@ -12,6 +13,14 @@ import (
"github.com/gorilla/websocket"
)
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
var (
ErrNilDialFunc = errors.New("dial func cannot be nil")
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
@@ -51,11 +60,11 @@ func (d *GorillaDialer) DialContext(
// Retry Dialer
// ----------------------------------------------------------------------------
func AcquireSocket(
func DialWithRetry(
ctx context.Context,
mgr *RetryManager,
dialFn func(ctx context.Context) (types.Socket, error),
errCh chan<- error,
dialFn func(context.Context) (types.Socket, error),
onError func(error),
handler slog.Handler,
) (types.Socket, error) {
if component.FromContext(ctx) == nil {
@@ -88,12 +97,8 @@ func AcquireSocket(
return socket, nil
}
if errCh != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case errCh <- err:
}
if onError != nil {
onError(err)
}
if mgr == nil {
+21 -25
View File
@@ -28,7 +28,7 @@ func TestNewGorillaDialer(t *testing.T) {
assert.NotNil(t, dialer.Dialer.NetDialContext)
}
func TestAcquireSocket(t *testing.T) {
func TestDialWithRetry(t *testing.T) {
cases := []struct {
name string
mockRuns []error
@@ -85,7 +85,7 @@ func TestAcquireSocket(t *testing.T) {
JitterFactor: 0.0,
})
socket, err := AcquireSocket(
socket, err := DialWithRetry(
context.Background(), retryMgr, dial, nil, nil)
assert.Equal(t, tc.wantRetryCount, retryMgr.RetryCount())
@@ -103,17 +103,17 @@ func TestAcquireSocket(t *testing.T) {
dial := func(ctx context.Context) (types.Socket, error) {
return nil, errors.New("dial failed")
}
_, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
_, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
assert.Error(t, err)
})
}
func TestAcquireSocketNoRetry(t *testing.T) {
func TestDialWithRetry_NoRetry(t *testing.T) {
t.Run("success", func(t *testing.T) {
dial := func(ctx context.Context) (types.Socket, error) {
return honeybeetest.NewMockSocket(), nil
}
socket, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
socket, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
assert.NotNil(t, socket)
assert.NoError(t, err)
})
@@ -122,12 +122,12 @@ func TestAcquireSocketNoRetry(t *testing.T) {
dial := func(ctx context.Context) (types.Socket, error) {
return nil, errors.New("dial failed")
}
_, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
_, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
assert.Error(t, err)
})
}
func TestAcquireSocketGuards(t *testing.T) {
func TestDialWithRetry_Guards(t *testing.T) {
validRetryConfig, _ := NewRetryConfig()
validRetryMgr := NewRetryManager(validRetryConfig)
@@ -146,7 +146,7 @@ func TestAcquireSocketGuards(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
socket, err := AcquireSocket(
socket, err := DialWithRetry(
context.Background(), tc.retryMgr, tc.dialFn, nil, nil)
assert.Error(t, err)
@@ -156,7 +156,7 @@ func TestAcquireSocketGuards(t *testing.T) {
}
}
func TestAcquireSocketContextCancellation(t *testing.T) {
func TestDialWithRetry_ContextCancellation(t *testing.T) {
t.Run("context cancelled during sleep returns before next attempt", func(t *testing.T) {
dialCount := atomic.Int32{}
dial := func(ctx context.Context) (types.Socket, error) {
@@ -181,7 +181,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
done := make(chan error, 1)
go func() {
_, err := AcquireSocket(ctx, retryMgr, dial, nil, nil)
_, err := DialWithRetry(ctx, retryMgr, dial, nil, nil)
done <- err
}()
@@ -198,7 +198,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
// dial count is 2 because the first retry is always immediate
assert.Equal(t, int32(2), dialCount.Load())
case <-time.After(honeybeetest.TestTimeout):
t.Fatal("AcquireSocket did not return after context cancellation")
t.Fatal("DialWithRetry did not return after context cancellation")
}
})
@@ -217,7 +217,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
retryMgr := NewRetryManager(retryCfg)
done := make(chan error, 1)
go func() {
_, err := AcquireSocket(ctx, retryMgr, dial, nil, nil)
_, err := DialWithRetry(ctx, retryMgr, dial, nil, nil)
done <- err
}()
@@ -229,13 +229,13 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(honeybeetest.TestTimeout):
t.Fatal("AcquireSocket did not return after context cancellation")
t.Fatal("DialWithRetry did not return after context cancellation")
}
})
}
func TestAcquireSocketDialErrors(t *testing.T) {
func TestDialWithRetry_DialErrors(t *testing.T) {
t.Run("receive one error per failed dial", func(t *testing.T) {
dialErr1 := errors.New("attempt 1 failed")
dialErr2 := errors.New("attempt 2 failed")
@@ -252,7 +252,8 @@ func TestAcquireSocketDialErrors(t *testing.T) {
}
}
errCh := make(chan error, 2)
var gotErrors []error
onError := func(err error) { gotErrors = append(gotErrors, err) }
retryCfg, _ := NewRetryConfig(
WithMaxRetries(3),
@@ -263,17 +264,11 @@ func TestAcquireSocketDialErrors(t *testing.T) {
retryMgr := NewRetryManager(retryCfg)
go func() {
_, err := AcquireSocket(context.Background(), retryMgr, dial, errCh, nil)
_, err := DialWithRetry(context.Background(), retryMgr, dial, onError, nil)
assert.NoError(t, err)
}()
var gotErrors []error
honeybeetest.Eventually(t, func() bool {
select {
case err := <-errCh:
gotErrors = append(gotErrors, err)
default:
}
return len(gotErrors) == 2
}, "expected errors")
@@ -286,7 +281,8 @@ func TestAcquireSocketDialErrors(t *testing.T) {
return honeybeetest.NewMockSocket(), nil
}
errCh := make(chan error, 1)
called := false
onError := func(err error) { called = true }
retryCfg, _ := NewRetryConfig(
WithMaxRetries(3),
@@ -296,9 +292,9 @@ func TestAcquireSocketDialErrors(t *testing.T) {
)
retryMgr := NewRetryManager(retryCfg)
_, err := AcquireSocket(context.Background(), retryMgr, dial, errCh, nil)
_, err := DialWithRetry(context.Background(), retryMgr, dial, onError, nil)
assert.NoError(t, err)
assert.Len(t, errCh, 0)
assert.False(t, called)
})
}
+3
View File
@@ -1,10 +1,13 @@
package transport
import (
"errors"
"net/url"
"strings"
)
var InvalidProtocol = errors.New("URL must use ws:// or wss:// scheme")
func ParseURL(urlStr string) (*url.URL, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {
+142 -143
View File
@@ -2,6 +2,7 @@ package honeybee
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
@@ -14,18 +15,92 @@ import (
)
// ----------------------------------------------------------------------------
// Worker
// Errors
// ----------------------------------------------------------------------------
// ---------------------------/
// Types
// -------------------------/
var ErrConnectionUnavailable = errors.New("connection unavailable")
type WorkerFactory func(
ctx context.Context,
id string,
handler slog.Handler,
) (Worker, error)
// ----------------------------------------------------------------------------
// Config
// ----------------------------------------------------------------------------
type WorkerConfig struct {
ConnectionConfig transport.ConnectionConfig
RetryConfig transport.RetryConfig
RetryDisabled bool
KeepaliveTimeout time.Duration
ReconnectDelay time.Duration
}
type WorkerOption func(*WorkerConfig)
func NewWorkerConfig(opts ...WorkerOption) (*WorkerConfig, error) {
connCfg, _ := transport.NewConnectionConfig()
retryCfg, _ := transport.NewRetryConfig()
cfg := &WorkerConfig{
ConnectionConfig: *connCfg,
RetryConfig: retryCfg,
KeepaliveTimeout: 60 * time.Second,
ReconnectDelay: 2 * time.Second,
}
for _, o := range opts {
o(cfg)
}
if err := ValidateWorkerConfig(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func WithConnectionConfig(value transport.ConnectionConfig) WorkerOption {
return func(c *WorkerConfig) {
c.ConnectionConfig = value
}
}
func WithRetryConfig(value transport.RetryConfig) WorkerOption {
return func(c *WorkerConfig) {
c.RetryConfig = value
}
}
func WithRetryDisabled(value bool) WorkerOption {
return func(c *WorkerConfig) {
c.RetryDisabled = value
}
}
// When KeepaliveTimeout is set to zero, keepalive functionality is disabled.
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
return func(c *WorkerConfig) {
c.KeepaliveTimeout = value
}
}
func WithReconnectDelay(value time.Duration) WorkerOption {
return func(c *WorkerConfig) {
c.ReconnectDelay = value
}
}
func ValidateWorkerConfig(c *WorkerConfig) error {
if !c.RetryDisabled {
if err := transport.ValidateRetryConfig(c.RetryConfig); err != nil {
return err
}
}
if c.KeepaliveTimeout < 0 {
return fmt.Errorf("invalid keepalive timeout: %v", c.KeepaliveTimeout)
}
if c.ReconnectDelay < 0 {
return fmt.Errorf("invalid reconnect delay: %v", c.ReconnectDelay)
}
return nil
}
// ----------------------------------------------------------------------------
// Worker
// ----------------------------------------------------------------------------
type Worker interface {
Start(pool PoolPlugin)
@@ -35,11 +110,9 @@ type Worker interface {
}
type WorkerStats struct {
IncomingAvailable bool
ChanIncoming int
ConnectionAvailable bool
Connection transport.ConnectionStats
ChanIncoming int
TotalProcessed uint64
TotalSent uint64
@@ -47,14 +120,15 @@ type WorkerStats struct {
}
type DefaultWorker struct {
id string
conn atomic.Pointer[transport.Connection]
url string
dialFn func(context.Context) (types.Socket, error)
conn atomic.Pointer[transport.Connection]
sendHeartbeat chan struct{}
ctx context.Context
cancel context.CancelFunc
config *WorkerConfig
config WorkerConfig
handler slog.Handler
logger *slog.Logger
@@ -63,18 +137,15 @@ type DefaultWorker struct {
restartCount *atomic.Uint64
}
// ---------------------------/
// Constructor
// -------------------------/
func NewWorker(
ctx context.Context,
id string,
url string,
dialFn func(context.Context) (types.Socket, error),
config *WorkerConfig,
handler slog.Handler,
) (*DefaultWorker, error) {
if config == nil {
config = GetDefaultWorkerConfig()
config, _ = NewWorkerConfig()
}
if err := ValidateWorkerConfig(config); err != nil {
return nil, err
@@ -88,13 +159,14 @@ func NewWorker(
ctx, cancel := context.WithCancel(ctx)
w := &DefaultWorker{
id: id,
url: url,
dialFn: dialFn,
sendHeartbeat: make(chan struct{}),
ctx: ctx,
cancel: cancel,
config: config,
config: *config,
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
@@ -103,26 +175,20 @@ func NewWorker(
if handler != nil {
comp := component.FromContext(ctx)
w.handler = handler.WithAttrs([]slog.Attr{slog.String("peer", id)})
w.handler = handler.WithAttrs([]slog.Attr{slog.String("peer", url)})
w.logger = slog.New(w.handler).With(slog.Any("component", comp))
}
return w, nil
}
// ---------------------------/
// Session
// -------------------------/
func (w *DefaultWorker) Start(pool PoolPlugin) {
if w.logger != nil {
w.logger.Debug("starting")
}
var wg sync.WaitGroup
wg.Go(func() {
w.runSession(w.ctx, pool)
})
wg.Go(func() { w.runSession(w.ctx, pool) })
if w.logger != nil {
w.logger.Debug("started")
@@ -136,63 +202,51 @@ func (w *DefaultWorker) Start(pool PoolPlugin) {
}
func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
// setup dialer
var dialCancel context.CancelFunc
newConn := make(chan *transport.Connection, 1)
spawnDialer := func() { dialCancel = w.spawnDialer(ctx, dialCancel, newConn, pool) }
// setup heartbeat
timer, inactive, heartbeat := w.setupHeartbeat()
defer timer.Stop()
// main loop
for {
// spawn initial dial for this reconnect cycle
spawnDialer()
// obtain new connection
var conn *transport.Connection
preConn:
for {
select {
case <-ctx.Done():
if dialCancel != nil {
dialCancel()
}
var retryMgr *transport.RetryManager
if !w.config.RetryDisabled {
retryMgr = transport.NewRetryManager(w.config.RetryConfig)
}
onError := func(err error) {
if errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) {
return
case conn = <-newConn:
if w.logger != nil {
w.logger.Info("connected")
}
break preConn
case <-w.sendHeartbeat:
heartbeat()
case <-inactive():
if w.logger != nil {
w.logger.Warn("keepalive: no activity observed")
}
timer.Reset(w.config.KeepaliveTimeout)
spawnDialer()
}
pool.Events <- PoolEvent{
ID: w.url, Kind: EventDialFailed, Err: err, At: time.Now()}
}
socket, err := transport.DialWithRetry(
ctx, retryMgr, w.dialFn, onError, w.handler,
)
if err != nil {
// TODO: cleanup worker? surface error?
return
}
// setup new connection
conn, err := transport.NewConnection(
ctx, socket, &w.config.ConnectionConfig, w.handler)
if err != nil {
// TODO: cleanup worker? surface error?
return
}
w.conn.Store(conn)
pool.Events <- PoolEvent{ID: w.id, Kind: EventConnected, At: time.Now()}
pool.Events <- PoolEvent{ID: w.url, Kind: EventConnected, At: time.Now()}
if w.logger != nil {
w.logger.Debug("session: started")
}
// start keepalive service
stopKeepalive, inactive, heartbeat := w.startKeepalive()
// run session loop
conn_loop:
session:
for {
select {
case <-ctx.Done():
break conn_loop
break session
case data, ok := <-conn.Incoming():
if !ok {
@@ -205,11 +259,11 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
if w.logger != nil {
w.logger.Info("websocket: closed", "reason", reason)
}
break conn_loop
break session
}
pool.Inbox <- types.InboxMessage{
ID: w.id, Data: data, ReceivedAt: time.Now()}
ID: w.url, Data: data, ReceivedAt: time.Now()}
pool.InboxCounter.Add(1)
w.processedCount.Add(1)
@@ -226,13 +280,13 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
if w.logger != nil {
w.logger.Warn("keepalive: no activity observed")
}
timer.Reset(w.config.KeepaliveTimeout)
break conn_loop
break session
}
}
// session ended
conn.Close()
stopKeepalive()
if w.logger != nil {
w.logger.Info("disconnected")
@@ -243,7 +297,7 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
// tear down connection
w.conn.Store(nil)
pool.Events <- PoolEvent{ID: w.id, Kind: EventDisconnected, At: time.Now()}
pool.Events <- PoolEvent{ID: w.url, Kind: EventDisconnected, At: time.Now()}
// exit if worker is shutting down
select {
@@ -258,14 +312,20 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
}
}
func (w *DefaultWorker) setupHeartbeat() (
timer *time.Timer, inactive func() <-chan time.Time, heartbeat func(),
func (w *DefaultWorker) startKeepalive() (
stop func(),
inactive func() <-chan time.Time,
heartbeat func(),
) {
var timer *time.Timer
stop = func() {}
if w.config.KeepaliveTimeout > 0 {
if w.logger != nil {
w.logger.Debug("keepalive: enabled", "timeout", w.config.KeepaliveTimeout)
}
timer = time.NewTimer(w.config.KeepaliveTimeout)
stop = func() { timer.Stop() }
} else {
if w.logger != nil {
w.logger.Debug("keepalive: disabled")
@@ -295,65 +355,6 @@ func (w *DefaultWorker) setupHeartbeat() (
return
}
func (w *DefaultWorker) spawnDialer(
ctx context.Context,
dialCancel context.CancelFunc,
newConn chan<- *transport.Connection,
pool PoolPlugin,
) context.CancelFunc {
if dialCancel != nil {
dialCancel()
}
dialCtx, dialCancel := context.WithCancel(ctx)
if w.logger != nil {
w.logger.Debug("session: dialing")
}
onDialError := func(err error) {
if dialCtx.Err() != nil {
return
}
pool.Events <- PoolEvent{ID: w.id, Kind: EventDialFailed, Err: err, At: time.Now()}
}
go func() {
conn, err := connect(w.id, dialCtx, pool, w.handler, onDialError)
if err != nil {
return
}
select {
case newConn <- conn:
case <-dialCtx.Done():
conn.Close()
}
}()
return dialCancel
}
func connect(
id string,
ctx context.Context,
pool PoolPlugin,
handler slog.Handler,
onDialError func(error),
) (*transport.Connection, error) {
cc := pool.ConnectionConfig
conn, err := transport.NewConnection(ctx, id, &cc, handler)
if err != nil {
return nil, err
}
return conn, conn.Connect(ctx, onDialError)
}
// ---------------------------/
// Methods
// -------------------------/
func (w *DefaultWorker) Stop() {
if w.logger != nil {
w.logger.Info("shutting down")
@@ -365,12 +366,12 @@ func (w *DefaultWorker) Send(data []byte) error {
conn := w.conn.Load()
if conn == nil {
// connection not established by session
return NewWorkerError(w.id, ErrConnectionUnavailable)
return ErrConnectionUnavailable
}
err := conn.Send(data)
if err != nil {
return NewWorkerError(w.id, err)
return err
}
select {
@@ -396,11 +397,9 @@ func (w *DefaultWorker) Stats() WorkerStats {
}
return WorkerStats{
IncomingAvailable: connectionAvailable,
ChanIncoming: incomingLen,
ConnectionAvailable: connectionAvailable,
Connection: connStats,
ChanIncoming: incomingLen,
TotalProcessed: w.processedCount.Load(),
TotalRestarts: w.restartCount.Load(),
+62 -276
View File
@@ -5,11 +5,9 @@ import (
"errors"
"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"
"net/http"
"sync"
"sync/atomic"
"testing"
@@ -25,24 +23,31 @@ func makeWorkerContext(t *testing.T) (
inbox = make(chan types.InboxMessage, 256)
events = make(chan PoolEvent, 10)
pool = PoolPlugin{
Inbox: inbox,
Events: events,
InboxCounter: &atomic.Uint64{},
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
Inbox: inbox,
Events: events,
InboxCounter: &atomic.Uint64{},
}
return
}
func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *DefaultWorker {
func makeWorker(
t *testing.T,
socket *honeybeetest.MockSocket,
config *WorkerConfig,
ctx context.Context,
cancel context.CancelFunc,
) *DefaultWorker {
t.Helper()
config, _ := NewWorkerConfig(
WithReconnectDelay(0 * time.Second),
)
if config == nil {
config, _ = NewWorkerConfig(WithReconnectDelay(0 * time.Second))
}
dialFn := func(_ context.Context) (types.Socket, error) { return socket, nil }
return &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
url: "wss://test",
dialFn: dialFn,
config: *config,
sendHeartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
@@ -50,23 +55,14 @@ func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *D
}
}
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.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, nil, ctx, cancel)
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() {
@@ -76,27 +72,23 @@ func TestWorkerSession(t *testing.T) {
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.ID == w.id && e.Kind == EventConnected
return e.ID == w.url && e.Kind == EventConnected
default:
return false
}
}, "expected EventConnected")
})
// TODO: should dial failure cleanup worker?
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)
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
pool.ConnectionConfig = *cc
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return nil, nil, errors.New("connection refused")
},
w := makeWorker(t, nil, nil, ctx, cancel)
w.dialFn = func(_ context.Context) (types.Socket, error) {
return nil, fmt.Errorf("connection refused")
}
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
@@ -123,61 +115,16 @@ func TestWorkerSession(t *testing.T) {
}(), "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,
sendHeartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, _, pool := makeWorkerContext(t)
var dialCount atomic.Uint64
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
pool.ConnectionConfig = *cc
pool.ConnectionConfig.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()
},
}
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)
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
pool.ConnectionConfig = *cc
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
<-dialCtx.Done()
return nil, nil, dialCtx.Err()
},
w := makeWorker(t, nil, nil, ctx, cancel)
w.dialFn = func(dialCtx context.Context) (types.Socket, error) {
<-dialCtx.Done()
return nil, dialCtx.Err()
}
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
@@ -202,10 +149,9 @@ func TestWorkerSession(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.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, nil, ctx, cancel)
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() {
@@ -238,12 +184,9 @@ func TestWorkerSession(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 := honeybeetest.NewMockSocket()
mockSocket.CloseFunc = func() error {
mockSocket.Once.Do(func() { close(mockSocket.Closed) })
return nil
@@ -256,7 +199,8 @@ func TestWorkerSession(t *testing.T) {
}
}
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, nil, ctx, cancel)
inbox, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() {
@@ -287,7 +231,7 @@ func TestWorkerSession(t *testing.T) {
return false
}
}, "expected message on Inbox")
assert.Equal(t, w.id, received.ID)
assert.Equal(t, w.url, received.ID)
assert.Equal(t, []byte("hello"), received.Data)
assert.False(t, received.ReceivedAt.IsZero(), "expected non-zero ReceivedAt")
})
@@ -300,19 +244,9 @@ func TestWorkerSession(t *testing.T) {
WithReconnectDelay(0),
WithKeepaliveTimeout(60*time.Millisecond),
)
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
sendHeartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, events, pool := makeWorkerContext(t)
_, mockSocket, incomingData, _ := setupTestConnection(t)
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, config, ctx, cancel)
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
@@ -358,27 +292,17 @@ func TestWorkerSession(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,
sendHeartbeat: 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.ConnectionConfig.Dialer = mockDialer(mockSocket)
config, _ := NewWorkerConfig(
WithReconnectDelay(0),
WithKeepaliveTimeout(60*time.Millisecond),
)
w := makeWorker(t, mockSocket, config, ctx, cancel)
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
@@ -428,19 +352,9 @@ func TestWorkerSession(t *testing.T) {
WithReconnectDelay(0),
WithKeepaliveTimeout(30*time.Millisecond),
)
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
config: config,
sendHeartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, events, pool := makeWorkerContext(t)
_, mockSocket, _, _ := setupTestConnection(t)
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, config, ctx, cancel)
_, events, pool := makeWorkerContext(t)
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
@@ -479,10 +393,9 @@ func TestWorkerSession(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.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, nil, ctx, cancel)
var wg sync.WaitGroup
wg.Go(func() {
@@ -519,50 +432,13 @@ func TestWorkerSession(t *testing.T) {
}, "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.ConnectionConfig.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.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, nil, ctx, cancel)
var wg sync.WaitGroup
wg.Go(func() {
@@ -605,10 +481,9 @@ func TestWorkerSession(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.ConnectionConfig.Dialer = mockDialer(mockSocket)
w := makeWorker(t, mockSocket, nil, workerCtx, workerCancel)
var wg sync.WaitGroup
wg.Go(func() {
@@ -640,20 +515,16 @@ func TestWorkerSession(t *testing.T) {
}, "expected wg to drain after parent cancel")
})
t.Run("EventDialFailed emitted with correct ID, non-nil Err, and non-zero At when dialer fails", func(t *testing.T) {
t.Run("EventDialFailed emitted when dialer fails", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
w := makeWorker(t, nil, nil, ctx, cancel)
_, events, pool := makeWorkerContext(t)
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
pool.ConnectionConfig = *cc
dialErr := errors.New("connection refused")
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return nil, nil, dialErr
},
w.dialFn = func(_ context.Context) (types.Socket, error) {
return nil, dialErr
}
var wg sync.WaitGroup
@@ -663,7 +534,7 @@ func TestWorkerSession(t *testing.T) {
select {
case e := <-events:
return e.Kind == EventDialFailed &&
e.ID == w.id &&
e.ID == w.url &&
e.Err == dialErr &&
!e.At.IsZero()
default:
@@ -672,52 +543,16 @@ func TestWorkerSession(t *testing.T) {
}, "expected EventDialFailed")
})
t.Run("no EventDialFailed when first 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.ConnectionConfig.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 any remaining events and assert none are EventDialFailed
honeybeetest.Never(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDialFailed
default:
return false
}
}, "expected no EventDialFailed when first dial succeeds")
})
t.Run("no EventDialFailed when worker is stopped mid-dial", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := makeWorker(t, ctx, cancel)
w := makeWorker(t, nil, nil, ctx, cancel)
_, events, pool := makeWorkerContext(t)
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
pool.ConnectionConfig = *cc
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
<-dialCtx.Done()
return nil, nil, dialCtx.Err()
},
w.dialFn = func(dialCtx context.Context) (types.Socket, error) {
<-dialCtx.Done()
return nil, dialCtx.Err()
}
var wg sync.WaitGroup
@@ -745,55 +580,6 @@ func TestWorkerSession(t *testing.T) {
}
}, "expected no EventDialFailed when stopped mid-dial")
})
t.Run("no EventDialFailed when keepalive replaces an in-progress dial", 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,
sendHeartbeat: make(chan struct{}),
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
_, events, pool := makeWorkerContext(t)
var dialCount atomic.Uint64
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
pool.ConnectionConfig = *cc
pool.ConnectionConfig.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()
},
}
var wg sync.WaitGroup
wg.Go(func() { w.Start(pool) })
// keepalive fires after 20ms, cancelling the first dial and spawning a second
honeybeetest.Eventually(t, func() bool {
return dialCount.Load() >= 2
}, "expected at least two dial attempts after keepalive fired")
honeybeetest.Never(t, func() bool {
select {
case e := <-events:
return e.Kind == EventDialFailed
default:
return false
}
}, "expected no EventDialFailed when keepalive replaces dial")
})
}
func TestWorkerSend(t *testing.T) {
@@ -809,7 +595,7 @@ func TestWorkerSend(t *testing.T) {
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
url: "wss://test",
sendHeartbeat: heartbeat,
outgoingCount: &atomic.Uint64{},
}
@@ -854,7 +640,7 @@ func TestWorkerSend(t *testing.T) {
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
url: "wss://test",
sendHeartbeat: heartbeat,
outgoingCount: &atomic.Uint64{},
}
@@ -888,7 +674,7 @@ func TestWorkerSend(t *testing.T) {
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
url: "wss://test",
sendHeartbeat: heartbeat,
}
defer w.cancel()