23 Commits

Author SHA1 Message Date
Jay d04341bfa2 docs: update README, CONFIG, EXTEND for config/dialer refactoring 2026-05-26 15:08:33 -04:00
Jay 8c1371e3a0 pool: add ConnectOption/WithDialer for per-call dialer override on Connect 2026-05-26 14:59:03 -04:00
Jay d4da16f82a transport: copy-on-intake in NewConnection/NewPool; add ConnectionConfig.Clone; remove SetDialer; dialer via config 2026-05-26 14:46:10 -04:00
Jay 695389798e config: add Dialer field to ConnectionConfig and PoolConfig with option constructors 2026-05-26 14:17:58 -04:00
Jay fac62c0675 config: flatten WorkerConfig to value type in PoolConfig 2026-05-26 14:13:40 -04:00
Jay c82e0184f5 config: flatten ConnectionConfig to value type in PoolConfig 2026-05-26 14:11:03 -04:00
Jay c4d35fe6fa transport: flatten RetryConfig to value type, replace nil sentinel with Disabled bool 2026-05-26 14:01:14 -04:00
Jay bcbdb79b32 fix logging_test: align expected log levels to current source 2026-05-26 13:43:09 -04:00
Jay a721eabd48 place attribute on handler 2026-05-21 17:02:07 -04:00
Jay f144a2a724 update logging 2026-05-21 08:19:37 -04:00
Jay 90c0783953 increment year 2026-05-20 23:03:44 -04:00
Jay c8c8a528f6 cleanup 2026-05-20 23:03:06 -04:00
Jay f1afca7921 cleanup and refactors 2026-05-20 22:49:25 -04:00
Jay cda6d286ab 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
2026-05-20 18:17:04 -04:00
Jay b44a46ed2f Migrate logging to go-mana-component; delete logging/ package
Replaces the flat key-value logging scheme with component-based structured
logging via go-mana-component. Each layer (pool, worker, connection) builds
its own component identity and derives a *slog.Logger from a caller-supplied
slog.Handler.

- Delete logging/ package (logging.go, logging_test.go)
- Strip LoggingEnabled and LogLevel from ConnectionConfig, PoolConfig,
 WorkerConfig; remove associated option funcs
- Change NewConnection and NewConnectionFromSocket to accept ctx and
 slog.Handler instead of *slog.Logger; constructors build component
 identity via MustNew/MustExtend internally
- Change WorkerFactory, NewWorker, connect, and RunDialer to carry
 slog.Handler; remove PoolPlugin.Handler
- Change NewPool to establish pool component identity via MustNew;
 remove pool_id field, PoolPlugin.ID, and ErrInvalidPoolID
- Fix data race in MockSlogHandler: WithAttrs now shares parent mutex
 pointer rather than allocating a new one per child
- Run go fix
2026-05-20 13:04:58 -04:00
Jay 5b31db304a Updated documentation. 2026-05-20 10:44:53 -04:00
Jay 59f7b86a2e minor fixes 2026-05-20 09:05:49 -04:00
Jay ecd036b4eb Remove deprecated worker fields 2026-05-20 08:57:27 -04:00
Jay 6facb6eed0 remove references to the queue 2026-05-20 08:53:46 -04:00
Jay 093a56ea56 gut inbound and queue. promote outbound to honeybee. 2026-05-20 08:46:13 -04:00
Jay ba5484e0dd collapse queue/forwarder path: reader writes directly to pool inbox 2026-05-20 08:37:44 -04:00
Jay 8c7e3c3ee6 fix RunDialer: drain dial signals only after successful connect 2026-05-20 08:19:46 -04:00
Jay 09257e39b4 refactor: add idle watchgod to transport 2026-05-20 08:10:02 -04:00
53 changed files with 2272 additions and 5695 deletions
+4
View File
@@ -0,0 +1,4 @@
# go-honeybee
## Build
- Run `go fmt` on every edited file before staging.
+56 -105
View File
@@ -2,13 +2,38 @@
All configuration is done through option functions applied at construction time. Invalid values return errors at application time; configs produced through `NewXConfig` constructors are validated at construction and cannot be saved in an invalid state.
There are three config scopes. `ConnectionConfig` governs a single WebSocket connection: its write behavior, ping interval, buffers, and retry policy. `WorkerConfig` governs a single worker's behavior, with separate types for inbound and outbound. `PoolConfig` bundles a connection config, a worker config, and an optional worker factory — it is a thin container.
There are three config scopes. `ConnectionConfig` governs a single WebSocket connection: its write behavior, ping interval, buffers, and retry policy. `WorkerConfig` governs a single worker's behavior. `PoolConfig` bundles a connection config, a worker config, and an optional worker factory — it is a thin container.
Logging can be controlled independently at the pool, worker, and connection levels. Each scope has a `LoggingEnabled` flag and a `LogLevel` override. When `LoggingEnabled` is false, no logger is constructed for that scope regardless of what handler is passed to the pool. When `LogLevel` is set, it overrides the handler's own level filter for that scope only, using a wrapping handler that enforces the minimum level before delegating. When `LogLevel` is nil, the handler's own level filtering applies unchanged.
## Defaults
| Scope | Setting | Default | Disabled by | Notes |
|---|---|---|---|---|
| Connection | `WriteTimeout` | 30s | `0` | Per-message write deadline |
| Connection | `RequestHeader` | User-Agent | — | honeybee/0.1.0 |
| Connection | `PingInterval` | 20s | `0` | ±10% jitter applied per interval |
| Connection | `IncomingBufferSize` | 100 | — | Must be positive |
| Connection | `ErrorsBufferSize` | 10 | — | Must be positive |
| Connection | `LoggingEnabled` | true | `false` | |
| Connection | `LogLevel` | nil | — | nil defers to handler's own filter |
| Retry | enabled | yes | `WithRetryDisabled()` | Governs `Connect()` only |
| Retry | `MaxRetries` | 0 | — | 0 means infinite |
| Retry | `InitialDelay` | 1s | — | Must be positive |
| Retry | `MaxDelay` | 60s | — | Must be ≥ InitialDelay |
| Retry | `JitterFactor` | 0.2 | `0.0` | Range [0.0, 1.0] |
| Pool | `InboxBufferSize` | 256 | — | Must be positive |
| Pool | `EventsBufferSize` | 10 | — | Must be positive |
| Pool | `LoggingEnabled` | true | `false` | |
| Pool | `LogLevel` | nil | — | |
| Worker | `KeepaliveTimeout` | 60s | `0` | 0 disables keepalive |
| Worker | `ReconnectDelay` | 2s | `0` | 0 means reconnect immediately |
| Worker | `LoggingEnabled` | true | `false` | |
| Worker | `LogLevel` | nil | — | |
## Connection Options
`ConnectionConfig` is defined in the `transport` package. Import `git.wisehodl.dev/jay/go-honeybee/transport` to construct one, then pass it to a pool via `inbound.WithConnectionConfig` or `outbound.WithConnectionConfig`.
`ConnectionConfig` is defined in the `transport` package. Import `git.wisehodl.dev/jay/go-honeybee/transport` to construct one, then pass it to a pool via `honeybee.WithConnectionConfig`.
These options are passed to `transport.NewConnectionConfig`.
@@ -36,11 +61,16 @@ Sets the capacity of the channel that buffers inbound messages between the reade
**`WithErrorsBufferSize(int)`**
Sets the capacity of the channel that carries connection-level errors to the consumer. Must be at least 1.
### Dialer
**`WithConnectionDialer(types.Dialer)`**
Overrides the dialer used to establish the WebSocket connection. When not set, the connection uses the default dialer. Useful in tests or when routing connections through a custom transport.
### Retry
The retry policy governs the `Connect()` call only. It does not affect outbound worker reconnection, which is controlled by `ReconnectDelay` on the worker config.
The retry policy governs the `Connect()` call only. It does not affect worker reconnection, which is controlled by `ReconnectDelay` on the worker config.
**`WithoutRetry()`**
**`WithRetryDisabled()`**
Disables retry entirely. `Connect()` returns on the first dial failure.
**`WithRetryMaxRetries(int)`**
@@ -63,133 +93,54 @@ Enables or disables logging for the connection. When false, no logger is constru
**`WithConnectionLogLevel(slog.Level)`**
Overrides the minimum log level for connection-scoped records. Does not affect pool or worker logging.
## Inbound Pool Options
## Pool Options
Import `git.wisehodl.dev/jay/go-honeybee/inbound`.
Import `git.wisehodl.dev/jay/go-honeybee`.
### Pool
These are passed to `inbound.NewPoolConfig`.
These are passed to `honeybee.NewPoolConfig`.
**`inbound.WithInboxBufferSize(int)`**
**`honeybee.WithInboxBufferSize(int)`**
Sets the capacity of the pool's shared inbox channel. Must be at least 1.
**`inbound.WithEventsBufferSize(int)`**
**`honeybee.WithEventsBufferSize(int)`**
Sets the capacity of the pool's events channel. Must be at least 1.
**`inbound.WithPoolLoggingEnabled(bool)`**
**`honeybee.WithPoolLoggingEnabled(bool)`**
Enables or disables pool-level logging.
**`inbound.WithPoolLogLevel(slog.Level)`**
**`honeybee.WithPoolLogLevel(slog.Level)`**
Overrides the minimum log level for pool-scoped records only.
### Worker
These are passed to `inbound.NewWorkerConfig` or embedded in the pool config.
These are passed to `honeybee.NewWorkerConfig` or embedded in the pool config.
**`inbound.WithInactivityTimeout(duration)`**
Enables the inactivity watchdog. When no data messages or pong replies arrive within this duration, the peer is evicted and `inbound.EventEvictedPolicy` is emitted. When set to zero, the watchdog is disabled and connections persist until removed or remotely terminated. Must not be negative.
**`inbound.WithMaxQueueSize(int)`**
Bounds the worker's internal message queue. When the queue is full, the oldest undelivered message is dropped. When set to zero, the queue is unbounded. Must not be negative.
**`inbound.WithWorkerLoggingEnabled(bool)`**
Enables or disables worker-level logging.
**`inbound.WithWorkerLogLevel(slog.Level)`**
Overrides the minimum log level for worker-scoped records only.
### Wiring
**`inbound.WithConnectionConfig(*transport.ConnectionConfig)`**
Supplies a connection config that is applied to every socket added to the pool.
**`inbound.WithWorkerConfig(*inbound.WorkerConfig)`**
Supplies a worker config that is applied to every worker the pool creates.
**`inbound.WithWorkerFactory(inbound.WorkerFactory)`**
Replaces the default worker constructor. See [EXTEND.md](EXTEND.md) for the factory contract.
## Outbound Pool Options
Import `git.wisehodl.dev/jay/go-honeybee/outbound`.
### Pool
These are passed to `outbound.NewPoolConfig`.
**`outbound.WithInboxBufferSize(int)`**
Sets the capacity of the pool's shared inbox channel. Must be at least 1.
**`outbound.WithEventsBufferSize(int)`**
Sets the capacity of the pool's events channel. Must be at least 1.
**`outbound.WithPoolLoggingEnabled(bool)`**
Enables or disables pool-level logging.
**`outbound.WithPoolLogLevel(slog.Level)`**
Overrides the minimum log level for pool-scoped records only.
### Worker
These are passed to `outbound.NewWorkerConfig` or embedded in the pool config.
**`outbound.WithKeepaliveTimeout(duration)`**
**`honeybee.WithKeepaliveTimeout(duration)`**
Enables the keepalive mechanism. When no heartbeat (inbound data, outbound send, or pong reply) is observed within this duration, the current connection is closed and a new one is dialed. When set to zero, keepalive is disabled. Must not be negative.
**`outbound.WithReconnectDelay(duration)`**
**`honeybee.WithReconnectDelay(duration)`**
Sets the delay between a disconnect and the next dial attempt. Applies after every session end, including those triggered by keepalive. The default of 2 seconds prevents tight reconnect loops against unavailable peers. Set to zero in tests or when immediate reconnection is required. Must not be negative.
**`outbound.WithMaxQueueSize(int)`**
Bounds the worker's internal message queue. When the queue is full, the oldest undelivered message is dropped. When set to zero, the queue is unbounded. Must not be negative.
**`outbound.WithWorkerLoggingEnabled(bool)`**
**`honeybee.WithWorkerLoggingEnabled(bool)`**
Enables or disables worker-level logging.
**`outbound.WithWorkerLogLevel(slog.Level)`**
**`honeybee.WithWorkerLogLevel(slog.Level)`**
Overrides the minimum log level for worker-scoped records only.
### Per-connection
**`honeybee.WithDialer(types.Dialer)`**
Overrides the dialer for a single `Connect` call. Passed as a variadic option: `pool.Connect(id, honeybee.WithDialer(d))`. When provided, it takes precedence over the dialer resolved from `ConnectionConfig`. Existing callers that pass no options are unaffected.
### Wiring
**`outbound.WithConnectionConfig(*transport.ConnectionConfig)`**
Supplies a connection config used when dialing each peer.
**`honeybee.WithConnectionConfig(transport.ConnectionConfig)`**
Supplies a connection config used when dialing each peer. Accepted by value; the pool stores its own copy.
**`outbound.WithWorkerConfig(*outbound.WorkerConfig)`**
Supplies a worker config applied to every worker the pool creates.
**`honeybee.WithWorkerConfig(honeybee.WorkerConfig)`**
Supplies a worker config applied to every worker the pool creates. Accepted by value; the pool stores its own copy.
**`outbound.WithWorkerFactory(outbound.WorkerFactory)`**
**`honeybee.WithWorkerFactory(honeybee.WorkerFactory)`**
Replaces the default worker constructor. See [EXTEND.md](EXTEND.md) for the factory contract.
## Defaults
| Scope | Setting | Default | Disabled by | Notes |
|---|---|---|---|---|
| Connection | `WriteTimeout` | 30s | `0` | Per-message write deadline |
| Connection | `RequestHeader` | User-Agent | — | honeybee/0.1.0 |
| Connection | `PingInterval` | 20s | `0` | ±10% jitter applied per interval |
| Connection | `IncomingBufferSize` | 100 | — | Must be positive |
| Connection | `ErrorsBufferSize` | 10 | — | Must be positive |
| Connection | `LoggingEnabled` | true | `false` | |
| Connection | `LogLevel` | nil | — | nil defers to handler's own filter |
| Retry | enabled | yes | `WithoutRetry()` | Governs `Connect()` only |
| Retry | `MaxRetries` | 0 | — | 0 means infinite |
| Retry | `InitialDelay` | 1s | — | Must be positive |
| Retry | `MaxDelay` | 60s | — | Must be ≥ InitialDelay |
| Retry | `JitterFactor` | 0.2 | `0.0` | Range [0.0, 1.0] |
| Inbound pool | `InboxBufferSize` | 256 | — | Must be positive |
| Inbound pool | `EventsBufferSize` | 10 | — | Must be positive |
| Inbound pool | `LoggingEnabled` | true | `false` | |
| Inbound pool | `LogLevel` | nil | — | |
| Inbound worker | `InactivityTimeout` | 0 | `0` | 0 disables watchdog |
| Inbound worker | `MaxQueueSize` | 0 | `0` | 0 means unbounded |
| Inbound worker | `LoggingEnabled` | true | `false` | |
| Inbound worker | `LogLevel` | nil | — | |
| Outbound pool | `InboxBufferSize` | 256 | — | Must be positive |
| Outbound pool | `EventsBufferSize` | 10 | — | Must be positive |
| Outbound pool | `LoggingEnabled` | true | `false` | |
| Outbound pool | `LogLevel` | nil | — | |
| Outbound worker | `KeepaliveTimeout` | 60s | `0` | 0 disables keepalive |
| Outbound worker | `ReconnectDelay` | 2s | `0` | 0 means reconnect immediately |
| Outbound worker | `MaxQueueSize` | 0 | `0` | 0 means unbounded |
| Outbound worker | `LoggingEnabled` | true | `false` | |
| Outbound worker | `LogLevel` | nil | — | |
+19 -90
View File
@@ -1,10 +1,10 @@
# Extending Pools
# 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.Add` or `pool.Connect` and the final disconnect event is the worker's responsibility, and it is fully replaceable.
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
Both pools accept any type that satisfies:
The pool accepts any type that satisfies:
```go
type Worker interface {
@@ -13,7 +13,7 @@ type Worker interface {
Send(data []byte) error
Stats() WorkerStats
}
````
```
The behavioral contract for each method:
@@ -30,123 +30,52 @@ The behavioral contract for each method:
The pool constructs a `PoolPlugin` and passes it to `Start`. It gives the worker access to pool-level channels and the logging handler.
```go
// inbound.PoolPlugin
type PoolPlugin struct {
Inbox chan<- inbound.InboxMessage
Events chan<- inbound.PoolEvent
InboxCounter *atomic.Uint64
OnExit inbound.OnExitFunction // inbound only
Handler slog.Handler
}
// outbound.PoolPlugin
type PoolPlugin struct {
ID string
Inbox chan<- outbound.InboxMessage
Events chan<- outbound.PoolEvent
Inbox chan<- honeybee.InboxMessage
Events chan<- honeybee.PoolEvent
InboxCounter *atomic.Uint64
Dialer outbound.Dialer
ConnectionConfig *transport.ConnectionConfig
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. Outbound workers emit `outbound.EventConnected` and `outbound.EventDisconnected` directly. Inbound workers do not touch this channel directly; instead they call `OnExit`, and the pool translates the exit kind into the appropriate event. All events include a timestamp in the `At` field.
**`Events`** The shared channel for lifecycle events. Workers emit `honeybee.EventConnected` and `honeybee.EventDisconnected` directly. All events include a timestamp in the `At` field.
**`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()`.
**`OnExit` (inbound only)** Must be called exactly once when an inbound worker exits on its own initiative — that is, when the peer closed the connection or the watchdog fired, not when `Stop` was called. The pool wraps the underlying function in a `sync.Once` as a safety net, but a well-behaved worker calls it once. Calling `OnExit` removes the peer from the pool's registry and emits the appropriate event.
**`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 Inbound Pool
## Extending the Pool
### Factory Signature
```go
type inbound.WorkerFactory func(
ctx context.Context,
id string,
conn *transport.Connection,
config *inbound.WorkerConfig,
logger *slog.Logger,
) (inbound.Worker, error)
```
The pool calls the factory under its write lock when `Add` or `Replace` is called. The factory must return without blocking. The pool constructs `logger` from the worker logging config before calling the factory; pass it to your worker or ignore it.
The factory is set via `inbound.WithWorkerFactory` on the pool config.
### Building Blocks
The default inbound worker is assembled from these exported functions. Each can be reused, replaced, or wrapped independently.
**`RunReader(ctx, onExit, conn, messages, heartbeat, logger)`** Reads from `conn.Incoming()` until the channel closes or `ctx` is cancelled. Forwards each message to `messages` and sends a signal on `heartbeat` for each message received. When the channel closes, classifies the exit — clean disconnect, unexpected close, or read error — and calls `onExit` with the appropriate `WorkerExitKind`. Does not call `onExit` on context cancellation.
**`RunHeartbeatForwarder(ctx, conn, heartbeat, logger)`** Reads from `conn.Heartbeat()` and forwards each signal to `heartbeat`. This propagates pong replies from the connection layer into the worker's heartbeat channel, so pongs reset the inactivity watchdog alongside data messages.
**`RunQueue(id, ctx, in, out, maxQueueSize, droppedCount, bufferDepth)`** Buffers messages between the reader and the forwarder. When `maxQueueSize` is positive and the queue is full, the oldest message is dropped and `droppedCount` is incremented. When `maxQueueSize` is zero, the queue grows without bound. `bufferDepth` is an `*atomic.Int64` maintained as the current number of items in the queue.
**`RunForwarder(id, ctx, messages, inbox, workerProcessedCount, poolInboxCount)`** Reads from `messages` and writes to `inbox`. Increments both counters on each successful delivery.
**`RunWatchdog(ctx, onInactive, heartbeat, timeout, logger)`** Monitors `heartbeat`. Resets a timer on each signal. When the timer fires, calls `onInactive(ExitPolicy)`. When `timeout` is zero, the watchdog is disabled: it drains `heartbeat` without acting and exits when `ctx` is cancelled.
### Replacement Patterns
**Swap one block.** Embed `*inbound.DefaultWorker`, override `Start`, reuse the goroutines you want unchanged, and substitute your own implementation for the one you are replacing. For example, to tag every forwarded message with a sequence number, reuse `RunReader`, `RunQueue`, and `RunWatchdog` verbatim, and write a custom forwarder.
**Wrap the plugin.** Intercept `Inbox` by substituting a channel you own, process messages, then forward to the original. The worker signature is unchanged; you compose behavior by controlling what the building blocks see.
**Implement from scratch.** Satisfy the `Worker` interface directly. The only obligations are the behavioral contracts on `Start`, `Stop`, `Send`, `Stats`, and `OnExit` described above.
## Extending the Outbound Pool
### Factory Signature
```go
type outbound.WorkerFactory func(
type honeybee.WorkerFactory func(
ctx context.Context,
id string,
logger *slog.Logger,
) (outbound.Worker, error)
) (honeybee.Worker, error)
```
The pool calls the factory under its write lock when `Connect` is called. The factory must return without blocking. Note that the outbound factory does not receive a `*transport.Connection`; 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 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 `outbound.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.
**`RunReader(ctx, onStop, conn, messages, heartbeat, logger)`** Reads from `conn.Incoming()` until the channel closes or `ctx` is cancelled. Forwards each message to `messages` and sends a signal on `heartbeat`. 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.
**`RunForwarder(id, ctx, messages, inbox, workerProcessedCount, poolInboxCount)`** Reads from `messages` and writes to `inbox`. Increments both counters on each successful delivery. Identical in behavior to the inbound variant.
**`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 `EventConnected` and `EventDisconnected` to `pool.Events`, and incrementing `pool.InboxCounter`.
`DefaultWorker`'s source is the authoritative reference for how those responsibilities are met.
## Factory Constraints
Factories for both pool types are called while the pool holds its write lock. Two constraints follow from this directly.
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.Add`, `pool.Send`, `pool.Remove`, and similar methods all acquire the same lock the factory is called under. Calling them from the factory will deadlock.
**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 `Add` or `Connect`.
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`.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf
Copyright (c) 2026 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+160 -133
View File
@@ -5,38 +5,31 @@ WebSocket connection and pool primitives in Go. Built for Nostr.
## Library Map
```txt
transport/ single-connection primitives
connection.go *Connection, state machine, reader goroutine, pinger
honeybee.go Pool, Worker, public types
transport/ single-connection primitives and helpers
connection.go Connection, state machine, reader goroutine, pinger
config.go ConnectionConfig, RetryConfig, options
retry.go exponential backoff with jitter
socket.go Dialer interface, AcquireSocket
url.go parsing and normalization
inbound/ pool for peer-initiated connections
pool.go Pool, Peer, event plumbing
worker.go Worker interface, DefaultWorker, Run* functions
config.go WorkerConfig, PoolConfig, options
outbound/ pool for self-initiated connections
pool.go Pool, Peer, event plumbing
worker.go Worker interface, DefaultWorker, Session, Run* functions
config.go WorkerConfig, PoolConfig, options
watchdog.go IdleWatchdog helper
logging/ structured log construction
logging.go logger constructors, ForcedLevelHandler
types/ internal interfaces
types/ shared interfaces (Dialer, Socket, ReceivedMessage)
honeybeetest/ test helpers and mocks for consumers
````
```
## What This Library Does
Honeybee is a reliable and simple library for managing websocket connections and pools.
Honeybee is a minimal, general-purpose WebSocket transport library.
- Handles websocket connections and pools cleanly and safely.
- Provides two pools: one to manage outbound peers and another to manage inbound peers.
- Exposes statistics at the connection, worker, and pool levels.
- Exposes a means to replace the internal pool worker to inject custom extensions.
- Client-Side: Manage a pool of outbound peer connections that reconnect automatically and surface lifecycle events.
- Server-Side: Wrap already-upgraded sockets in the connection primitive, which provides a ping-based heartbeat, automated read-loop, concurrent-safe writes, and classified disconnect errors.
- The same connection primitive may also be used directly on the client side when pool semantics are not needed, providing automated dialing retry with exponential backoff and jitter.
- Exposes a means to completely replace the internal pool worker to inject custom behavior.
## What This Library Does Not Do
@@ -45,7 +38,7 @@ Honeybee is a pure transport layer, but it is also a deliberately simple one. Ho
Honeybee does not provide:
- interpretation of message content. All messages are treated equally.
- message queuing, prioritization, batching, or coalescing.
- message queuing, buffering, prioritization, batching, or coalescing.
- rate limiting, circuit breakers, token buckets, or adaptive throttling.
- broadcast, fanout, or any many-to-many message routing.
- compression strategies, prepared message caching, or encoding optimization.
@@ -65,11 +58,146 @@ If the primary repository is unavailable, use the `replace` directive in your go
replace git.wisehodl.dev/jay/go-honeybee => github.com/wisehodl/go-honeybee latest
```
## Usage
## Outbound Pool
### Bare Connection
The pool connects to peers by their URLs and keeps them connected. It reconnects automatically when a connection drops and proactively refreshes inactive connections.
Use `transport` directly when you need one socket and do not want pool semantics.
```go
import "git.wisehodl.dev/jay/go-honeybee"
pool, err := honeybee.NewPool(ctx, nil, handler)
if err != nil { /* handle error or panic */ }
defer pool.Close()
if err := pool.Connect("wss://peer.example.com"); err != nil { /* handle error */ }
go func() {
for msg := range pool.Inbox() {
// msg.ID is the normalized URL
// msg.Data is the payload
// msg.ReceivedAt is the timestamp
}
}()
go func() {
for ev := range pool.Events() {
// ev.At is the event timestamp
switch ev.Kind {
case honeybee.EventConnected:
case honeybee.EventDisconnected:
}
}
}()
pool.Send("wss://peer.example.com", []byte("hello"))
```
**Usage Notes:**
URLs are normalized by the pool. `wss://peer.example.com`, `wss://peer.example.com/`, and `WSS://Peer.Example.Com:443` all identify the same peer. `honeybee.NormalizeURL` is also available directly if you need to use the same URLs as keys elsewhere.
Every time a connection is established, `honeybee.EventConnected` is emitted. Every time a connection drops for any reason, `honeybee.EventDisconnected` is emitted. A peer that reconnects three times produces three Connected/Disconnected pairs.
Keepalive is configured via `honeybee.WithKeepaliveTimeout`. The worker records a heartbeat on every inbound message, every successful send, and every received pong. If no heartbeats arrive before the keepalive timer fires, the connection is proactively disconnected and reconnected. When set to zero, keepalive is disabled.
After a disconnect, the worker waits for `ReconnectDelay` before attempting the next connection. The default is 2 seconds. Set to zero in tests or when you need immediate reconnection.
`Send` returns `ErrConnectionUnavailable` during the gap between a disconnect and the next successful reconnect. Callers should wait for `EventConnected` before retrying and maintain their own write buffers if needed.
Dial failures are handled internally by the worker's retry logic and documented in structured logs. These do not stop the pool; it continues retrying according to the connection's retry config.
## Server-Side Usage
### Connection
Use `transport.NewConnectionFromSocket` when your HTTP upgrade handler gives you an open socket. The connection starts in `StateConnected`; do not call `Connect`.
```go
import "git.wisehodl.dev/jay/go-honeybee/transport"
// wsConn is a *websocket.Conn from your upgrade handler
conn, err := transport.NewConnectionFromSocket(wsConn, nil, logger)
if err != nil { /* handle error */ }
defer conn.Close()
go func() {
for data := range conn.Incoming() { // data: []byte
// process incoming messages
}
}()
go func() {
for err := range conn.Errors() {
// log or handle disconnects / read errors
}
}()
// Send can be called concurrently
conn.Send([]byte("hello"))
```
`Send` is safe for concurrent callers. `Close` is idempotent and safe to call from any goroutine.
When the reader exits, exactly one classified error reaches `Errors()` before the channel closes.
- `ErrPeerClosedClean` for normal closure
- `ErrPeerClosedUnexpected` for abnormal close codes
- `ErrReadError` for anything else
Pass an `*slog.Logger` as the third argument to get structured logs. Pass nil to disable logging entirely.
### IdleWatchdog
The watchdog helper detects clients that have gone silent. Wire activity signals from your `Incoming()` consumer, on each Send() call, and from the connection's `Heartbeat()` channel into it, and provide a callback to invoke when the timeout fires. Feeding all three sources means a client that neither sends or receives data but still responds to pings will not be considered idle. Since there is no reconnect loop on the server side, the typical callback is `conn.Close()`.
```go
import "time"
activity := make(chan struct{}, 1)
signal := func() {
select {
case activity <- struct{}{}:
default:
}
}
// Feed data messages:
go func() {
for data := range conn.Incoming() {
signal()
// process data
}
}()
// Feed sends:
func SendWithHeartbeat(conn *Connection, data []byte) error {
err := conn.Send(data)
if err != nil {
return err
}
signal()
return nil
}
// Feed pong heartbeats:
go func() {
for range conn.Heartbeat() {
signal()
}
}()
// Start the watchdog:
go transport.IdleWatchdog(ctx, activity, 30*time.Second, func() {
conn.Close()
})
```
When no activity signal arrives within the timeout, `onTimeout` is called once and the watchdog exits. When `ctx` is cancelled, the watchdog exits without calling `onTimeout`. When the timeout is zero or negative, the watchdog drains activity signals and waits for `ctx` to be cancelled without ever firing.
## Bare Connection
Use `transport.NewConnection` when you need a single outbound connection without pool semantics — for example, a one-shot query or a custom use-case. It adds several conveniences over a raw socket: a retry loop with exponential backoff, concurrent-safe writes, automatic write deadline enforcement, classified disconnect errors, and observable connection state.
```go
import "git.wisehodl.dev/jay/go-honeybee/transport"
@@ -88,14 +216,15 @@ go func() {
go func() {
for err := range conn.Errors() {
// log or handle
// log or handle disconnects / read errors
}
}()
// Send can be called concurrently
conn.Send([]byte("hello"))
```
The connection goes through four states: `StateDisconnected`, `StateConnecting`, `StateConnected`, `StateClosed`. Transitions are atomic and observable via `conn.State()`. Once closed, the connection should not be reused. Instead, construct a new one with the same URL and reconnect.
The connection goes through four states: `StateDisconnected`, `StateConnecting`, `StateConnected`, `StateClosed`. Transitions are atomic and observable via `conn.State()`. Once closed, the connection should not be reused; construct a new one with the same URL and reconnect.
`Send` is safe for concurrent callers. `Close` is idempotent and safe to call from any goroutine.
@@ -105,118 +234,17 @@ When the reader exits, exactly one classified error reaches `Errors()` before th
- `ErrPeerClosedUnexpected` for abnormal close codes
- `ErrReadError` for anything else
Consumers use this to decide whether the disconnect was expected. No other errors are sent by the connection.
Pass an `*slog.Logger` as the third argument to get structured logs. Pass nil to disable logging entirely.
### Inbound Pool
The inbound pool manages connections initiated by peers. The consumer accepts a WebSocket somewhere else and hands the resulting socket to the pool along with an ID.
Each pool requires a non-empty string ID. This ID is attached to all structured log records emitted by the pool, its workers, and their connections.
```go
import "git.wisehodl.dev/jay/go-honeybee/inbound"
pool, err := inbound.NewPool(ctx, "my-pool", nil, handler)
if err != nil { /* handle error */ }
defer pool.Close()
// When a peer connects at the HTTP layer:
if err := pool.Add(peerID, socket); err != nil { /* handle error */ }
// Consume inbound data from all peers on one channel:
go func() {
for msg := range pool.Inbox() {
// msg.ID identifies the peer
// msg.Data is the payload
// msg.ReceivedAt is the timestamp
}
}()
// React to peer lifecycle:
go func() {
for ev := range pool.Events() {
// ev.At is the event timestamp
switch ev.Kind {
case inbound.EventDisconnected: // clean close
case inbound.EventDroppedClose: // peer closed with abnormal code
case inbound.EventDroppedError: // read error
case inbound.EventEvictedPolicy: // inactivity timeout
}
}
}()
pool.Send(peerID, []byte("response"))
```
`Add`, `Replace`, and `Remove` do not emit events. Events are emitted only when a worker exits on its own, either when the peer closed the socket or it was determined to be inactive.
Use `Replace` if you need to swap the socket for a peer while keeping its ID. No events are emitted during this process.
The watchdog is configured via `inbound.WithInactivityTimeout`. When set to zero, it is disabled. When set, the watchdog observes message traffic and disconnects the peer if no messages arrive within the configured duration. The watchdog is disabled by default, meaning connections persist until manually removed or remotely terminated.
### Outbound Pool
The outbound pool connects to peers by their URLs and keeps them connected. It reconnects automatically when a connection drops and proactively refreshes inactive connections.
Each pool requires a non-empty string ID. This ID is attached to all structured log records emitted by the pool, its workers, and their connections.
```go
import "git.wisehodl.dev/jay/go-honeybee/outbound"
pool, err := outbound.NewPool(ctx, "my-pool", nil, handler)
if err != nil { /* handle error */ }
defer pool.Close()
if err := pool.Connect("wss://peer.example.com"); err != nil { /* handle error */ }
go func() {
for msg := range pool.Inbox() {
// msg.ID is the normalized URL
// msg.Data is the payload
// msg.ReceivedAt is the timestamp
}
}()
go func() {
for ev := range pool.Events() {
// ev.At is the event timestamp
switch ev.Kind {
case outbound.EventConnected:
case outbound.EventDisconnected:
}
}
}()
pool.Send("wss://peer.example.com", []byte("hello"))
```
URLs are normalized by the pool. `wss://peer.example.com`, `wss://peer.example.com/`, and `WSS://Peer.Example.Com:443` all identify the same peer. `outbound.NormalizeURL` is also available directly if you need to use the same URLs as keys elsewhere.
Every time a connection is established, `outbound.EventConnected` is emitted. Every time a connection drops for any reason, `outbound.EventDisconnected` is emitted. A peer that reconnects three times produces three Connected/Disconnected pairs.
Keepalive is configured via `outbound.WithKeepaliveTimeout`. The worker records a heartbeat on every inbound message, every successful send, and every received pong. If no heartbeats arrive before the keepalive timer fires, the connection is proactively disconnected and reconnected. When set to zero, keepalive is disabled.
After a disconnect, the worker waits for `ReconnectDelay` before attempting the next connection. The default is 2 seconds. Set to zero in tests or when you need immediate reconnection.
`Send` returns `ErrConnectionUnavailable` during the gap between a disconnect and the next successful reconnect. Callers should wait for `OutboundEventConnected` before retrying and maintain their own write buffers if needed.
Dial failures are handled internally by the worker's retry logic and documented in structured logs. These do not stop the pool; it continues retrying according to the connection's retry config.
## Ping-Pong Heartbeats
Connections send periodic WebSocket ping frames and listen for the corresponding pong replies. A received pong registers as a heartbeat signal within the worker.
For inbound workers, pong-derived heartbeats reset the inactivity watchdog timer alongside data messages. A peer that sends no data but responds to pings will not be evicted.
Pong-derived heartbeats reset the keepalive timer alongside data messages and sends. A peer that sends no data but responds to pings will not be disconnected and reconnected by the keepalive mechanism.
For outbound workers, pong-derived heartbeats reset the keepalive timer. A peer that sends no data but responds to pings will not be disconnected and reconnected by the keepalive mechanism.
The ping interval is configured via `transport.WithPingInterval` on the `transport.ConnectionConfig`. Import `git.wisehodl.dev/jay/go-honeybee/transport` to construct a `ConnectionConfig`, then pass it to the pool via `inbound.WithConnectionConfig` or `outbound.WithConnectionConfig`. The default is 20 seconds. Set to zero to disable pings entirely, in which case only data messages and outbound sends generate heartbeats.
The ping interval is configured via `transport.WithPingInterval` on the `transport.ConnectionConfig`. Import `git.wisehodl.dev/jay/go-honeybee/transport` to construct a `ConnectionConfig`, then pass it to the pool by value via `honeybee.WithConnectionConfig`, or supply it directly to `NewConnection` and `NewConnectionFromSocket`. The default is 20 seconds. Set to zero to disable pings entirely, in which case only data messages and outbound sends generate heartbeats.
## Statistics
Pools, workers, and connections expose counters and channel depths that can be sampled at any time. All values are snapshots; counters are monotonically increasing and are not reset between reconnects on an outbound worker.
Pools, workers, and connections expose counters and channel depths that can be sampled at any time. All values are snapshots; counters are monotonically increasing and are not reset between reconnects.
```go
// Pool-level snapshot
@@ -229,8 +257,7 @@ stats := pool.Stats()
// Single peer
peerStats, err := pool.PeerStats(peerID)
// peerStats.Worker — queue depths, buffer depth, processed/dropped/sent counts
// peerStats.Connection — channel depths, receive/send/heartbeat counts (inbound)
// peerStats.Worker — channel depths, processed/sent counts
// Bare connection (transport package)
connStats := conn.Stats() // conn is a *transport.Connection
@@ -239,9 +266,9 @@ connStats := conn.Stats() // conn is a *transport.Connection
## 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 both inbound and outbound pools.
See EXTEND.md for the worker interface contract, the `PoolPlugin` fields, and extension patterns.
## Configuration
+35 -93
View File
@@ -1,34 +1,28 @@
package outbound
package honeybee
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/transport"
"log/slog"
"time"
)
// Types
type WorkerFactory func(
ctx context.Context,
id string,
logger *slog.Logger,
) (Worker, error)
// ----------------------------------------------------------------------------
// Pool Config
// ----------------------------------------------------------------------------
// Types
type PoolConfig struct {
InboxBufferSize int
EventsBufferSize int
LoggingEnabled bool
LogLevel *slog.Level
ConnectionConfig *transport.ConnectionConfig
ConnectionConfig transport.ConnectionConfig
WorkerFactory WorkerFactory
WorkerConfig *WorkerConfig
WorkerConfig WorkerConfig
}
type PoolOption func(*PoolConfig) error
// Constructor
func NewPoolConfig(options ...PoolOption) (*PoolConfig, error) {
conf := GetDefaultPoolConfig()
if err := applyPoolOptions(conf, options...); err != nil {
@@ -44,11 +38,9 @@ func GetDefaultPoolConfig() *PoolConfig {
return &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
LoggingEnabled: true,
LogLevel: nil,
ConnectionConfig: nil,
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
WorkerFactory: nil,
WorkerConfig: nil,
WorkerConfig: *GetDefaultWorkerConfig(),
}
}
@@ -61,21 +53,19 @@ func applyPoolOptions(config *PoolConfig, options ...PoolOption) error {
return nil
}
// Validation
func ValidatePoolConfig(config *PoolConfig) error {
var err error
if config.ConnectionConfig != nil {
err = transport.ValidateConnectionConfig(config.ConnectionConfig)
if err != nil {
return err
}
err = transport.ValidateConnectionConfig(&config.ConnectionConfig)
if err != nil {
return err
}
if config.WorkerConfig != nil {
err = ValidateWorkerConfig(config.WorkerConfig)
if err != nil {
return err
}
err = ValidateWorkerConfig(&config.WorkerConfig)
if err != nil {
return err
}
return nil
@@ -88,6 +78,8 @@ func validateBufferSize(value int) error {
return nil
}
// Options
func WithInboxBufferSize(value int) PoolOption {
return func(c *PoolConfig) error {
if err := validateBufferSize(value); err != nil {
@@ -108,24 +100,9 @@ func WithEventsBufferSize(value int) PoolOption {
}
}
func WithPoolLoggingEnabled(value bool) PoolOption {
func WithConnectionConfig(cc transport.ConnectionConfig) PoolOption {
return func(c *PoolConfig) error {
c.LoggingEnabled = value
return nil
}
}
func WithPoolLogLevel(level slog.Level) PoolOption {
return func(c *PoolConfig) error {
l := level
c.LogLevel = &l
return nil
}
}
func WithConnectionConfig(cc *transport.ConnectionConfig) PoolOption {
return func(c *PoolConfig) error {
err := transport.ValidateConnectionConfig(cc)
err := transport.ValidateConnectionConfig(&cc)
if err != nil {
return err
}
@@ -134,9 +111,9 @@ func WithConnectionConfig(cc *transport.ConnectionConfig) PoolOption {
}
}
func WithWorkerConfig(wc *WorkerConfig) PoolOption {
func WithWorkerConfig(wc WorkerConfig) PoolOption {
return func(c *PoolConfig) error {
err := ValidateWorkerConfig(wc)
err := ValidateWorkerConfig(&wc)
if err != nil {
return err
}
@@ -152,18 +129,21 @@ func WithWorkerFactory(wf WorkerFactory) PoolOption {
}
}
// ----------------------------------------------------------------------------
// Worker Config
// ----------------------------------------------------------------------------
// Types
type WorkerConfig struct {
KeepaliveTimeout time.Duration
ReconnectDelay time.Duration
MaxQueueSize int
LoggingEnabled bool
LogLevel *slog.Level
}
type WorkerOption func(*WorkerConfig) error
// Constructor
func NewWorkerConfig(options ...WorkerOption) (*WorkerConfig, error) {
conf := GetDefaultWorkerConfig()
if err := applyWorkerOptions(conf, options...); err != nil {
@@ -179,9 +159,6 @@ func GetDefaultWorkerConfig() *WorkerConfig {
return &WorkerConfig{
KeepaliveTimeout: 60 * time.Second,
ReconnectDelay: 2 * time.Second,
MaxQueueSize: 0, // disabled by default
LoggingEnabled: true,
LogLevel: nil,
}
}
@@ -194,24 +171,14 @@ func applyWorkerOptions(config *WorkerConfig, options ...WorkerOption) error {
return nil
}
// Validation
func ValidateWorkerConfig(config *WorkerConfig) error {
err := validateKeepaliveTimeout(config.KeepaliveTimeout)
if err != nil {
return err
}
err = validateMaxQueueSize(config.MaxQueueSize)
if err != nil {
return err
}
return nil
}
func validateMaxQueueSize(value int) error {
if value < 0 {
return InvalidMaxQueueSize
}
return nil
}
@@ -229,6 +196,8 @@ func validateReconnectDelay(value time.Duration) error {
return nil
}
// Options
// When KeepaliveTimeout is set to zero, keepalive timeouts are disabled.
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
return func(c *WorkerConfig) error {
@@ -251,30 +220,3 @@ func WithReconnectDelay(value time.Duration) WorkerOption {
return nil
}
}
// When MaxQueueSize is set to zero, queue limits are disabled.
func WithMaxQueueSize(value int) WorkerOption {
return func(c *WorkerConfig) error {
err := validateMaxQueueSize(value)
if err != nil {
return err
}
c.MaxQueueSize = value
return nil
}
}
func WithWorkerLoggingEnabled(value bool) WorkerOption {
return func(c *WorkerConfig) error {
c.LoggingEnabled = value
return nil
}
}
func WithWorkerLogLevel(level slog.Level) WorkerOption {
return func(c *WorkerConfig) error {
l := level
c.LogLevel = &l
return nil
}
}
@@ -1,4 +1,4 @@
package outbound
package honeybee
import (
"git.wisehodl.dev/jay/go-honeybee/transport"
@@ -14,10 +14,8 @@ func TestNewPoolConfig(t *testing.T) {
assert.Equal(t, conf, &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
LoggingEnabled: true,
LogLevel: nil,
ConnectionConfig: nil,
WorkerConfig: nil,
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
WorkerConfig: *GetDefaultWorkerConfig(),
WorkerFactory: nil,
})
}
@@ -28,10 +26,8 @@ func TestDefaultPoolConfig(t *testing.T) {
assert.Equal(t, conf, &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
LoggingEnabled: true,
LogLevel: nil,
ConnectionConfig: nil,
WorkerConfig: nil,
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
WorkerConfig: *GetDefaultWorkerConfig(),
WorkerFactory: nil,
})
}
@@ -40,7 +36,9 @@ func TestApplyPoolOptions(t *testing.T) {
conf := &PoolConfig{}
err := applyPoolOptions(
conf,
WithConnectionConfig(&transport.ConnectionConfig{}),
WithConnectionConfig(transport.ConnectionConfig{
Retry: transport.RetryConfig{Disabled: true},
}),
)
assert.NoError(t, err)
@@ -61,15 +59,21 @@ func TestWithBufferSizes(t *testing.T) {
func TestWithConnectionConfig(t *testing.T) {
conf := &PoolConfig{}
opt := WithConnectionConfig(&transport.ConnectionConfig{WriteTimeout: 1 * time.Second})
opt := WithConnectionConfig(transport.ConnectionConfig{
WriteTimeout: 1 * time.Second,
Retry: transport.RetryConfig{Disabled: true},
})
err := applyPoolOptions(conf, opt)
assert.NoError(t, err)
assert.NotNil(t, conf.ConnectionConfig)
assert.Equal(t, 1*time.Second, conf.ConnectionConfig.WriteTimeout)
// invalid config is rejected
conf = &PoolConfig{}
opt = WithConnectionConfig(&transport.ConnectionConfig{WriteTimeout: -1 * time.Second})
opt = WithConnectionConfig(
transport.ConnectionConfig{
WriteTimeout: -1 * time.Second,
Retry: transport.RetryConfig{Disabled: true},
})
err = applyPoolOptions(conf, opt)
assert.Error(t, err)
}
@@ -82,8 +86,12 @@ func TestValidatePoolConfig(t *testing.T) {
wantErrText string
}{
{
name: "valid empty",
conf: *&PoolConfig{},
name: "valid empty (retry disabled)",
conf: PoolConfig{
ConnectionConfig: transport.ConnectionConfig{
Retry: transport.RetryConfig{Disabled: true},
},
},
},
{
name: "valid defaults",
@@ -92,14 +100,16 @@ func TestValidatePoolConfig(t *testing.T) {
{
name: "valid complete",
conf: PoolConfig{
ConnectionConfig: &transport.ConnectionConfig{},
ConnectionConfig: transport.ConnectionConfig{
Retry: transport.RetryConfig{Disabled: true},
},
},
},
{
name: "invalid connection config",
conf: PoolConfig{
ConnectionConfig: &transport.ConnectionConfig{
Retry: &transport.RetryConfig{
ConnectionConfig: transport.ConnectionConfig{
Retry: transport.RetryConfig{
InitialDelay: 10 * time.Second,
MaxDelay: 1 * time.Second,
},
+4 -6
View File
@@ -1,4 +1,4 @@
package outbound
package honeybee
import "errors"
import "fmt"
@@ -7,14 +7,12 @@ var (
// Config errors
InvalidKeepaliveTimeout = errors.New("keepalive timeout cannot be negative")
InvalidReconnectDelay = errors.New("reconnect delay cannot be negative")
InvalidMaxQueueSize = errors.New("maximum queue size cannot be negative")
InvalidBufferSize = errors.New("buffer size must be greater than zero")
// Pool errors
ErrInvalidPoolID = errors.New("pool id cannot be empty")
ErrPoolClosed = errors.New("pool is closed")
ErrPeerNotFound = errors.New("peer not found")
ErrPeerExists = errors.New("peer already exists")
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")
+2 -1
View File
@@ -1,6 +1,6 @@
module git.wisehodl.dev/jay/go-honeybee
go 1.23.5
go 1.25.0
require (
github.com/gorilla/websocket v1.5.3
@@ -8,6 +8,7 @@ require (
)
require (
git.wisehodl.dev/jay/go-mana-component v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+2
View File
@@ -1,3 +1,5 @@
git.wisehodl.dev/jay/go-mana-component v0.1.0 h1:wWYN5MzC9Hq3tEt4z7FjrwNuQz3rZY3RWAmgmNE8EZE=
git.wisehodl.dev/jay/go-mana-component v0.1.0/go.mod h1:r2ZaTjKzwV5JJfC5boikxtjAKusPrzlJU/7qul0EUqA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+3 -2
View File
@@ -1,6 +1,7 @@
package outbound
package honeybee
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/transport"
"github.com/stretchr/testify/assert"
@@ -18,7 +19,7 @@ func setupTestConnection(t *testing.T) (
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
var err error
conn, err = transport.NewConnectionFromSocket(socket, nil, nil)
conn, err = transport.NewConnectionFromSocket(context.Background(), socket, nil, nil)
assert.NoError(t, err)
return
}
+10
View File
@@ -10,7 +10,9 @@ import (
"time"
)
// ----------------------------------------------------------------------------
// Constants
// ----------------------------------------------------------------------------
const (
TestTimeout = 2 * time.Second
@@ -18,7 +20,9 @@ const (
NegativeTestTimeout = 100 * time.Millisecond
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
type MockIncomingData struct {
MsgType int
@@ -37,7 +41,9 @@ type ExpectedLog struct {
Attrs map[string]any
}
// ----------------------------------------------------------------------------
// Setup
// ----------------------------------------------------------------------------
func SetupTestSocket(t *testing.T) (
socket *MockSocket,
@@ -81,7 +87,9 @@ func SetupTestSocket(t *testing.T) (
return
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
func ExpectIncoming(t *testing.T, incoming <-chan []byte, expected []byte) {
t.Helper()
@@ -126,7 +134,9 @@ func Never(t *testing.T, condition func() bool, msg string) {
assert.Never(t, condition, NegativeTestTimeout, TestTick, msg)
}
// ----------------------------------------------------------------------------
// Logging Helpers
// ----------------------------------------------------------------------------
func AssertLogSequence(t *testing.T, records []slog.Record, expected []ExpectedLog) {
t.Helper()
+12 -2
View File
@@ -9,12 +9,16 @@ import (
"time"
)
// Re-exported types for consumer convenience
// ----------------------------------------------------------------------------
// Re-exports
// ----------------------------------------------------------------------------
type Socket = types.Socket
type Dialer = types.Dialer
// ----------------------------------------------------------------------------
// Dialer Mocks
// ----------------------------------------------------------------------------
type MockDialer struct {
DialContextFunc func(
@@ -28,7 +32,9 @@ func (m *MockDialer) DialContext(
return m.DialContextFunc(ctx, url, h)
}
// ----------------------------------------------------------------------------
// Socket Mocks
// ----------------------------------------------------------------------------
type MockSocket struct {
WriteMessageFunc func(int, []byte) error
@@ -93,12 +99,14 @@ func (m *MockSocket) SetPongHandler(h func(s string) error) {
m.SetPongHandlerFunc(h)
}
// ----------------------------------------------------------------------------
// Logging mocks
// ----------------------------------------------------------------------------
type MockSlogHandler struct {
records *[]slog.Record
attrs []slog.Attr
mu sync.RWMutex
mu *sync.RWMutex
}
func NewMockSlogHandler() *MockSlogHandler {
@@ -106,6 +114,7 @@ func NewMockSlogHandler() *MockSlogHandler {
return &MockSlogHandler{
records: &records,
attrs: make([]slog.Attr, 0),
mu: &sync.RWMutex{},
}
}
@@ -126,6 +135,7 @@ func (m *MockSlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
defer m.mu.RUnlock()
return &MockSlogHandler{
records: m.records, // shared records slice
mu: m.mu, // shared mutex
attrs: append(m.attrs, attrs...),
}
}
-246
View File
@@ -1,246 +0,0 @@
package inbound
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/transport"
"log/slog"
"time"
)
// Pool Config
type WorkerFactory func(
ctx context.Context,
id string,
conn *transport.Connection,
config *WorkerConfig,
logger *slog.Logger,
) (Worker, error)
type PoolConfig struct {
InboxBufferSize int
EventsBufferSize int
LoggingEnabled bool
LogLevel *slog.Level
ConnectionConfig *transport.ConnectionConfig
WorkerConfig *WorkerConfig
WorkerFactory WorkerFactory
}
type PoolOption func(*PoolConfig) error
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,
LoggingEnabled: true,
LogLevel: nil,
ConnectionConfig: nil,
WorkerConfig: nil,
WorkerFactory: nil,
}
}
func applyPoolOptions(config *PoolConfig, options ...PoolOption) error {
for _, option := range options {
if err := option(config); err != nil {
return err
}
}
return nil
}
func ValidatePoolConfig(config *PoolConfig) error {
if config.ConnectionConfig != nil {
if err := transport.ValidateConnectionConfig(config.ConnectionConfig); err != nil {
return err
}
}
if config.WorkerConfig != nil {
if err := ValidateWorkerConfig(config.WorkerConfig); err != nil {
return err
}
}
return nil
}
func validateBufferSize(value int) error {
if value < 1 {
return InvalidBufferSize
}
return nil
}
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 WithPoolLoggingEnabled(value bool) PoolOption {
return func(c *PoolConfig) error {
c.LoggingEnabled = value
return nil
}
}
func WithPoolLogLevel(level slog.Level) PoolOption {
return func(c *PoolConfig) error {
l := level
c.LogLevel = &l
return nil
}
}
func WithConnectionConfig(cc *transport.ConnectionConfig) PoolOption {
return func(c *PoolConfig) error {
if err := transport.ValidateConnectionConfig(cc); err != nil {
return err
}
c.ConnectionConfig = cc
return nil
}
}
func WithWorkerConfig(wc *WorkerConfig) PoolOption {
return func(c *PoolConfig) error {
if err := ValidateWorkerConfig(wc); 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
type WorkerConfig struct {
MaxQueueSize int
InactivityTimeout time.Duration
LoggingEnabled bool
LogLevel *slog.Level
}
type WorkerOption func(*WorkerConfig) error
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{
MaxQueueSize: 0, // queue can grow indefinitely by default
InactivityTimeout: 0, // eviction disabled by default
LoggingEnabled: true,
LogLevel: nil,
}
}
func applyWorkerOptions(config *WorkerConfig, options ...WorkerOption) error {
for _, option := range options {
if err := option(config); err != nil {
return err
}
}
return nil
}
func ValidateWorkerConfig(config *WorkerConfig) error {
if err := validateMaxQueueSize(config.MaxQueueSize); err != nil {
return err
}
if err := validateInactivityTimeout(config.InactivityTimeout); err != nil {
return err
}
return nil
}
func validateMaxQueueSize(value int) error {
if value < 0 {
return InvalidMaxQueueSize
}
return nil
}
func validateInactivityTimeout(value time.Duration) error {
if value < 0 {
return InvalidInactivityTimeout
}
return nil
}
// When MaxQueueSize is zero, queue limits are disabled.
func WithMaxQueueSize(value int) WorkerOption {
return func(c *WorkerConfig) error {
if err := validateMaxQueueSize(value); err != nil {
return err
}
c.MaxQueueSize = value
return nil
}
}
// When InactivityTimeout is zero, the watchdog is disabled.
func WithInactivityTimeout(value time.Duration) WorkerOption {
return func(c *WorkerConfig) error {
if err := validateInactivityTimeout(value); err != nil {
return err
}
c.InactivityTimeout = value
return nil
}
}
func WithWorkerLoggingEnabled(value bool) WorkerOption {
return func(c *WorkerConfig) error {
c.LoggingEnabled = value
return nil
}
}
func WithWorkerLogLevel(level slog.Level) WorkerOption {
return func(c *WorkerConfig) error {
l := level
c.LogLevel = &l
return nil
}
}
-207
View File
@@ -1,207 +0,0 @@
// responderpool/config_test.go
package inbound
import (
"git.wisehodl.dev/jay/go-honeybee/transport"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestNewWorkerConfig(t *testing.T) {
conf, err := NewWorkerConfig()
assert.NoError(t, err)
assert.Equal(t, GetDefaultWorkerConfig(), conf)
}
func TestDefaultWorkerConfig(t *testing.T) {
conf := GetDefaultWorkerConfig()
assert.Equal(t, &WorkerConfig{
MaxQueueSize: 0,
InactivityTimeout: 0,
LoggingEnabled: true,
LogLevel: nil,
}, conf)
}
func TestValidateWorkerConfig(t *testing.T) {
cases := []struct {
name string
conf WorkerConfig
wantErr error
}{
{
name: "valid defaults",
conf: *GetDefaultWorkerConfig(),
},
{
name: "zero inactivity timeout disabled",
conf: WorkerConfig{InactivityTimeout: 0},
},
{
name: "positive inactivity timeout",
conf: WorkerConfig{InactivityTimeout: 30 * time.Second},
},
{
name: "negative max queue size",
conf: WorkerConfig{MaxQueueSize: -1},
wantErr: InvalidMaxQueueSize,
},
{
name: "negative inactivity timeout",
conf: WorkerConfig{InactivityTimeout: -1 * time.Second},
wantErr: InvalidInactivityTimeout,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateWorkerConfig(&tc.conf)
if tc.wantErr != nil {
assert.ErrorIs(t, err, tc.wantErr)
return
}
assert.NoError(t, err)
})
}
}
func TestWithMaxQueueSize(t *testing.T) {
conf := &WorkerConfig{}
err := applyWorkerOptions(conf, WithMaxQueueSize(10))
assert.NoError(t, err)
assert.Equal(t, 10, conf.MaxQueueSize)
err = applyWorkerOptions(conf, WithMaxQueueSize(0))
assert.NoError(t, err)
err = applyWorkerOptions(conf, WithMaxQueueSize(-1))
assert.ErrorIs(t, err, InvalidMaxQueueSize)
}
func TestWithInactivityTimeout(t *testing.T) {
conf := &WorkerConfig{}
err := applyWorkerOptions(conf, WithInactivityTimeout(30*time.Second))
assert.NoError(t, err)
assert.Equal(t, 30*time.Second, conf.InactivityTimeout)
err = applyWorkerOptions(conf, WithInactivityTimeout(0))
assert.NoError(t, err)
err = applyWorkerOptions(conf, WithInactivityTimeout(-1*time.Second))
assert.ErrorIs(t, err, InvalidInactivityTimeout)
}
func TestNewPoolConfig(t *testing.T) {
conf, err := NewPoolConfig()
assert.NoError(t, err)
assert.Equal(t, GetDefaultPoolConfig(), conf)
}
func TestDefaultPoolConfig(t *testing.T) {
conf := GetDefaultPoolConfig()
assert.Equal(t, &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
LoggingEnabled: true,
LogLevel: nil,
ConnectionConfig: nil,
WorkerConfig: nil,
WorkerFactory: nil,
}, conf)
}
func TestValidatePoolConfig(t *testing.T) {
cases := []struct {
name string
conf PoolConfig
wantErrText string
}{
{
name: "valid empty",
conf: PoolConfig{},
},
{
name: "valid defaults",
conf: *GetDefaultPoolConfig(),
},
{
name: "valid with configs",
conf: PoolConfig{
ConnectionConfig: &transport.ConnectionConfig{},
WorkerConfig: &WorkerConfig{},
},
},
{
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",
},
{
name: "invalid worker config",
conf: PoolConfig{
WorkerConfig: &WorkerConfig{MaxQueueSize: -1},
},
wantErrText: "maximum queue size cannot be negative",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePoolConfig(&tc.conf)
if tc.wantErrText != "" {
assert.ErrorContains(t, err, tc.wantErrText)
return
}
assert.NoError(t, err)
})
}
}
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{}
err := applyPoolOptions(conf, WithConnectionConfig(&transport.ConnectionConfig{}))
assert.NoError(t, err)
assert.NotNil(t, conf.ConnectionConfig)
err = applyPoolOptions(conf, WithConnectionConfig(&transport.ConnectionConfig{
Retry: &transport.RetryConfig{
InitialDelay: 10 * time.Second,
MaxDelay: 1 * time.Second,
},
}))
assert.Error(t, err)
}
func TestWithWorkerConfig(t *testing.T) {
conf := &PoolConfig{}
err := applyPoolOptions(conf, WithWorkerConfig(&WorkerConfig{InactivityTimeout: 30 * time.Second}))
assert.NoError(t, err)
assert.Equal(t, 30*time.Second, conf.WorkerConfig.InactivityTimeout)
err = applyPoolOptions(conf, WithWorkerConfig(&WorkerConfig{MaxQueueSize: -1}))
assert.Error(t, err)
}
-17
View File
@@ -1,17 +0,0 @@
package inbound
import "errors"
var (
// Pool errors
PoolError = errors.New("pool error")
ErrInvalidPoolID = errors.New("pool id cannot be empty")
ErrPoolClosed = errors.New("pool is closed")
ErrPeerNotFound = errors.New("peer not found")
ErrPeerExists = errors.New("peer already exists")
// Config errors
InvalidMaxQueueSize = errors.New("maximum queue size cannot be negative")
InvalidInactivityTimeout = errors.New("inactivity timeout cannot be negative")
InvalidBufferSize = errors.New("buffer size must be greater than zero")
)
-24
View File
@@ -1,24 +0,0 @@
package inbound
import (
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/transport"
"github.com/stretchr/testify/assert"
"testing"
)
func setupTestConnection(t *testing.T) (
conn *transport.Connection,
socket *honeybeetest.MockSocket,
incoming chan honeybeetest.MockIncomingData,
outgoing chan honeybeetest.MockOutgoingData,
) {
t.Helper()
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
var err error
conn, err = transport.NewConnectionFromSocket(socket, nil, nil)
assert.NoError(t, err)
return
}
-423
View File
@@ -1,423 +0,0 @@
package inbound
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/logging"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"log/slog"
"sync"
"sync/atomic"
"time"
)
// Re-exported types for consumer convenience
type Socket = types.Socket
type InboxMessage = types.InboxMessage
var NormalizeURL = transport.NormalizeURL
// Types
type PoolEventKind string
const (
EventDisconnected PoolEventKind = "disconnected"
EventDroppedClose PoolEventKind = "dropped_close"
EventDroppedError PoolEventKind = "dropped_error"
EventEvictedPolicy PoolEventKind = "evicted_policy"
)
var workerToPoolEvent = map[WorkerExitKind]PoolEventKind{
ExitDisconnected: EventDisconnected,
ExitUnexpectedClose: EventDroppedClose,
ExitReadError: EventDroppedError,
ExitPolicy: EventEvictedPolicy,
}
type OnExitFunction func(kind WorkerExitKind)
type PoolEvent struct {
ID string
Kind PoolEventKind
At time.Time
}
type PoolStats struct {
ChanInbox int
ChanEvents int
TotalReceived uint64
TotalSent uint64
PeerCount int
PeerStats []PeerStats
}
type PeerStats struct {
ID string
Worker WorkerStats
Connection transport.ConnectionStats
}
type PoolPlugin struct {
Inbox chan<- types.InboxMessage
Events chan<- PoolEvent
InboxCounter *atomic.Uint64
OnExit OnExitFunction
Handler slog.Handler
}
// Pool
type Peer struct {
id string
conn *transport.Connection
worker Worker
done chan struct{}
}
type Pool struct {
ctx context.Context
cancel context.CancelFunc
id string
peers map[string]*Peer
inbox chan types.InboxMessage
events chan PoolEvent
inboxCounter *atomic.Uint64
outgoingCount *atomic.Uint64
config *PoolConfig
handler slog.Handler
logger *slog.Logger
mu sync.RWMutex
wg sync.WaitGroup
closed bool
}
func NewPool(ctx context.Context, id string, config *PoolConfig, handler slog.Handler) (*Pool, error) {
if id == "" {
return nil, ErrInvalidPoolID
}
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,
conn *transport.Connection,
config *WorkerConfig,
logger *slog.Logger,
) (Worker, error) {
return NewWorker(ctx, id, conn, config, logger)
}
}
if err := ValidatePoolConfig(config); err != nil {
return nil, err
}
pctx, cancel := context.WithCancel(ctx)
var logger *slog.Logger
if handler != nil && config.LoggingEnabled {
logger = logging.NewInboundPoolLogger(
logging.WrapOrDefault(config.LogLevel, handler), id)
}
return &Pool{
ctx: pctx,
cancel: cancel,
id: id,
peers: make(map[string]*Peer),
inbox: make(chan types.InboxMessage, config.InboxBufferSize),
events: make(chan PoolEvent, config.EventsBufferSize),
inboxCounter: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
config: config,
handler: handler,
logger: logger,
}, nil
}
func (p *Pool) Peers() []string {
p.mu.RLock()
defer p.mu.RUnlock()
ids := make([]string, 0, len(p.peers))
for id := range p.peers {
ids = append(ids, id)
}
return ids
}
func (p *Pool) Inbox() <-chan types.InboxMessage {
return p.inbox
}
func (p *Pool) Events() <-chan PoolEvent {
return p.events
}
func (p *Pool) Stats() PoolStats {
p.mu.RLock()
defer p.mu.RUnlock()
count := len(p.peers)
peerStats := make([]PeerStats, 0, count)
for id, peer := range p.peers {
peerStats = append(peerStats, PeerStats{
ID: id,
Worker: peer.worker.Stats(),
Connection: peer.conn.Stats(),
})
}
return PoolStats{
ChanInbox: len(p.inbox),
ChanEvents: len(p.events),
TotalReceived: p.inboxCounter.Load(),
TotalSent: p.outgoingCount.Load(),
PeerCount: len(p.peers),
PeerStats: peerStats,
}
}
func (p *Pool) PeerStats(id string) (PeerStats, error) {
p.mu.RLock()
defer p.mu.RUnlock()
peer, exists := p.peers[id]
if !exists {
return PeerStats{}, ErrPeerNotFound
}
return PeerStats{
ID: id,
Worker: peer.worker.Stats(),
Connection: peer.conn.Stats(),
}, nil
}
func (p *Pool) Close() {
if p.logger != nil {
p.logger.Debug("closing")
}
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return
}
p.closed = true
p.cancel()
// close all connections
for _, peer := range p.peers {
peer.worker.Stop()
peer.conn.Close()
}
// remove all peers
p.peers = make(map[string]*Peer)
p.mu.Unlock()
go func() {
p.wg.Wait()
close(p.inbox)
close(p.events)
if p.logger != nil {
p.logger.Info("closed")
}
}()
}
func (p *Pool) Add(id string, socket types.Socket) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return ErrPoolClosed
}
if _, exists := p.peers[id]; exists {
return ErrPeerExists
}
return p.addLocked(id, socket)
}
func (p *Pool) Replace(id string, socket types.Socket) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return ErrPoolClosed
}
if peer, exists := p.peers[id]; exists {
p.removeLocked(peer)
if p.logger != nil {
p.logger.Info("removed peer", "peer", id)
}
} else {
return ErrPeerNotFound
}
return p.addLocked(id, socket)
}
func (p *Pool) Remove(id string) error {
if p.logger != nil {
p.logger.Debug("removing peer", "peer", id)
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return ErrPoolClosed
}
peer, exists := p.peers[id]
if !exists {
return ErrPeerNotFound
}
p.removeLocked(peer)
if p.logger != nil {
p.logger.Info("removed peer", "peer", id)
}
return nil
}
func (p *Pool) Send(id string, data []byte) error {
p.mu.RLock()
defer p.mu.RUnlock()
if p.closed {
return ErrPoolClosed
}
peer, exists := p.peers[id]
if !exists {
return ErrPeerNotFound
}
err := peer.worker.Send(data)
if err != nil {
return err
}
p.outgoingCount.Add(1)
return nil
}
// addLocked constructs and registers a peer. Caller must hold p.mu write lock.
func (p *Pool) addLocked(id string, socket types.Socket) error {
var logger *slog.Logger
if p.handler != nil && p.config.ConnectionConfig.LoggingEnabled {
logger = logging.NewConnectionLogger(
logging.WrapOrDefault(p.config.ConnectionConfig.LogLevel, p.handler), p.id, id)
}
conn, err := transport.NewConnectionFromSocket(
socket, p.config.ConnectionConfig, logger)
if err != nil {
return err
}
wctx, cancel := context.WithCancel(p.ctx)
if p.handler != nil && p.config.WorkerConfig.LoggingEnabled {
logger = logging.NewInboundWorkerLogger(
logging.WrapOrDefault(p.config.WorkerConfig.LogLevel, p.handler), p.id, id)
}
// The worker factory must be non-blocking to avoid deadlocks
worker, err := p.config.WorkerFactory(wctx, id, conn, p.config.WorkerConfig, logger)
if err != nil {
cancel()
conn.Close()
return fmt.Errorf("%w: %w", PoolError, err)
}
var once sync.Once
onExit := func(kind WorkerExitKind) {
once.Do(func() {
p.mu.Lock()
delete(p.peers, id)
p.mu.Unlock()
conn.Close()
select {
case p.events <- PoolEvent{ID: id, Kind: workerToPoolEvent[kind], At: time.Now()}:
case <-p.ctx.Done():
return
}
})
}
pool := PoolPlugin{
Inbox: p.inbox,
Events: p.events,
InboxCounter: p.inboxCounter,
OnExit: onExit,
Handler: p.handler,
}
peer := &Peer{
id: id,
conn: conn,
worker: worker,
done: make(chan struct{}),
}
p.wg.Add(1)
go func() {
defer cancel()
defer close(peer.done)
worker.Start(pool)
p.wg.Done()
}()
p.peers[id] = peer
if p.logger != nil {
p.logger.Info("added peer", "peer", id)
}
return nil
}
// removeLocked closes and unregisters a peer. Caller must hold p.mu write lock.
func (p *Pool) removeLocked(peer *Peer) {
delete(p.peers, peer.id)
peer.worker.Stop()
go func() {
<-peer.done
peer.conn.Close()
}()
}
-400
View File
@@ -1,400 +0,0 @@
package inbound
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"slices"
"testing"
"time"
)
// Helpers
func setupPool(t *testing.T) *Pool {
t.Helper()
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
assert.NoError(t, err)
return pool
}
func expectEvent(
t *testing.T,
events <-chan PoolEvent,
expectedURL string,
expectedKind PoolEventKind,
) {
t.Helper()
honeybeetest.Eventually(t, func() bool {
select {
case e := <-events:
return e.ID == expectedURL && e.Kind == expectedKind && !e.At.IsZero()
default:
return false
}
}, fmt.Sprintf("expected event: URL=%q, Kind=%q", expectedURL, expectedKind))
}
// Tests
func TestPoolID(t *testing.T) {
_, err := NewPool(context.Background(), "", nil, nil)
assert.ErrorIs(t, err, ErrInvalidPoolID)
}
func TestPoolAdd(t *testing.T) {
t.Run("successfully adds peer", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.NoError(t, err)
})
t.Run("peer appears in Peers after add", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.NoError(t, err)
assert.Contains(t, pool.Peers(), "peer-1")
})
t.Run("duplicate id returns ErrPeerExists", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket1, _, _ := honeybeetest.SetupTestSocket(t)
socket2, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket1)
assert.NoError(t, err)
err = pool.Add("peer-1", socket2)
assert.ErrorIs(t, err, ErrPeerExists)
})
t.Run("closed pool returns ErrPoolClosed", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.ErrorIs(t, err, ErrPoolClosed)
})
}
func TestPoolReplace(t *testing.T) {
t.Run("replaces existing peer", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket1, _, _ := honeybeetest.SetupTestSocket(t)
socket2, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket1)
assert.NoError(t, err)
err = pool.Replace("peer-1", socket2)
assert.NoError(t, err)
assert.Contains(t, pool.Peers(), "peer-1")
})
t.Run("unknown id returns ErrPeerNotFound", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Replace("unknown", socket)
assert.ErrorIs(t, err, ErrPeerNotFound)
})
t.Run("closed pool returns ErrPoolClosed", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Replace("peer-1", socket)
assert.ErrorIs(t, err, ErrPoolClosed)
})
t.Run("no event emitted for replaced peer", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket1, _, _ := honeybeetest.SetupTestSocket(t)
socket2, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket1)
assert.NoError(t, err)
err = pool.Replace("peer-1", socket2)
assert.NoError(t, err)
honeybeetest.Never(t, func() bool {
select {
case <-pool.Events():
return true
default:
return false
}
}, "no event expected on replace")
})
}
func TestPoolRemove(t *testing.T) {
t.Run("removes known peer", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.NoError(t, err)
err = pool.Remove("peer-1")
assert.NoError(t, err)
assert.NotContains(t, pool.Peers(), "peer-1")
})
t.Run("unknown id returns ErrPeerNotFound", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
err := pool.Remove("unknown")
assert.ErrorIs(t, err, ErrPeerNotFound)
})
t.Run("closed pool returns ErrPoolClosed", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
err := pool.Remove("peer-1")
assert.ErrorIs(t, err, ErrPoolClosed)
})
t.Run("no event emitted on remove", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.NoError(t, err)
err = pool.Remove("peer-1")
assert.NoError(t, err)
honeybeetest.Never(t, func() bool {
select {
case e := <-pool.Events():
fmt.Printf("got event: %v", e)
return true
default:
return false
}
}, "no event expected on remove")
})
}
func TestPoolSend(t *testing.T) {
t.Run("data reaches socket", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, outgoing := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.NoError(t, err)
err = pool.Send("peer-1", []byte("hello"))
assert.NoError(t, err)
honeybeetest.ExpectWrite(t, outgoing, websocket.TextMessage, []byte("hello"))
})
t.Run("unknown id returns ErrPeerNotFound", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
err := pool.Send("unknown", []byte("hello"))
assert.ErrorIs(t, err, ErrPeerNotFound)
})
t.Run("closed pool returns ErrPoolClosed", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
err := pool.Send("peer-1", []byte("hello"))
assert.ErrorIs(t, err, ErrPoolClosed)
})
}
func TestPoolClose(t *testing.T) {
t.Run("inbox and events channels close after pool close", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
_, ok := <-pool.Inbox()
assert.False(t, ok)
_, ok = <-pool.Events()
assert.False(t, ok)
})
t.Run("add after close returns ErrPoolClosed", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
err := pool.Add("peer-1", socket)
assert.ErrorIs(t, err, ErrPoolClosed)
})
t.Run("close is idempotent", func(t *testing.T) {
pool := setupPool(t)
pool.Close()
pool.Close()
})
}
func TestPoolPeers(t *testing.T) {
t.Run("reflects active peers after add", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket1, _, _ := honeybeetest.SetupTestSocket(t)
socket2, _, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket1)
pool.Add("peer-2", socket2)
peers := pool.Peers()
assert.Contains(t, peers, "peer-1")
assert.Contains(t, peers, "peer-2")
})
t.Run("loses entry after remove", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket)
pool.Remove("peer-1")
assert.NotContains(t, pool.Peers(), "peer-1")
})
t.Run("loses entry after peer self-disconnects", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, incoming, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket)
close(incoming)
honeybeetest.Eventually(t, func() bool {
return !slices.Contains(pool.Peers(), "peer-1")
}, "expected peer to be removed after self-disconnect")
})
}
func TestPoolEvents(t *testing.T) {
t.Run("EventPeerDisconnected emitted on clean close", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, incoming, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket)
incoming <- honeybeetest.MockIncomingData{
Err: &websocket.CloseError{Code: websocket.CloseNormalClosure},
}
expectEvent(t, pool.Events(), "peer-1", EventDisconnected)
honeybeetest.Eventually(t, func() bool {
return !slices.Contains(pool.Peers(), "peer-1")
}, "expected peer auto-removed")
})
t.Run("EventPeerDropped emitted on unexpected close", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, incoming, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket)
incoming <- honeybeetest.MockIncomingData{
Err: &websocket.CloseError{Code: websocket.CloseProtocolError},
}
expectEvent(t, pool.Events(), "peer-1", EventDroppedClose)
honeybeetest.Eventually(t, func() bool {
return !slices.Contains(pool.Peers(), "peer-1")
}, "expected peer auto-removed")
})
t.Run("EventPeerEvicted emitted on watchdog timeout", func(t *testing.T) {
config, err := NewPoolConfig(
WithWorkerConfig(&WorkerConfig{InactivityTimeout: 20 * time.Millisecond}),
)
assert.NoError(t, err)
pool, err := NewPool(context.Background(), "pool-1", config, nil)
assert.NoError(t, err)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket)
expectEvent(t, pool.Events(), "peer-1", EventEvictedPolicy)
honeybeetest.Eventually(t, func() bool {
return !slices.Contains(pool.Peers(), "peer-1")
}, "expected peer auto-removed")
})
t.Run("no event emitted on Remove", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket, _, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket)
pool.Remove("peer-1")
honeybeetest.Never(t, func() bool {
select {
case <-pool.Events():
return true
default:
return false
}
}, "no event expected on Remove")
})
t.Run("no event emitted on Replace of old peer", func(t *testing.T) {
pool := setupPool(t)
defer pool.Close()
socket1, _, _ := honeybeetest.SetupTestSocket(t)
socket2, _, _ := honeybeetest.SetupTestSocket(t)
pool.Add("peer-1", socket1)
pool.Replace("peer-1", socket2)
honeybeetest.Never(t, func() bool {
select {
case <-pool.Events():
return true
default:
return false
}
}, "no event expected on Replace")
})
}
-340
View File
@@ -1,340 +0,0 @@
package inbound
import (
"context"
"errors"
"git.wisehodl.dev/jay/go-honeybee/queue"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"log/slog"
"sync"
"sync/atomic"
"time"
)
type Worker interface {
Start(pool PoolPlugin)
Stop()
Send(data []byte) error
Stats() WorkerStats
}
type WorkerExitKind string
const (
ExitDisconnected WorkerExitKind = "disconnected"
ExitUnexpectedClose WorkerExitKind = "unexpected_close"
ExitReadError WorkerExitKind = "read_error"
ExitPolicy WorkerExitKind = "policy"
)
type WorkerStats struct {
ChanIncoming int
ChanQueue int
ChanForwarder int
BufferDepth int64
TotalProcessed uint64
TotalDropped uint64
TotalSent uint64
}
type DefaultWorker struct {
id string
conn *transport.Connection
heartbeat chan struct{}
toQueue chan types.ReceivedMessage
toForwarder chan types.ReceivedMessage
processedCount *atomic.Uint64
droppedCount *atomic.Uint64
outgoingCount *atomic.Uint64
bufferDepth *atomic.Int64
ctx context.Context
cancel context.CancelFunc
config *WorkerConfig
logger *slog.Logger
}
func NewWorker(
ctx context.Context,
id string,
conn *transport.Connection,
config *WorkerConfig,
logger *slog.Logger,
) (*DefaultWorker, error) {
if config == nil {
config = GetDefaultWorkerConfig()
}
if err := ValidateWorkerConfig(config); err != nil {
return nil, err
}
wctx, cancel := context.WithCancel(ctx)
return &DefaultWorker{
id: id,
conn: conn,
heartbeat: make(chan struct{}),
toQueue: make(chan types.ReceivedMessage, 256),
toForwarder: make(chan types.ReceivedMessage, 256),
processedCount: &atomic.Uint64{},
droppedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
bufferDepth: &atomic.Int64{},
config: config,
ctx: wctx,
cancel: cancel,
logger: logger,
}, nil
}
func (w *DefaultWorker) Start(pool PoolPlugin) {
if w.logger != nil {
w.logger.Debug("starting")
}
var wg sync.WaitGroup
wg.Add(5)
go func() {
defer wg.Done()
RunReader(w.ctx, pool.OnExit, w.conn, w.toQueue, w.heartbeat, w.logger)
}()
go func() {
defer wg.Done()
RunHeartbeatForwarder(w.ctx, w.conn, w.heartbeat, w.logger)
}()
go func() {
defer wg.Done()
queue.RunQueue(w.id, w.ctx, w.toQueue, w.toForwarder, w.config.MaxQueueSize, w.droppedCount, w.bufferDepth)
}()
go func() {
defer wg.Done()
RunForwarder(w.id, w.ctx, w.toForwarder, pool.Inbox, w.processedCount, pool.InboxCounter)
}()
go func() {
defer wg.Done()
RunWatchdog(w.ctx, pool.OnExit, w.heartbeat, w.config.InactivityTimeout, w.logger)
}()
if w.logger != nil {
w.logger.Info("started")
}
wg.Wait()
if w.logger != nil {
w.logger.Info("stopped")
}
}
func (w *DefaultWorker) Stop() {
if w.logger != nil {
w.logger.Debug("shutting down")
}
w.cancel()
}
func (w *DefaultWorker) Send(data []byte) error {
if err := w.conn.Send(data); err != nil {
return err
}
select {
case w.heartbeat <- struct{}{}:
case <-w.ctx.Done():
}
w.outgoingCount.Add(1)
return nil
}
func (w *DefaultWorker) Stats() WorkerStats {
return WorkerStats{
ChanIncoming: len(w.conn.Incoming()),
ChanQueue: len(w.toQueue),
ChanForwarder: len(w.toForwarder),
BufferDepth: w.bufferDepth.Load(),
TotalProcessed: w.processedCount.Load(),
TotalDropped: w.droppedCount.Load(),
TotalSent: w.outgoingCount.Load(),
}
}
func RunReader(
ctx context.Context,
onPeerClose OnExitFunction,
conn *transport.Connection,
messages chan<- types.ReceivedMessage,
heartbeat chan<- struct{},
logger *slog.Logger,
) {
for {
select {
case <-ctx.Done():
return
case data, ok := <-conn.Incoming():
if !ok {
var err error
// determine exit kind
// by default, the peer dropped unexpectedly
kind := ExitUnexpectedClose
select {
// the peer-side error is sent before the connection is closed,
// so a non-blocking call here is correct
// if an error is not sent, then assume the default event kind
case err = <-conn.Errors():
if errors.Is(err, transport.ErrPeerClosedClean) {
kind = ExitDisconnected
}
if errors.Is(err, transport.ErrPeerClosedUnexpected) {
kind = ExitUnexpectedClose
}
if errors.Is(err, transport.ErrReadError) {
kind = ExitReadError
}
default:
}
if logger != nil {
if kind == ExitUnexpectedClose || kind == ExitReadError {
logger.Error("reader: peer dropped", "event", kind, "error", err)
} else {
logger.Info("reader: peer disconnected", "event", kind)
}
}
onPeerClose(kind)
return
}
messages <- types.ReceivedMessage{Data: data, ReceivedAt: time.Now()}
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 RunForwarder(
id string,
ctx context.Context,
messages <-chan types.ReceivedMessage,
inbox chan<- types.InboxMessage,
workerProcessedCount *atomic.Uint64,
poolInboxCount *atomic.Uint64,
) {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-messages:
if !ok {
return
}
select {
case <-ctx.Done():
return
case inbox <- types.InboxMessage{
ID: id,
Data: msg.Data,
ReceivedAt: msg.ReceivedAt,
}:
workerProcessedCount.Add(1)
poolInboxCount.Add(1)
}
}
}
}
func RunWatchdog(
ctx context.Context,
onInactive OnExitFunction,
heartbeat <-chan struct{},
timeout time.Duration,
logger *slog.Logger,
) {
// disable watchdog timeout if not configured
if timeout <= 0 {
if logger != nil {
logger.Debug("watchdog: disabled")
}
// drain heartbeats
// wait for cancel and exit
for {
select {
case <-heartbeat:
case <-ctx.Done():
return
}
}
}
if logger != nil {
logger.Debug("watchdog: 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:
// signal peer is inactive
if logger != nil {
logger.Info("watchdog: no activity observed")
}
onInactive(ExitPolicy)
return
}
}
}
-33
View File
@@ -1,33 +0,0 @@
package inbound
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/types"
"sync/atomic"
"testing"
"time"
)
func TestRunForwarder(t *testing.T) {
t.Run("message passes through to inbox", func(t *testing.T) {
id := "wss://test"
messages := make(chan types.ReceivedMessage, 1)
inbox := make(chan types.InboxMessage, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go RunForwarder(id, ctx, messages, inbox, &atomic.Uint64{}, &atomic.Uint64{})
messages <- types.ReceivedMessage{Data: []byte("hello"), ReceivedAt: time.Now()}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-inbox:
return string(msg.Data) == "hello" && msg.ID == "wss://test"
default:
return false
}
}, "expected message")
})
}
-213
View File
@@ -1,213 +0,0 @@
package inbound
import (
"context"
"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 forwarded with correct data and non-zero receivedAt", func(t *testing.T) {
conn, _, incoming, _ := setupTestConnection(t)
defer conn.Close()
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go RunReader(ctx, func(WorkerExitKind) {}, conn, messages, heartbeat, nil)
before := time.Now()
incoming <- honeybeetest.MockIncomingData{MsgType: websocket.TextMessage, Data: []byte("hello")}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-messages:
return string(msg.Data) == "hello" && msg.ReceivedAt.After(before)
default:
return false
}
}, "expected message")
})
t.Run("heartbeat sent per forwarded message", func(t *testing.T) {
conn, _, incoming, _ := setupTestConnection(t)
defer conn.Close()
messages := make(chan types.ReceivedMessage, 10)
heartbeat := make(chan struct{}, 10)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
count := atomic.Int32{}
go func() {
for range heartbeat {
count.Add(1)
}
}()
go func() {
for range messages {
}
}()
go RunReader(ctx, func(WorkerExitKind) {}, conn, messages, heartbeat, nil)
const n = 3
for i := 0; i < n; i++ {
incoming <- honeybeetest.MockIncomingData{MsgType: websocket.TextMessage, Data: []byte("msg")}
}
honeybeetest.Eventually(t, func() bool {
return count.Load() == n
}, "expected heartbeats")
})
t.Run("clean close calls onPeerClose with ExitCleanDisconnect", func(t *testing.T) {
mock := honeybeetest.NewMockSocket()
mock.CloseFunc = func() error {
mock.Once.Do(func() { close(mock.Closed) })
return nil
}
mock.ReadMessageFunc = func() (int, []byte, error) {
return 0, nil, &websocket.CloseError{Code: websocket.CloseNormalClosure}
}
conn, err := transport.NewConnectionFromSocket(mock, nil, nil)
assert.NoError(t, err)
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var gotKind WorkerExitKind
done := make(chan struct{})
go RunReader(ctx, func(kind WorkerExitKind) {
gotKind = kind
close(done)
}, conn, messages, heartbeat, nil)
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected onPeerClose")
assert.Equal(t, ExitDisconnected, gotKind)
})
t.Run("unexpected close calls onPeerClose with ExitUnexpectedDrop", func(t *testing.T) {
mock := honeybeetest.NewMockSocket()
mock.CloseFunc = func() error {
mock.Once.Do(func() { close(mock.Closed) })
return nil
}
mock.ReadMessageFunc = func() (int, []byte, error) {
return 0, nil, &websocket.CloseError{Code: websocket.CloseProtocolError}
}
conn, err := transport.NewConnectionFromSocket(mock, nil, nil)
assert.NoError(t, err)
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var gotKind WorkerExitKind
done := make(chan struct{})
go RunReader(ctx, func(kind WorkerExitKind) {
gotKind = kind
close(done)
}, conn, messages, heartbeat, nil)
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected onPeerClose")
assert.Equal(t, ExitUnexpectedClose, gotKind)
})
t.Run("read error calls onPeerClose with ExitUnexpectedDrop", func(t *testing.T) {
mock := honeybeetest.NewMockSocket()
mock.CloseFunc = func() error {
mock.Once.Do(func() { close(mock.Closed) })
return nil
}
mock.ReadMessageFunc = func() (int, []byte, error) {
return 0, nil, io.EOF
}
conn, err := transport.NewConnectionFromSocket(mock, nil, nil)
assert.NoError(t, err)
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var gotKind WorkerExitKind
done := make(chan struct{})
go RunReader(ctx, func(kind WorkerExitKind) {
gotKind = kind
close(done)
}, conn, messages, heartbeat, nil)
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected onPeerClose")
assert.Equal(t, ExitReadError, gotKind)
})
t.Run("ctx.Done exits without calling onPeerClose", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
defer conn.Close()
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
called := atomic.Bool{}
done := make(chan struct{})
go func() {
RunReader(ctx, func(WorkerExitKind) {
called.Store(true)
}, conn, messages, heartbeat, nil)
close(done)
}()
cancel()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected RunReader to exit")
assert.False(t, called.Load())
})
}
-266
View File
@@ -1,266 +0,0 @@
package inbound
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"
"sync"
"sync/atomic"
"testing"
"time"
)
type workerTestVars struct {
worker *DefaultWorker
conn *transport.Connection
incoming chan honeybeetest.MockIncomingData
outgoing chan honeybeetest.MockOutgoingData
pool PoolPlugin
inbox chan types.InboxMessage
events chan PoolEvent
exitKind *atomic.Value
wg *sync.WaitGroup
}
func setupWorkerTest(t *testing.T) workerTestVars {
t.Helper()
conn, _, incoming, outgoing := setupTestConnection(t)
ctx, cancel := context.WithCancel(context.Background())
var err error
worker, err := NewWorker(ctx, "peer-1", conn, nil, nil)
assert.NoError(t, err)
worker.cancel = cancel
inbox := make(chan types.InboxMessage, 256)
events := make(chan PoolEvent, 10)
exitKind := &atomic.Value{}
var once sync.Once
pool := PoolPlugin{
Inbox: inbox,
Events: events,
OnExit: func(kind WorkerExitKind) {
once.Do(func() { exitKind.Store(kind) })
},
InboxCounter: &atomic.Uint64{},
}
wg := &sync.WaitGroup{}
wg.Add(1)
return workerTestVars{
worker: worker,
conn: conn,
incoming: incoming,
outgoing: outgoing,
pool: pool,
inbox: inbox,
events: events,
exitKind: exitKind,
wg: wg,
}
}
func TestWorkerStart(t *testing.T) {
t.Run("socket data arrives on inbox", func(t *testing.T) {
v := setupWorkerTest(t)
defer v.worker.Stop()
go v.worker.Start(v.pool)
v.incoming <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: []byte("hello"),
}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-v.inbox:
return msg.ID == "peer-1" && string(msg.Data) == "hello"
default:
return false
}
}, "expected message on inbox")
})
t.Run("clean peer close calls OnExit with ExitCleanDisconnect", func(t *testing.T) {
v := setupWorkerTest(t)
defer v.worker.Stop()
go v.worker.Start(v.pool)
v.incoming <- honeybeetest.MockIncomingData{
Err: &websocket.CloseError{Code: websocket.CloseNormalClosure},
}
honeybeetest.Eventually(t, func() bool {
val := v.exitKind.Load()
return val != nil && val.(WorkerExitKind) == ExitDisconnected
}, "expected ExitCleanDisconnect")
})
t.Run("unexpected peer close calls OnExit with ExitUnexpectedDrop", func(t *testing.T) {
v := setupWorkerTest(t)
defer v.worker.Stop()
go v.worker.Start(v.pool)
v.incoming <- honeybeetest.MockIncomingData{
Err: &websocket.CloseError{Code: websocket.CloseProtocolError},
}
honeybeetest.Eventually(t, func() bool {
val := v.exitKind.Load()
return val != nil && val.(WorkerExitKind) == ExitUnexpectedClose
}, "expected ExitUnexpectedDrop")
})
t.Run("watchdog timeout calls OnExit with ExitInactive", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
ctx, cancel := context.WithCancel(context.Background())
worker, err := NewWorker(ctx, "peer-1", conn, &WorkerConfig{
InactivityTimeout: 20 * time.Millisecond,
}, nil)
assert.NoError(t, err)
worker.cancel = cancel
defer worker.Stop()
exitKind := &atomic.Value{}
var once sync.Once
pool := PoolPlugin{
Inbox: make(chan types.InboxMessage, 256),
Events: make(chan PoolEvent, 10),
OnExit: func(kind WorkerExitKind) {
once.Do(func() { exitKind.Store(kind) })
},
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
worker.Start(pool)
wg.Done()
}()
honeybeetest.Eventually(t, func() bool {
val := exitKind.Load()
return val != nil && val.(WorkerExitKind) == ExitPolicy
}, "expected ExitInactive")
})
}
func TestWorkerStop(t *testing.T) {
v := setupWorkerTest(t)
go func() { v.worker.Start(v.pool); v.wg.Done() }()
v.worker.Stop()
done := make(chan struct{})
go func() { v.wg.Wait(); close(done) }()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected wg to drain")
// does not call onExit
assert.Nil(t, v.exitKind.Load())
}
func TestWorkerSend(t *testing.T) {
t.Run("Send delivers data to socket", func(t *testing.T) {
v := setupWorkerTest(t)
defer v.worker.Stop()
go v.worker.Start(v.pool)
err := v.worker.Send([]byte("hello"))
assert.NoError(t, err)
honeybeetest.ExpectWrite(t, v.outgoing, websocket.TextMessage, []byte("hello"))
})
t.Run("Send produces heartbeats", func(t *testing.T) {
v := setupWorkerTest(t)
defer v.worker.Stop()
count := atomic.Int32{}
go func() {
for range v.worker.heartbeat {
count.Add(1)
}
}()
// do not start the worker, allow heartbeats to be drained manually
for i := 0; i < 3; i++ {
err := v.worker.Send([]byte(fmt.Sprintf("msg-%d", i)))
assert.NoError(t, err)
}
honeybeetest.Eventually(t, func() bool {
return count.Load() == 3
}, "expected heartbeats")
})
t.Run("Send returns error after connection closed", func(t *testing.T) {
v := setupWorkerTest(t)
defer v.worker.Stop()
go v.worker.Start(v.pool)
v.conn.Close()
honeybeetest.Eventually(t, func() bool {
return v.conn.State() == transport.StateClosed
}, "expected connection closed")
err := v.worker.Send([]byte("hello"))
assert.Error(t, err)
})
}
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(socket, nil, nil)
assert.NoError(t, err)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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")
}
})
}
-111
View File
@@ -1,111 +0,0 @@
package logging
import (
"context"
"log/slog"
)
// Constants
const KEY_MODULE = "module"
const KEY_COMPONENT = "component"
const KEY_POOL_ID = "pool_id"
const KEY_PEER_ID = "peer_id"
const MODULE_NAME = "honeybee"
const COMPONENT_OUTBOUND_POOL = "outbound_pool"
const COMPONENT_OUTBOUND_WORKER = "outbound_worker"
const COMPONENT_INBOUND_POOL = "inbound_pool"
const COMPONENT_INBOUND_WORKER = "inbound_worker"
const COMPONENT_CONNECTION = "connection"
// Constructors
func NewOutboundPoolLogger(handler slog.Handler, poolID string) *slog.Logger {
return newLogger(handler,
KEY_MODULE, MODULE_NAME,
KEY_COMPONENT, COMPONENT_OUTBOUND_POOL,
KEY_POOL_ID, poolID,
)
}
func NewOutboundWorkerLogger(handler slog.Handler, poolID string, peerID string) *slog.Logger {
return newLogger(handler,
KEY_MODULE, MODULE_NAME,
KEY_COMPONENT, COMPONENT_OUTBOUND_WORKER,
KEY_POOL_ID, poolID,
KEY_PEER_ID, peerID,
)
}
func NewInboundPoolLogger(handler slog.Handler, poolID string) *slog.Logger {
return newLogger(handler,
KEY_MODULE, MODULE_NAME,
KEY_COMPONENT, COMPONENT_INBOUND_POOL,
KEY_POOL_ID, poolID,
)
}
func NewInboundWorkerLogger(handler slog.Handler, poolID string, peerID string) *slog.Logger {
return newLogger(handler,
KEY_MODULE, MODULE_NAME,
KEY_COMPONENT, COMPONENT_INBOUND_WORKER,
KEY_POOL_ID, poolID,
KEY_PEER_ID, peerID,
)
}
func NewConnectionLogger(handler slog.Handler, poolID string, peerID string) *slog.Logger {
return newLogger(handler,
KEY_MODULE, MODULE_NAME,
KEY_COMPONENT, COMPONENT_CONNECTION,
KEY_POOL_ID, poolID,
KEY_PEER_ID, peerID,
)
}
// Helpers
func newLogger(handler slog.Handler, attrs ...any) *slog.Logger {
return slog.New(handler).With(attrs...)
}
// Handlers
type ForcedLevelHandler struct {
level slog.Level
next slog.Handler
}
func NewForcedLevelHandler(level slog.Level, next slog.Handler) slog.Handler {
return &ForcedLevelHandler{
level: level,
next: next,
}
}
func (h *ForcedLevelHandler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.level
}
func (h *ForcedLevelHandler) Handle(ctx context.Context, r slog.Record) error {
return h.next.Handle(ctx, r)
}
func (h *ForcedLevelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &ForcedLevelHandler{level: h.level, next: h.next.WithAttrs(attrs)}
}
func (h *ForcedLevelHandler) WithGroup(name string) slog.Handler {
return &ForcedLevelHandler{level: h.level, next: h.next.WithGroup(name)}
}
func WrapOrDefault(level *slog.Level, handler slog.Handler) slog.Handler {
if level != nil {
return NewForcedLevelHandler(*level, handler)
}
return handler
}
-84
View File
@@ -1,84 +0,0 @@
package logging
import (
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
// "github.com/stretchr/testify/assert"
"log/slog"
"testing"
)
// Helpers
func log(level slog.Level, msg string, attrs map[string]any) honeybeetest.ExpectedLog {
return honeybeetest.ExpectedLog{Level: level, Msg: msg, Attrs: attrs}
}
// Tests
func TestOutboundLogger(t *testing.T) {
const POOL_ID = "pool-1"
const PEER_ID = "wss://test"
handler := honeybeetest.NewMockSlogHandler()
poolLogger := NewOutboundPoolLogger(handler, POOL_ID)
workerLogger := NewOutboundWorkerLogger(handler, POOL_ID, PEER_ID)
connLogger := NewConnectionLogger(handler, POOL_ID, PEER_ID)
poolLogger.Info("test")
workerLogger.Info("test")
connLogger.Info("test")
honeybeetest.Eventually(t, func() bool {
return len(handler.GetRecords()) == 3
}, "expected a log record")
records := handler.GetRecords()
honeybeetest.AssertAttributePresent(t, records[0], KEY_MODULE, MODULE_NAME)
honeybeetest.AssertAttributePresent(t, records[0], KEY_COMPONENT, COMPONENT_OUTBOUND_POOL)
honeybeetest.AssertAttributePresent(t, records[0], KEY_POOL_ID, POOL_ID)
honeybeetest.AssertAttributePresent(t, records[1], KEY_MODULE, MODULE_NAME)
honeybeetest.AssertAttributePresent(t, records[1], KEY_COMPONENT, COMPONENT_OUTBOUND_WORKER)
honeybeetest.AssertAttributePresent(t, records[1], KEY_POOL_ID, POOL_ID)
honeybeetest.AssertAttributePresent(t, records[1], KEY_PEER_ID, PEER_ID)
honeybeetest.AssertAttributePresent(t, records[2], KEY_MODULE, MODULE_NAME)
honeybeetest.AssertAttributePresent(t, records[2], KEY_COMPONENT, COMPONENT_CONNECTION)
honeybeetest.AssertAttributePresent(t, records[2], KEY_POOL_ID, POOL_ID)
honeybeetest.AssertAttributePresent(t, records[2], KEY_PEER_ID, PEER_ID)
}
func TestInboundLogger(t *testing.T) {
const POOL_ID = "pool-1"
const PEER_ID = "peer-1"
handler := honeybeetest.NewMockSlogHandler()
poolLogger := NewInboundPoolLogger(handler, POOL_ID)
workerLogger := NewInboundWorkerLogger(handler, POOL_ID, PEER_ID)
connLogger := NewConnectionLogger(handler, POOL_ID, PEER_ID)
poolLogger.Info("test")
workerLogger.Info("test")
connLogger.Info("test")
honeybeetest.Eventually(t, func() bool {
return len(handler.GetRecords()) == 3
}, "expected a log record")
records := handler.GetRecords()
honeybeetest.AssertAttributePresent(t, records[0], KEY_MODULE, MODULE_NAME)
honeybeetest.AssertAttributePresent(t, records[0], KEY_COMPONENT, COMPONENT_INBOUND_POOL)
honeybeetest.AssertAttributePresent(t, records[0], KEY_POOL_ID, POOL_ID)
honeybeetest.AssertAttributePresent(t, records[1], KEY_MODULE, MODULE_NAME)
honeybeetest.AssertAttributePresent(t, records[1], KEY_COMPONENT, COMPONENT_INBOUND_WORKER)
honeybeetest.AssertAttributePresent(t, records[1], KEY_POOL_ID, POOL_ID)
honeybeetest.AssertAttributePresent(t, records[1], KEY_PEER_ID, PEER_ID)
honeybeetest.AssertAttributePresent(t, records[2], KEY_MODULE, MODULE_NAME)
honeybeetest.AssertAttributePresent(t, records[2], KEY_COMPONENT, COMPONENT_CONNECTION)
honeybeetest.AssertAttributePresent(t, records[2], KEY_POOL_ID, POOL_ID)
honeybeetest.AssertAttributePresent(t, records[2], KEY_PEER_ID, PEER_ID)
}
-575
View File
@@ -1,575 +0,0 @@
package outbound
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/logging"
"git.wisehodl.dev/jay/go-honeybee/queue"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"log/slog"
"sync"
"sync/atomic"
"time"
)
// Worker
type Worker interface {
Start(pool PoolPlugin)
Stop()
Send(data []byte) error
Stats() WorkerStats
}
type WorkerStats struct {
IncomingAvailable bool
ChanIncoming int
ChanQueue int
ChanForwarder int
BufferDepth int64
ConnectionAvailable bool
Connection transport.ConnectionStats
TotalProcessed uint64
TotalDropped uint64
TotalSent uint64
TotalRestarts uint64
}
type DefaultWorker struct {
id string
conn atomic.Pointer[transport.Connection]
heartbeat chan struct{}
toQueue chan types.ReceivedMessage
toForwarder chan types.ReceivedMessage
processedCount *atomic.Uint64
droppedCount *atomic.Uint64
outgoingCount *atomic.Uint64
restartCount *atomic.Uint64
bufferDepth *atomic.Int64
config *WorkerConfig
ctx context.Context
cancel context.CancelFunc
logger *slog.Logger
}
func NewWorker(
ctx context.Context,
id string,
config *WorkerConfig,
logger *slog.Logger,
) (*DefaultWorker, error) {
if config == nil {
config = GetDefaultWorkerConfig()
}
if err := ValidateWorkerConfig(config); err != nil {
return nil, err
}
wctx, wcancel := context.WithCancel(ctx)
w := &DefaultWorker{
id: id,
config: config,
heartbeat: make(chan struct{}),
toQueue: make(chan types.ReceivedMessage, 256),
toForwarder: make(chan types.ReceivedMessage, 256),
processedCount: &atomic.Uint64{},
droppedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
bufferDepth: &atomic.Int64{},
ctx: wctx,
cancel: wcancel,
logger: logger,
}
return w, nil
}
func (w *DefaultWorker) Start(pool PoolPlugin) {
if w.logger != nil {
w.logger.Debug("starting")
}
dial := make(chan struct{}, 1)
newConn := make(chan *transport.Connection, 1)
keepalive := make(chan struct{}, 1)
var wg sync.WaitGroup
wg.Add(5)
go func() {
defer wg.Done()
RunDialer(w.id, w.ctx, pool, dial, newConn, w.logger)
}()
go func() {
defer wg.Done()
RunKeepalive(w.ctx, w.heartbeat, keepalive, w.config.KeepaliveTimeout, w.logger)
}()
go func() {
defer wg.Done()
queue.RunQueue(w.id, w.ctx, w.toQueue, w.toForwarder, w.config.MaxQueueSize, w.droppedCount, w.bufferDepth)
}()
go func() {
defer wg.Done()
RunForwarder(w.id, w.ctx, w.toForwarder, pool.Inbox, w.processedCount, pool.InboxCounter)
}()
go func() {
defer wg.Done()
session := &Session{
id: w.id,
connPtr: &w.conn,
messages: w.toQueue,
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 {
w.logger.Info("started")
}
wg.Wait()
if w.logger != nil {
w.logger.Info("stopped")
}
}
func (w *DefaultWorker) Stop() {
if w.logger != nil {
w.logger.Debug("shutting down")
}
w.cancel()
}
func (w *DefaultWorker) Send(data []byte) error {
conn := w.conn.Load()
if conn == nil {
// connection not established by session
return NewWorkerError(w.id, ErrConnectionUnavailable)
}
if err := conn.Send(data); err != nil {
return NewWorkerError(w.id, err)
}
select {
case w.heartbeat <- struct{}{}:
case <-w.ctx.Done():
}
w.outgoingCount.Add(1)
return nil
}
func (w *DefaultWorker) Stats() WorkerStats {
connectionAvailable := false
incomingLen := 0
connStats := transport.ConnectionStats{}
conn := w.conn.Load()
if conn != nil {
connectionAvailable = true
incomingLen = len(conn.Incoming())
connStats = conn.Stats()
}
return WorkerStats{
IncomingAvailable: connectionAvailable,
ChanIncoming: incomingLen,
ChanQueue: len(w.toQueue),
ChanForwarder: len(w.toForwarder),
BufferDepth: w.bufferDepth.Load(),
ConnectionAvailable: connectionAvailable,
Connection: connStats,
TotalProcessed: w.processedCount.Load(),
TotalDropped: w.droppedCount.Load(),
TotalRestarts: w.restartCount.Load(),
TotalSent: w.outgoingCount.Load(),
}
}
type Session struct {
id string
connPtr *atomic.Pointer[transport.Connection]
messages chan<- types.ReceivedMessage
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(sctx, onStop, conn, s.messages, 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(
ctx context.Context,
onStop func(),
conn *transport.Connection,
messages chan<- types.ReceivedMessage,
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
messages <- types.ReceivedMessage{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 RunForwarder(
id string,
ctx context.Context,
messages <-chan types.ReceivedMessage,
inbox chan<- types.InboxMessage,
workerProcessedCount *atomic.Uint64,
poolInboxCount *atomic.Uint64,
) {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-messages:
if !ok {
return
}
select {
case <-ctx.Done():
return
case inbox <- types.InboxMessage{
ID: id,
Data: msg.Data,
ReceivedAt: msg.ReceivedAt,
}:
workerProcessedCount.Add(1)
poolInboxCount.Add(1)
}
}
}
}
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(
id string,
ctx context.Context,
pool PoolPlugin,
) (*transport.Connection, error) {
var logger *slog.Logger
if pool.Handler != nil && pool.ConnectionConfig.LoggingEnabled {
logger = logging.NewConnectionLogger(
logging.WrapOrDefault(pool.ConnectionConfig.LogLevel, pool.Handler), pool.ID, id)
}
conn, err := transport.NewConnection(id, pool.ConnectionConfig, logger)
if err != nil {
return nil, err
}
conn.SetDialer(pool.Dialer)
return conn, conn.Connect(ctx)
}
func RunDialer(
id string,
ctx context.Context,
pool PoolPlugin,
dial <-chan struct{},
newConn chan<- *transport.Connection,
logger *slog.Logger,
) {
for {
select {
case <-ctx.Done():
return
case <-dial:
// drain dial signals while connection is being established
done := make(chan struct{})
go func() {
for {
select {
case <-dial:
case <-done:
return
}
}
}()
if logger != nil {
logger.Debug("dialer: dialing")
}
// dial a new connection
conn, err := connect(id, ctx, pool)
close(done)
// 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")
}
// send the new connection or close and exit
select {
case newConn <- conn:
case <-ctx.Done():
conn.Close()
return
}
}
}
}
-223
View File
@@ -1,223 +0,0 @@
package outbound
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, cancel := context.WithCancel(context.Background())
defer cancel()
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)
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, cancel := context.WithCancel(context.Background())
defer cancel()
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)
dial <- struct{}{}
// wait for dial to start blocking on gate
<-started
// flood dial while dialer is blocked
for i := 0; i < 5; i++ {
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, cancel := context.WithCancel(context.Background())
defer cancel()
// 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)
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)
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)
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)
})
}
-33
View File
@@ -1,33 +0,0 @@
package outbound
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/types"
"sync/atomic"
"testing"
"time"
)
func TestRunForwarder(t *testing.T) {
t.Run("message passes through to inbox", func(t *testing.T) {
id := "wss://test"
messages := make(chan types.ReceivedMessage, 1)
inbox := make(chan types.InboxMessage, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go RunForwarder(id, ctx, messages, inbox, &atomic.Uint64{}, &atomic.Uint64{})
messages <- types.ReceivedMessage{Data: []byte("hello"), ReceivedAt: time.Now()}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-inbox:
return string(msg.Data) == "hello" && msg.ID == "wss://test"
default:
return false
}
}, "expected message")
})
}
-102
View File
@@ -1,102 +0,0 @@
package outbound
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, cancel := context.WithCancel(context.Background())
defer cancel()
go RunKeepalive(ctx, heartbeat, keepalive, timeout, nil)
// send heartbeats faster than the timeout
for i := 0; i < 5; i++ {
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, cancel := context.WithCancel(context.Background())
defer cancel()
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, cancel := context.WithCancel(context.Background())
defer cancel()
go RunKeepalive(ctx, heartbeat, keepalive, 0, nil)
// these must not block
for i := 0; i < 5; i++ {
heartbeat <- struct{}{}
}
honeybeetest.Never(t, func() bool {
select {
case <-keepalive:
return true
default:
return false
}
}, "keepalive signal should not fire when disabled")
})
}
-117
View File
@@ -1,117 +0,0 @@
package outbound
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"github.com/stretchr/testify/assert"
"sync/atomic"
"testing"
)
func TestWorkerSend(t *testing.T) {
t.Run("data sent to mock socket", func(t *testing.T) {
conn, _, _, outgoingData := setupTestConnection(t)
defer conn.Close()
ctx, cancel := context.WithCancel(context.Background())
heartbeat := make(chan struct{})
heartbeatCount := atomic.Int32{}
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
heartbeat: heartbeat,
outgoingCount: &atomic.Uint64{},
}
w.conn.Store(conn)
defer w.cancel()
go func() {
for range heartbeat {
heartbeatCount.Add(1)
}
}()
testData := []byte("hello")
err := w.Send(testData)
assert.NoError(t, err)
// at least one heartbeat was sent
honeybeetest.Eventually(t, func() bool {
return heartbeatCount.Load() >= 1
}, "expected heartbeats")
// message was sent by the socket
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-outgoingData:
return string(msg.Data) == "hello"
default:
return false
}
}, "expected message")
})
t.Run("sends one heartbeat per successful send", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
defer conn.Close()
ctx, cancel := context.WithCancel(context.Background())
heartbeat := make(chan struct{})
heartbeatCount := atomic.Int32{}
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
heartbeat: heartbeat,
outgoingCount: &atomic.Uint64{},
}
w.conn.Store(conn)
defer w.cancel()
go func() {
for range heartbeat {
heartbeatCount.Add(1)
}
}()
const count = 3
for i := 0; i < count; i++ {
err := w.Send([]byte(fmt.Sprintf("msg-%d", i)))
assert.NoError(t, err)
}
honeybeetest.Eventually(t, func() bool {
return heartbeatCount.Load() == count
}, "expected heartbeats")
})
t.Run("returns error if connection is unavailable", func(t *testing.T) {
// no connection available to worker
ctx, cancel := context.WithCancel(context.Background())
heartbeat := make(chan struct{})
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
heartbeat: heartbeat,
}
defer w.cancel()
go func() {
for range heartbeat {
}
}()
err := w.Send([]byte("hello"))
assert.ErrorIs(t, err, ErrConnectionUnavailable)
})
}
-230
View File
@@ -1,230 +0,0 @@
package outbound
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()
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for range heartbeat {
}
}()
go RunReader(ctx, cancel, conn, messages, heartbeat, nil)
before := time.Now()
incomingData <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: []byte("hello"),
}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-messages:
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()
messages := make(chan types.ReceivedMessage, 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 messages {
}
}()
go RunReader(ctx, cancel, conn, messages, heartbeat, nil)
const count = 3
for i := 0; i < count; i++ {
incomingData <- honeybeetest.MockIncomingData{
MsgType: websocket.TextMessage,
Data: []byte(fmt.Sprintf("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)
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for range heartbeat {
}
}()
go func() {
for range messages {
}
}()
go RunReader(ctx, cancel, conn, messages, 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)
messages := make(chan types.ReceivedMessage, 1)
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
go RunReader(ctx, cancel, conn, messages, 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(socket, nil, nil)
assert.NoError(t, err)
heartbeat := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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 outbound
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
messages chan types.ReceivedMessage
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),
messages: make(chan types.ReceivedMessage, 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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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 i := 0; i < 3; i++ {
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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,
messages: v.messages,
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")
})
}
-315
View File
@@ -1,315 +0,0 @@
package outbound
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{}),
toQueue: make(chan types.ReceivedMessage, 256),
toForwarder: make(chan types.ReceivedMessage, 256),
processedCount: &atomic.Uint64{},
droppedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
bufferDepth: &atomic.Int64{},
}
}
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.Add(1)
go func() {
w.Start(pool)
wg.Done()
}()
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.Add(1)
go func() {
w.Start(pool)
wg.Done()
}()
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.Add(1)
go func() {
w.Start(pool)
wg.Done()
}()
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.Add(1)
go func() {
w.Start(pool)
wg.Done()
}()
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.Add(1)
go func() {
w.Start(pool)
wg.Done()
}()
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.Add(1)
go func() {
w.Start(pool)
wg.Done()
}()
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")
})
}
+80 -66
View File
@@ -1,11 +1,12 @@
package outbound
package honeybee
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/logging"
"log/slog"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"log/slog"
"git.wisehodl.dev/jay/go-mana-component"
"sync"
"sync/atomic"
"time"
@@ -18,7 +19,9 @@ type Dialer = types.Dialer
var NormalizeURL = transport.NormalizeURL
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
type PoolEventKind string
@@ -50,16 +53,15 @@ type PeerStats struct {
}
type PoolPlugin struct {
ID string
Inbox chan<- types.InboxMessage
Events chan<- PoolEvent
InboxCounter *atomic.Uint64
Dialer types.Dialer
ConnectionConfig *transport.ConnectionConfig
Handler slog.Handler
ConnectionConfig transport.ConnectionConfig
}
// ----------------------------------------------------------------------------
// Pool
// ----------------------------------------------------------------------------
type Peer struct {
id string
@@ -67,34 +69,27 @@ type Peer struct {
}
type Pool struct {
ctx context.Context
cancel context.CancelFunc
id string
peers map[string]*Peer
inbox chan types.InboxMessage
events chan PoolEvent
inboxCounter *atomic.Uint64
outgoingCount *atomic.Uint64
closed bool
dialer types.Dialer
config *PoolConfig
handler slog.Handler
logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
wg sync.WaitGroup
closed bool
inboxCounter *atomic.Uint64
outgoingCount *atomic.Uint64
}
func NewPool(ctx context.Context, id string, config *PoolConfig, handler slog.Handler,
func NewPool(ctx context.Context, config *PoolConfig, handler slog.Handler,
) (*Pool, error) {
if id == "" {
return nil, ErrInvalidPoolID
}
if config == nil {
config = GetDefaultPoolConfig()
}
@@ -104,8 +99,9 @@ func NewPool(ctx context.Context, id string, config *PoolConfig, handler slog.Ha
// deadlocks.
if config.WorkerFactory == nil {
config.WorkerFactory = func(
ctx context.Context, id string, logger *slog.Logger) (Worker, error) {
return NewWorker(ctx, id, config.WorkerConfig, logger)
ctx context.Context, id string, handler slog.Handler) (Worker, error) {
wc := config.WorkerConfig
return NewWorker(ctx, id, &wc, handler)
}
}
@@ -113,27 +109,36 @@ func NewPool(ctx context.Context, id string, config *PoolConfig, handler slog.Ha
return nil, err
}
pctx, cancel := context.WithCancel(ctx)
ctx, cancel := context.WithCancel(component.MustNew(ctx, "honeybee", "pool"))
var logger *slog.Logger
if handler != nil && config.LoggingEnabled {
logger = logging.NewOutboundPoolLogger(
logging.WrapOrDefault(config.LogLevel, handler), id)
if handler != nil {
c := component.FromContext(ctx)
logger = slog.New(handler).With(slog.Any("component", c))
}
var dialer types.Dialer
if config.ConnectionConfig.Dialer != nil {
dialer = config.ConnectionConfig.Dialer
} else {
dialer = transport.NewDialer()
}
return &Pool{
ctx: pctx,
cancel: cancel,
id: id,
peers: make(map[string]*Peer),
inbox: make(chan types.InboxMessage, config.InboxBufferSize),
events: make(chan PoolEvent, config.EventsBufferSize),
peers: make(map[string]*Peer),
inbox: make(chan types.InboxMessage, config.InboxBufferSize),
events: make(chan PoolEvent, config.EventsBufferSize),
dialer: dialer,
config: config,
handler: handler,
logger: logger,
ctx: ctx,
cancel: cancel,
inboxCounter: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
dialer: transport.NewDialer(),
config: config,
handler: handler,
logger: logger,
}, nil
}
@@ -142,7 +147,7 @@ func (p *Pool) Peers() []string {
defer p.mu.RUnlock()
ids := make([]string, 0, len(p.peers))
for i, _ := range p.peers {
for i := range p.peers {
ids = append(ids, i)
}
return ids
@@ -196,16 +201,9 @@ func (p *Pool) PeerStats(id string) (PeerStats, error) {
}, nil
}
func (p *Pool) SetDialer(d types.Dialer) {
if d == nil {
panic("dialer cannot be nil")
}
p.dialer = d
}
func (p *Pool) Close() {
if p.logger != nil {
p.logger.Debug("closing")
p.logger.Info("closing")
}
p.mu.Lock()
@@ -233,9 +231,24 @@ func (p *Pool) Close() {
}()
}
func (p *Pool) Connect(id string) error {
// ConnectOption configures a single Connect call.
type ConnectOption func(*connectOptions)
type connectOptions struct {
dialer types.Dialer
}
// WithDialer returns a ConnectOption that overrides the pool dialer for this
// connection only.
func WithDialer(d types.Dialer) ConnectOption {
return func(o *connectOptions) {
o.dialer = d
}
}
func (p *Pool) Connect(id string, opts ...ConnectOption) error {
if p.logger != nil {
p.logger.Debug("connecting to peer", "peer", id)
p.logger.Info("connecting", "peer", id)
}
id, err := transport.NormalizeURL(id)
@@ -254,38 +267,39 @@ func (p *Pool) Connect(id string) error {
return NewPoolError(ErrPeerExists)
}
var logger *slog.Logger
if p.handler != nil && p.config.WorkerConfig.LoggingEnabled {
logger = logging.NewOutboundWorkerLogger(
logging.WrapOrDefault(p.config.WorkerConfig.LogLevel, p.handler), p.id, id)
}
// The worker factory must be non-blocking to avoid deadlocks
worker, err := p.config.WorkerFactory(p.ctx, id, logger)
worker, err := p.config.WorkerFactory(p.ctx, id, p.handler)
if err != nil {
return err
}
o := &connectOptions{}
for _, opt := range opts {
opt(o)
}
effectiveDialer := p.dialer
if o.dialer != nil {
effectiveDialer = o.dialer
}
cc := p.config.ConnectionConfig.Clone()
cc.Dialer = effectiveDialer
pool := PoolPlugin{
ID: p.id,
Inbox: p.inbox,
Events: p.events,
InboxCounter: p.inboxCounter,
Dialer: p.dialer,
ConnectionConfig: p.config.ConnectionConfig,
Handler: p.handler,
ConnectionConfig: cc,
}
p.wg.Add(1)
go func() {
p.wg.Go(func() {
worker.Start(pool)
p.wg.Done()
}()
})
p.peers[id] = &Peer{id: id, worker: worker}
if p.logger != nil {
p.logger.Info("registered peer", "peer", id)
p.logger.Debug("registered peer", "peer", id)
}
return nil
@@ -293,7 +307,7 @@ func (p *Pool) Connect(id string) error {
func (p *Pool) Remove(id string) error {
if p.logger != nil {
p.logger.Debug("disconnecting from peer", "peer", id)
p.logger.Info("disconnecting", "peer", id)
}
id, err := transport.NormalizeURL(id)
@@ -317,7 +331,7 @@ func (p *Pool) Remove(id string) error {
peer.worker.Stop()
if p.logger != nil {
p.logger.Info("disconnected from peer", "peer", id)
p.logger.Debug("disconnected from peer", "peer", id)
}
return nil
+66 -13
View File
@@ -1,9 +1,10 @@
package outbound
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"
@@ -15,14 +16,20 @@ import (
func setupPool(t *testing.T) (*Pool, *honeybeetest.MockDialer) {
t.Helper()
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
assert.NoError(t, err)
dialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return honeybeetest.NewMockSocket(), nil, nil
},
}
pool.dialer = dialer
cc := *transport.GetDefaultConnectionConfig()
cc.Dialer = dialer
pool, err := NewPool(context.Background(), &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
ConnectionConfig: cc,
WorkerConfig: *GetDefaultWorkerConfig(),
}, nil)
assert.NoError(t, err)
return pool, dialer
}
@@ -45,11 +52,6 @@ func expectEvent(
// Tests
func TestPoolID(t *testing.T) {
_, err := NewPool(context.Background(), "", nil, nil)
assert.ErrorIs(t, err, ErrInvalidPoolID)
}
func TestPoolConnect(t *testing.T) {
t.Run("successfully adds connection", func(t *testing.T) {
pool, _ := setupPool(t)
@@ -88,9 +90,54 @@ func TestPoolConnect(t *testing.T) {
})
}
func TestPoolConnectWithDialer(t *testing.T) {
t.Run("per-call dialer is used instead of pool dialer", func(t *testing.T) {
perCallUsed := false
perCallDialer := &honeybeetest.MockDialer{
DialContextFunc: func(ctx context.Context, url string, h http.Header) (types.Socket, *http.Response, error) {
perCallUsed = true
return honeybeetest.NewMockSocket(), nil, nil
},
}
// pool dialer should NOT be called
poolDialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
t.Error("pool dialer should not be called when per-call dialer is provided")
return nil, nil, fmt.Errorf("unexpected call")
},
}
cc := *transport.GetDefaultConnectionConfig()
cc.Dialer = poolDialer
pool, err := NewPool(context.Background(), &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
ConnectionConfig: cc,
WorkerConfig: *GetDefaultWorkerConfig(),
}, nil)
assert.NoError(t, err)
err = pool.Connect("wss://test", WithDialer(perCallDialer))
assert.NoError(t, err)
honeybeetest.Eventually(t, func() bool {
select {
case e := <-pool.events:
return e.ID == "wss://test" && e.Kind == EventConnected
default:
return false
}
}, "expected connected event")
assert.True(t, perCallUsed, "per-call dialer was not used")
pool.Close()
})
}
func TestPoolClose(t *testing.T) {
t.Run("channels close after pool close", func(t *testing.T) {
pool, _ := NewPool(context.Background(), "pool-1", nil, nil)
pool, _ := NewPool(context.Background(), nil, nil)
pool.Close()
_, ok := <-pool.Inbox()
assert.False(t, ok)
@@ -99,7 +146,7 @@ func TestPoolClose(t *testing.T) {
})
t.Run("connect after close returns error", func(t *testing.T) {
pool, _ := NewPool(context.Background(), "pool-1", nil, nil)
pool, _ := NewPool(context.Background(), nil, nil)
pool.Close()
err := pool.Connect("wss://test")
assert.ErrorIs(t, err, ErrPoolClosed)
@@ -157,9 +204,15 @@ func TestPoolSend(t *testing.T) {
},
}
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
cc := *transport.GetDefaultConnectionConfig()
cc.Dialer = mockDialer
pool, err := NewPool(context.Background(), &PoolConfig{
InboxBufferSize: 256,
EventsBufferSize: 10,
ConnectionConfig: cc,
WorkerConfig: *GetDefaultWorkerConfig(),
}, nil)
assert.NoError(t, err)
pool.dialer = mockDialer
err = pool.Connect("wss://test")
assert.NoError(t, err)
-131
View File
@@ -1,131 +0,0 @@
package queue
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/types"
"sync/atomic"
)
func RunQueue(
id string,
ctx context.Context,
in <-chan types.ReceivedMessage,
out chan<- types.ReceivedMessage,
maxQueueSize int,
droppedCount *atomic.Uint64,
bufferDepth *atomic.Int64,
) {
var next types.ReceivedMessage
var queue messageQueue
if maxQueueSize > 0 {
queue = newBoundedRing(maxQueueSize)
} else {
queue = newUnboundedRing(1024)
}
for {
var outOrNil chan<- types.ReceivedMessage
// enable out channel if queue is populated
if queue.len() > 0 {
outOrNil = out
next = queue.peek()
}
select {
case <-ctx.Done():
return
case msg := <-in:
// limit queue size if maximum is configured
if maxQueueSize > 0 && queue.len() >= maxQueueSize {
// drop oldest message
_ = queue.pop()
droppedCount.Add(1)
bufferDepth.Add(-1)
}
// add new message
queue.push(msg)
bufferDepth.Add(1)
// send next message to out channel
case outOrNil <- next:
_ = queue.pop()
bufferDepth.Add(-1)
}
}
}
// Ring Buffer Queue
type messageQueue interface {
push(types.ReceivedMessage)
pop() types.ReceivedMessage
peek() types.ReceivedMessage
len() int
}
type ring struct {
buf []types.ReceivedMessage
head int
size int
}
func (r *ring) len() int { return r.size }
func (r *ring) pop() types.ReceivedMessage {
m := r.buf[r.head]
var zero types.ReceivedMessage
r.buf[r.head] = zero // release reference for GC
r.head = (r.head + 1) % len(r.buf)
r.size--
return m
}
func (r *ring) peek() types.ReceivedMessage {
m := r.buf[r.head]
return m
}
// shared write at logical tail; caller guarantees space exists
func (r *ring) writeTail(m types.ReceivedMessage) {
r.buf[(r.head+r.size)%len(r.buf)] = m
r.size++
}
// Bounded ring
type boundedRing struct{ ring }
func newBoundedRing(cap int) *boundedRing {
return &boundedRing{ring{buf: make([]types.ReceivedMessage, cap)}}
}
func (b *boundedRing) push(m types.ReceivedMessage) {
if b.size == len(b.buf) {
b.buf[b.head] = m
b.head = (b.head + 1) % len(b.buf)
return
}
b.writeTail(m)
}
// Unbounded Ring
type unboundedRing struct{ ring }
func newUnboundedRing(initialCap int) *unboundedRing {
if initialCap < 1 {
initialCap = 1
}
return &unboundedRing{ring{buf: make([]types.ReceivedMessage, initialCap)}}
}
func (u *unboundedRing) push(m types.ReceivedMessage) {
if u.size == len(u.buf) {
bigger := make([]types.ReceivedMessage, len(u.buf)*2)
n := copy(bigger, u.buf[u.head:])
copy(bigger[n:], u.buf[:u.head])
u.buf = bigger
u.head = 0
}
u.writeTail(m)
}
-105
View File
@@ -1,105 +0,0 @@
package queue
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/types"
"github.com/stretchr/testify/assert"
"sync/atomic"
"testing"
"time"
)
func TestRunQueue(t *testing.T) {
t.Run("message passes through to inbox", func(t *testing.T) {
id := "wss://test"
inChan := make(chan types.ReceivedMessage, 1)
outChan := make(chan types.ReceivedMessage, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go RunQueue(id, ctx, inChan, outChan, 0, &atomic.Uint64{}, &atomic.Int64{})
inChan <- types.ReceivedMessage{Data: []byte("hello"), ReceivedAt: time.Now()}
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-outChan:
return string(msg.Data) == "hello"
default:
return false
}
}, "expected message")
})
t.Run("oldest message dropped when queue is full", func(t *testing.T) {
id := "wss://test"
inChan := make(chan types.ReceivedMessage, 1)
outChan := make(chan types.ReceivedMessage, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
gate := make(chan struct{})
gatedInbox := make(chan types.ReceivedMessage)
// gate the inbox from receiving messages until the gate is opened
go func() {
<-gate
for msg := range gatedInbox {
outChan <- msg
}
}()
go RunQueue(id, ctx, inChan, gatedInbox, 2, &atomic.Uint64{}, &atomic.Int64{})
// send three messages while the gated inbox is blocked
inChan <- types.ReceivedMessage{Data: []byte("first"), ReceivedAt: time.Now()}
inChan <- types.ReceivedMessage{Data: []byte("second"), ReceivedAt: time.Now()}
inChan <- types.ReceivedMessage{Data: []byte("third"), ReceivedAt: time.Now()}
// allow time for the first message to be dropped
time.Sleep(20 * time.Millisecond)
// close the gate, draining messages into the inbox
close(gate)
// receive messages from the inbox
var received []string
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-outChan:
received = append(received, string(msg.Data))
default:
}
return len(received) == 2
}, "expected messages")
// first message was dropped
assert.Equal(t, []string{"second", "third"}, received)
})
t.Run("exits on context cancellation", func(t *testing.T) {
id := "wss://test"
inChan := make(chan types.ReceivedMessage, 1)
outChan := make(chan types.ReceivedMessage, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
RunQueue(id, ctx, inChan, outChan, 0, &atomic.Uint64{}, &atomic.Int64{})
close(done)
}()
cancel()
honeybeetest.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, "expected done signal")
})
}
+31 -42
View File
@@ -1,11 +1,17 @@
package transport
import (
"log/slog"
"git.wisehodl.dev/jay/go-honeybee/types"
"net/http"
"time"
)
// ----------------------------------------------------------------------------
// Connection Config
// ----------------------------------------------------------------------------
// Types
type CloseHandler func(code int, text string) error
type ConnectionConfig struct {
@@ -15,12 +21,12 @@ type ConnectionConfig struct {
PingInterval time.Duration
IncomingBufferSize int
ErrorsBufferSize int
LoggingEnabled bool
LogLevel *slog.Level
Retry *RetryConfig
Retry RetryConfig
Dialer types.Dialer
}
type RetryConfig struct {
Disabled bool
MaxRetries int
InitialDelay time.Duration
MaxDelay time.Duration
@@ -29,6 +35,8 @@ type RetryConfig struct {
type ConnectionOption func(*ConnectionConfig) error
// Constructors
func NewConnectionConfig(options ...ConnectionOption) (*ConnectionConfig, error) {
conf := GetDefaultConnectionConfig()
if err := applyConnectionOptions(conf, options...); err != nil {
@@ -50,19 +58,20 @@ func GetDefaultConnectionConfig() *ConnectionConfig {
PingInterval: 20 * time.Second,
IncomingBufferSize: 100,
ErrorsBufferSize: 10,
LoggingEnabled: true,
LogLevel: nil,
Retry: GetDefaultRetryConfig(),
Retry: RetryConfig{
MaxRetries: 0, // Infinite retries
InitialDelay: 1 * time.Second,
MaxDelay: 60 * time.Second,
JitterFactor: 0.2,
},
}
}
func GetDefaultRetryConfig() *RetryConfig {
return &RetryConfig{
MaxRetries: 0, // Infinite retries
InitialDelay: 1 * time.Second,
MaxDelay: 60 * time.Second,
JitterFactor: 0.2,
func (c ConnectionConfig) Clone() ConnectionConfig {
if c.RequestHeader != nil {
c.RequestHeader = c.RequestHeader.Clone()
}
return c
}
func applyConnectionOptions(config *ConnectionConfig, options ...ConnectionOption) error {
@@ -74,13 +83,15 @@ func applyConnectionOptions(config *ConnectionConfig, options ...ConnectionOptio
return nil
}
// Validation
func ValidateConnectionConfig(config *ConnectionConfig) error {
err := validateWriteTimeout(config.WriteTimeout)
if err != nil {
return err
}
if config.Retry != nil {
if !config.Retry.Disabled {
err = validateMaxRetries(config.Retry.MaxRetries)
if err != nil {
return err
@@ -158,6 +169,8 @@ func validateJitterFactor(value float64) error {
return nil
}
// Options
func WithCloseHandler(handler CloseHandler) ConnectionOption {
return func(c *ConnectionConfig) error {
c.CloseHandler = handler
@@ -216,34 +229,22 @@ func WithErrorsBufferSize(value int) ConnectionOption {
}
}
func WithLoggingEnabled(value bool) ConnectionOption {
func WithConnectionDialer(d types.Dialer) ConnectionOption {
return func(c *ConnectionConfig) error {
c.LoggingEnabled = value
c.Dialer = d
return nil
}
}
func WithLogLevel(level slog.Level) ConnectionOption {
func WithRetryDisabled() ConnectionOption {
return func(c *ConnectionConfig) error {
l := level
c.LogLevel = &l
return nil
}
}
func WithoutRetry() ConnectionOption {
return func(c *ConnectionConfig) error {
c.Retry = nil
c.Retry.Disabled = true
return nil
}
}
func WithRetryMaxRetries(value int) ConnectionOption {
return func(c *ConnectionConfig) error {
if c.Retry == nil {
c.Retry = GetDefaultRetryConfig()
}
err := validateMaxRetries(value)
if err != nil {
return err
@@ -256,10 +257,6 @@ func WithRetryMaxRetries(value int) ConnectionOption {
func WithRetryInitialDelay(value time.Duration) ConnectionOption {
return func(c *ConnectionConfig) error {
if c.Retry == nil {
c.Retry = GetDefaultRetryConfig()
}
err := validateInitialDelay(value)
if err != nil {
return err
@@ -272,10 +269,6 @@ func WithRetryInitialDelay(value time.Duration) ConnectionOption {
func WithRetryMaxDelay(value time.Duration) ConnectionOption {
return func(c *ConnectionConfig) error {
if c.Retry == nil {
c.Retry = GetDefaultRetryConfig()
}
err := validateMaxDelay(value)
if err != nil {
return err
@@ -288,10 +281,6 @@ func WithRetryMaxDelay(value time.Duration) ConnectionOption {
func WithRetryJitterFactor(value float64) ConnectionOption {
return func(c *ConnectionConfig) error {
if c.Retry == nil {
c.Retry = GetDefaultRetryConfig()
}
err := validateJitterFactor(value)
if err != nil {
return err
+39 -24
View File
@@ -1,8 +1,8 @@
package transport
import (
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"github.com/stretchr/testify/assert"
"log/slog"
"net/http"
"testing"
"time"
@@ -36,20 +36,12 @@ func TestDefaultConnectionConfig(t *testing.T) {
PingInterval: 20 * time.Second,
IncomingBufferSize: 100,
ErrorsBufferSize: 10,
LoggingEnabled: true,
LogLevel: nil,
Retry: GetDefaultRetryConfig(),
})
}
func TestDefaultRetryConnectionConfig(t *testing.T) {
conf := GetDefaultRetryConfig()
assert.Equal(t, conf, &RetryConfig{
MaxRetries: 0,
InitialDelay: 1 * time.Second,
MaxDelay: 60 * time.Second,
JitterFactor: 0.2,
Retry: RetryConfig{
MaxRetries: 0,
InitialDelay: 1 * time.Second,
MaxDelay: 60 * time.Second,
JitterFactor: 0.2,
},
})
}
@@ -61,8 +53,6 @@ func TestApplyConnectionOptions(t *testing.T) {
conf,
WithIncomingBufferSize(256),
WithErrorsBufferSize(100),
WithLoggingEnabled(false),
WithLogLevel(slog.LevelError),
WithRetryMaxRetries(0),
WithRetryInitialDelay(3*time.Second),
WithRetryJitterFactor(0.5),
@@ -71,8 +61,6 @@ func TestApplyConnectionOptions(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 256, conf.IncomingBufferSize)
assert.Equal(t, 100, conf.ErrorsBufferSize)
assert.False(t, conf.LoggingEnabled)
assert.Equal(t, slog.LevelError, *conf.LogLevel)
assert.Equal(t, 0, conf.Retry.MaxRetries)
assert.Equal(t, 3*time.Second, conf.Retry.InitialDelay)
assert.Equal(t, 0.5, conf.Retry.JitterFactor)
@@ -121,10 +109,10 @@ func TestWithWriteTimeout(t *testing.T) {
func TestWithRetry(t *testing.T) {
t.Run("without retry", func(t *testing.T) {
conf := GetDefaultConnectionConfig()
opt := WithoutRetry()
opt := WithRetryDisabled()
err := applyConnectionOptions(conf, opt)
assert.NoError(t, err)
assert.Nil(t, conf.Retry)
assert.True(t, conf.Retry.Disabled)
})
t.Run("with attempts", func(t *testing.T) {
@@ -216,7 +204,7 @@ func TestValidateConnectionConfig(t *testing.T) {
}{
{
name: "valid empty",
conf: *&ConnectionConfig{},
conf: ConnectionConfig{Retry: RetryConfig{Disabled: true}},
},
{
name: "valid defaults",
@@ -227,7 +215,7 @@ func TestValidateConnectionConfig(t *testing.T) {
conf: ConnectionConfig{
CloseHandler: (func(code int, text string) error { return nil }),
WriteTimeout: time.Duration(30),
Retry: &RetryConfig{
Retry: RetryConfig{
MaxRetries: 0,
InitialDelay: 2 * time.Second,
MaxDelay: 10 * time.Second,
@@ -238,7 +226,7 @@ func TestValidateConnectionConfig(t *testing.T) {
{
name: "invalid - initial delay > max delay",
conf: ConnectionConfig{
Retry: &RetryConfig{
Retry: RetryConfig{
InitialDelay: 10 * time.Second,
MaxDelay: 1 * time.Second,
},
@@ -266,3 +254,30 @@ func TestValidateConnectionConfig(t *testing.T) {
})
}
}
func TestConnectionConfigClone(t *testing.T) {
header := http.Header{}
header.Set("X-Test", "val")
orig := ConnectionConfig{
RequestHeader: header,
WriteTimeout: 5 * time.Second,
Retry: RetryConfig{Disabled: true},
}
cloned := orig.Clone()
// values match
assert.Equal(t, orig.WriteTimeout, cloned.WriteTimeout)
assert.Equal(t, "val", cloned.RequestHeader.Get("X-Test"))
// header is a distinct copy
cloned.RequestHeader.Set("X-Test", "mutated")
assert.Equal(t, "val", orig.RequestHeader.Get("X-Test"))
}
func TestWithConnectionDialer(t *testing.T) {
mock := &honeybeetest.MockDialer{}
conf, err := NewConnectionConfig(WithConnectionDialer(mock))
assert.NoError(t, err)
assert.Equal(t, mock, conf.Dialer)
}
+295 -236
View File
@@ -12,9 +12,14 @@ import (
"time"
"git.wisehodl.dev/jay/go-honeybee/types"
"git.wisehodl.dev/jay/go-mana-component"
"github.com/gorilla/websocket"
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
type ConnectionState int
const (
@@ -48,11 +53,19 @@ type ConnectionStats struct {
TotalHeartbeats uint64
}
// ----------------------------------------------------------------------------
// Connection
// ----------------------------------------------------------------------------
// ---------------------------/
// Constructors
// -------------------------/
type Connection struct {
url *url.URL
dialer types.Dialer
socket types.Socket
config *ConnectionConfig
config ConnectionConfig
logger *slog.Logger
incoming chan []byte
@@ -74,7 +87,7 @@ type Connection struct {
cleanupOnce sync.Once
}
func NewConnection(urlStr string, config *ConnectionConfig, logger *slog.Logger) (*Connection, error) {
func NewConnection(ctx context.Context, urlStr string, config *ConnectionConfig, handler slog.Handler) (*Connection, error) {
if config == nil {
config = GetDefaultConnectionConfig()
}
@@ -88,15 +101,26 @@ func NewConnection(urlStr string, config *ConnectionConfig, logger *slog.Logger)
return nil, err
}
if component.FromContext(ctx) == nil {
ctx = component.MustNew(ctx, "honeybee", "connection")
} else {
ctx = component.MustExtend(ctx, "connection")
}
// Clone config to ensure full ownership of all fields.
cc := config.Clone()
if cc.Dialer == nil {
cc.Dialer = NewDialer()
}
conn := &Connection{
url: url,
dialer: NewDialer(),
dialer: cc.Dialer,
socket: nil,
config: config,
logger: logger,
incoming: make(chan []byte, config.IncomingBufferSize),
config: cc,
incoming: make(chan []byte, cc.IncomingBufferSize),
heartbeat: make(chan struct{}, 1),
errors: make(chan error, config.ErrorsBufferSize),
errors: make(chan error, cc.ErrorsBufferSize),
incomingCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
heartbeatCount: &atomic.Uint64{},
@@ -104,11 +128,16 @@ func NewConnection(urlStr string, config *ConnectionConfig, logger *slog.Logger)
done: make(chan struct{}),
}
if handler != nil {
comp := component.FromContext(ctx)
conn.logger = slog.New(handler).With(slog.Any("component", comp))
}
return conn, nil
}
func NewConnectionFromSocket(
socket types.Socket, config *ConnectionConfig, logger *slog.Logger,
ctx context.Context, socket types.Socket, config *ConnectionConfig, handler slog.Handler,
) (*Connection, error) {
if socket == nil {
return nil, NewConnectionError(ErrNilSocket)
@@ -122,15 +151,23 @@ func NewConnectionFromSocket(
return nil, err
}
if component.FromContext(ctx) == nil {
ctx = component.MustNew(ctx, "honeybee", "connection")
} else {
ctx = component.MustExtend(ctx, "connection")
}
// Clone config to ensure full ownership of all fields.
cc := config.Clone()
conn := &Connection{
url: nil,
dialer: nil,
socket: socket,
config: config,
logger: logger,
incoming: make(chan []byte, config.IncomingBufferSize),
config: cc,
incoming: make(chan []byte, cc.IncomingBufferSize),
heartbeat: make(chan struct{}, 1),
errors: make(chan error, config.ErrorsBufferSize),
errors: make(chan error, cc.ErrorsBufferSize),
incomingCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
heartbeatCount: &atomic.Uint64{},
@@ -138,17 +175,31 @@ func NewConnectionFromSocket(
done: make(chan struct{}),
}
if handler != nil {
comp := component.FromContext(ctx)
conn.logger = slog.New(handler).With(slog.Any("component", comp))
}
// initialize
if config.CloseHandler != nil {
socket.SetCloseHandler(config.CloseHandler)
}
conn.setupPongHandler()
conn.startPinger()
conn.startReader()
if conn.config.PingInterval > 0 {
conn.wg.Go(conn.startPinger)
}
conn.wg.Go(conn.startReader)
return conn, nil
}
// ---------------------------/
// Methods
// -------------------------/
func (c *Connection) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -161,253 +212,53 @@ func (c *Connection) Connect(ctx context.Context) error {
return NewConnectionError(ErrConnectionClosed)
}
// begin connecting
if c.logger != nil {
c.logger.Debug("connecting")
}
c.state = StateConnecting
// obtain socket
retryMgr := NewRetryManager(c.config.Retry)
socket, _, err := AcquireSocket(
ctx, retryMgr, c.dialer, c.url.String(), c.config.RequestHeader, c.logger)
if err != nil {
// socket acquisition failed
c.state = StateDisconnected
if c.logger != nil {
c.logger.Error("connection failed", "error", err)
c.logger.Warn("connection failed", "error", err)
}
return NewConnectionError(err)
}
// got socket
c.socket = socket
c.state = StateConnected
// initialize
if c.config.CloseHandler != nil {
c.socket.SetCloseHandler(c.config.CloseHandler)
}
if c.logger != nil {
c.logger.Info("connected")
}
c.setupPongHandler()
c.startPinger()
c.startReader()
if c.config.PingInterval > 0 {
c.wg.Go(c.startPinger)
}
c.wg.Go(c.startReader)
// connected
c.state = StateConnected
if c.logger != nil {
c.logger.Debug("connected")
}
return nil
}
func (c *Connection) Close() {
c.shutdownExternal()
}
func (c *Connection) shutdownExternal() {
err := c.shutdownSetClosed(true)
if err != nil {
return
}
c.shutdownInner()
c.shutdownCleanup()
}
func (c *Connection) shutdownInternal() {
err := c.shutdownSetClosed(false)
if err != nil {
return
}
c.shutdownInner()
// defer final cleanup to allow this function to return
// otherwise, a deadlock occurs where startReader triggers a shutdown and
// must wait for itself to exit.
go func() {
c.shutdownCleanup()
}()
}
func (c *Connection) shutdownInner() {
c.shutdownSignalDone()
c.shutdownLogStart()
c.shutdownCloseSocket()
}
func (c *Connection) shutdownCleanup() {
c.cleanupOnce.Do(func() {
c.wg.Wait()
c.shutdownCloseChannels()
c.shutdownLogComplete()
})
}
func (c *Connection) shutdownSetClosed(wait bool) error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return NewConnectionError(ErrConnectionClosed)
}
c.closed = true
c.state = StateClosed
c.mu.Unlock()
return nil
}
func (c *Connection) shutdownSignalDone() {
c.doneOnce.Do(func() {
close(c.done)
})
}
func (c *Connection) shutdownLogStart() {
if c.logger != nil {
c.logger.Info("closing")
}
}
func (c *Connection) shutdownCloseSocket() {
if c.socket != nil {
// force unblock of any network operations immediately
expired := time.Now().Add(-1 * time.Minute)
c.socket.SetReadDeadline(expired)
c.socket.SetWriteDeadline(expired)
// close socket
err := c.socket.Close()
if err != nil && c.logger != nil {
c.logger.Error("socket close failed", "error", err)
}
}
}
func (c *Connection) shutdownCloseChannels() {
close(c.incoming)
close(c.errors)
}
func (c *Connection) shutdownLogComplete() {
if c.logger != nil {
c.logger.Info("closed")
}
}
func (c *Connection) startReader() {
c.wg.Add(1)
go func() {
defer c.wg.Done()
defer c.shutdownInternal()
for {
select {
case <-c.done:
return
default:
messageType, data, err := c.socket.ReadMessage()
if err != nil {
var wrappedErr error
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
switch closeErr.Code {
case websocket.CloseNormalClosure, websocket.CloseGoingAway:
if c.logger != nil {
c.logger.Info("connection closed by peer",
"code", closeErr.Code,
"text", closeErr.Text,
)
}
wrappedErr = fmt.Errorf("%w: %w", ErrPeerClosedClean, err)
default:
if c.logger != nil {
c.logger.Error("unexpected close",
"code", closeErr.Code,
"text", closeErr.Text,
)
}
wrappedErr = fmt.Errorf("%w: %w", ErrPeerClosedUnexpected, err)
}
} else {
isLocalClose := false
select {
case <-c.done:
isLocalClose = true
default:
}
if c.logger != nil {
if isLocalClose {
c.logger.Debug("read loop terminated", "error", err)
} else {
c.logger.Error("read error", "error", err)
}
}
wrappedErr = fmt.Errorf("%w: %w", ErrReadError, err)
}
select {
case <-c.done:
case c.errors <- wrappedErr:
}
return
}
if messageType == websocket.TextMessage ||
messageType == websocket.BinaryMessage {
select {
case <-c.done:
return
case c.incoming <- data:
c.incomingCount.Add(1)
}
}
}
}
}()
}
func (c *Connection) setupPongHandler() {
c.socket.SetPongHandler(func(appData string) error {
select {
case c.heartbeat <- struct{}{}:
c.heartbeatCount.Add(1)
default:
}
return nil
})
}
func (c *Connection) startPinger() {
if c.config.PingInterval <= 0 {
return
}
c.wg.Add(1)
go func() {
defer c.wg.Done()
defer c.shutdownInternal()
// Calculate 10% jitter window
jitter := c.config.PingInterval / 10
for {
offset := time.Duration(rand.Int63n(int64(jitter*2))) - jitter
next := c.config.PingInterval + offset
timer := time.NewTimer(next)
select {
case <-c.done:
timer.Stop()
return
case <-timer.C:
deadline := time.Now().Add(c.config.WriteTimeout)
if err := c.socket.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
return
}
}
}
}()
}
func (c *Connection) Send(data []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
@@ -416,6 +267,7 @@ func (c *Connection) Send(data []byte) error {
return NewConnectionError(ErrConnectionClosed)
}
// setup
if c.config.WriteTimeout > 0 {
if err := c.socket.SetWriteDeadline(time.Now().Add(c.config.WriteTimeout)); err != nil {
if c.logger != nil {
@@ -425,7 +277,10 @@ func (c *Connection) Send(data []byte) error {
}
}
if err := c.socket.WriteMessage(websocket.TextMessage, data); err != nil {
// send
err := c.socket.WriteMessage(websocket.TextMessage, data)
if err != nil {
if c.logger != nil {
c.logger.Error("write error", "error", err)
}
@@ -465,6 +320,210 @@ func (c *Connection) Stats() ConnectionStats {
}
}
func (c *Connection) SetDialer(d types.Dialer) {
c.dialer = d
// ---------------------------/
// Reader loop
// -------------------------/
func (c *Connection) startReader() {
defer c.shutdownInternal()
for {
select {
case <-c.done:
return
default:
messageType, data, err := c.socket.ReadMessage()
if err != nil {
select {
case <-c.done:
case c.errors <- c.classifyCloseError(err):
}
return
}
if messageType == websocket.TextMessage ||
messageType == websocket.BinaryMessage {
select {
case <-c.done:
return
case c.incoming <- data:
c.incomingCount.Add(1)
}
}
}
}
}
func (c *Connection) classifyCloseError(err error) error {
var classifiedError error
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
switch closeErr.Code {
case websocket.CloseNormalClosure, websocket.CloseGoingAway:
if c.logger != nil {
c.logger.Debug("connection closed by peer",
"code", closeErr.Code,
"text", closeErr.Text,
)
}
classifiedError = fmt.Errorf("%w: %w", ErrPeerClosedClean, err)
default:
if c.logger != nil {
c.logger.Warn("unexpected close",
"code", closeErr.Code,
"text", closeErr.Text,
)
}
classifiedError = fmt.Errorf("%w: %w", ErrPeerClosedUnexpected, err)
}
} else {
isLocalClose := false
select {
case <-c.done:
isLocalClose = true
default:
}
if c.logger != nil {
if isLocalClose {
c.logger.Debug("read loop terminated", "error", err)
} else {
c.logger.Error("read error", "error", err)
}
}
classifiedError = fmt.Errorf("%w: %w", ErrReadError, err)
}
return classifiedError
}
// ---------------------------/
// Heartbeat Handling
// -------------------------/
func (c *Connection) setupPongHandler() {
c.socket.SetPongHandler(func(appData string) error {
select {
case c.heartbeat <- struct{}{}:
c.heartbeatCount.Add(1)
default:
}
return nil
})
}
func (c *Connection) startPinger() {
defer c.shutdownInternal()
// Calculate 10% jitter window
jitter := c.config.PingInterval / 10
for {
offset := time.Duration(rand.Int63n(int64(jitter*2))) - jitter
next := c.config.PingInterval + offset
timer := time.NewTimer(next)
select {
case <-c.done:
timer.Stop()
return
case <-timer.C:
deadline := time.Now().Add(c.config.WriteTimeout)
err := c.socket.WriteControl(websocket.PingMessage, nil, deadline)
if err != nil {
return
}
}
}
}
// ---------------------------/
// Shutdown
// -------------------------/
func (c *Connection) Close() {
c.shutdownExternal()
}
func (c *Connection) shutdownExternal() {
// set closed
c.mu.Lock()
if c.closed {
// idempotent shutdown
c.mu.Unlock()
return
}
c.closed = true
c.state = StateClosed
c.mu.Unlock()
// perform shutdown
c.shutdownInner()
c.shutdownCleanup()
}
// shutdownInternal defers final cleanup to allow it to return.
// Otherwise, a deadlock occurs where startReader triggers a shutdown and
// must wait for itself to exit.
func (c *Connection) shutdownInternal() {
// set closed
c.mu.Lock()
if c.closed {
// idempotent shutdown
c.mu.Unlock()
return
}
c.closed = true
c.state = StateClosed
c.mu.Unlock()
// perform shutdown
c.shutdownInner()
// defer cleanup to avoid deadlock
go func() {
c.shutdownCleanup()
}()
}
func (c *Connection) shutdownInner() {
c.doneOnce.Do(func() {
close(c.done)
})
if c.logger != nil {
c.logger.Debug("closing")
}
if c.socket != nil {
// force unblock of any network operations immediately
expired := time.Now().Add(-1 * time.Minute)
c.socket.SetReadDeadline(expired)
c.socket.SetWriteDeadline(expired)
// close socket
err := c.socket.Close()
if err != nil && c.logger != nil {
c.logger.Error("socket close failed", "error", err)
}
}
}
func (c *Connection) shutdownCleanup() {
c.cleanupOnce.Do(func() {
c.wg.Wait()
close(c.incoming)
close(c.errors)
if c.logger != nil {
c.logger.Debug("closed")
}
})
}
+7 -6
View File
@@ -2,6 +2,7 @@ package transport
import (
"bytes"
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"github.com/gorilla/websocket"
@@ -11,7 +12,7 @@ import (
func TestDisconnectedConnectionClose(t *testing.T) {
t.Run("close succeeds on disconnected connection", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
assert.Equal(t, StateDisconnected, conn.State())
@@ -20,7 +21,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
})
t.Run("close is idempotent", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
conn.Close()
@@ -29,7 +30,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
})
t.Run("close with nil socket", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
assert.Nil(t, conn.socket)
@@ -44,7 +45,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
return expectedErr
}
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
conn.socket = mockSocket
@@ -53,7 +54,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
})
t.Run("channels close after close", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
conn.Close()
@@ -66,7 +67,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
})
t.Run("send fails after close", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
conn.Close()
+2 -1
View File
@@ -1,6 +1,7 @@
package transport
import (
"context"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
@@ -66,7 +67,7 @@ func TestStartReader(t *testing.T) {
return 0, nil, io.EOF
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
assert.NoError(t, err)
honeybeetest.Eventually(t, func() bool {
+11 -10
View File
@@ -1,6 +1,7 @@
package transport
import (
"context"
"fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"github.com/gorilla/websocket"
@@ -62,12 +63,12 @@ func TestConnectionSend(t *testing.T) {
defer close(done)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
for i := range 5 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
data := []byte(fmt.Sprintf("msg-%d-%d", id, j))
for j := range 10 {
data := fmt.Appendf(nil, "msg-%d-%d", id, j)
for {
// send and retry until success
err := conn.Send(data)
@@ -101,7 +102,7 @@ func TestConnectionSend(t *testing.T) {
})
t.Run("write timeout disabled when zero", func(t *testing.T) {
config := &ConnectionConfig{WriteTimeout: 0}
config := &ConnectionConfig{WriteTimeout: 0, Retry: RetryConfig{Disabled: true}}
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
mockSocket := honeybeetest.NewMockSocket()
@@ -129,7 +130,7 @@ func TestConnectionSend(t *testing.T) {
return nil
}
conn, err := NewConnectionFromSocket(mockSocket, config, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, nil)
assert.NoError(t, err)
defer conn.Close()
@@ -147,7 +148,7 @@ func TestConnectionSend(t *testing.T) {
})
t.Run("write timeout sets deadline when positive", func(t *testing.T) {
config := &ConnectionConfig{WriteTimeout: 30 * time.Millisecond}
config := &ConnectionConfig{WriteTimeout: 30 * time.Millisecond, Retry: RetryConfig{Disabled: true}}
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
mockSocket := honeybeetest.NewMockSocket()
@@ -175,7 +176,7 @@ func TestConnectionSend(t *testing.T) {
return nil
}
conn, err := NewConnectionFromSocket(mockSocket, config, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, nil)
assert.NoError(t, err)
defer conn.Close()
@@ -193,7 +194,7 @@ func TestConnectionSend(t *testing.T) {
})
t.Run("send fails on deadline error", func(t *testing.T) {
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond}
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond, Retry: RetryConfig{Disabled: true}}
mockSocket := honeybeetest.NewMockSocket()
@@ -208,7 +209,7 @@ func TestConnectionSend(t *testing.T) {
return fmt.Errorf("test error")
}
conn, err := NewConnectionFromSocket(mockSocket, config, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, nil)
assert.NoError(t, err)
defer conn.Close()
@@ -228,7 +229,7 @@ func TestConnectionSend(t *testing.T) {
return writeErr
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
assert.NoError(t, err)
defer conn.Close()
+86 -76
View File
@@ -39,11 +39,11 @@ func TestConnectionStateString(t *testing.T) {
func TestConnectionState(t *testing.T) {
// Test initial state
conn, _ := NewConnection("ws://test", nil, nil)
conn, _ := NewConnection(context.Background(), "ws://test", nil, nil)
assert.Equal(t, StateDisconnected, conn.State())
// Test state after FromSocket (should be Connected)
conn2, _ := NewConnectionFromSocket(honeybeetest.NewMockSocket(), nil, nil)
conn2, _ := NewConnectionFromSocket(context.Background(), honeybeetest.NewMockSocket(), nil, nil)
assert.Equal(t, StateConnected, conn2.State())
// Test state after close
@@ -69,7 +69,7 @@ func TestNewConnection(t *testing.T) {
{
name: "valid url, valid config",
url: "wss://relay.example.com:8080/path",
config: &ConnectionConfig{WriteTimeout: 30 * time.Second},
config: &ConnectionConfig{WriteTimeout: 30 * time.Second, Retry: RetryConfig{Disabled: true}},
},
{
name: "invalid url",
@@ -82,7 +82,7 @@ func TestNewConnection(t *testing.T) {
name: "invalid config",
url: "ws://example.com",
config: &ConnectionConfig{
Retry: &RetryConfig{
Retry: RetryConfig{
InitialDelay: 10 * time.Second,
MaxDelay: 1 * time.Second,
},
@@ -94,7 +94,7 @@ func TestNewConnection(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
conn, err := NewConnection(tc.url, tc.config, nil)
conn, err := NewConnection(context.Background(), tc.url, tc.config, nil)
if tc.wantErr {
assert.Error(t, err)
@@ -121,9 +121,13 @@ func TestNewConnection(t *testing.T) {
// Verify default config is used if nil is passed
if tc.config == nil {
assert.Equal(t, GetDefaultConnectionConfig(), conn.config)
expected := *GetDefaultConnectionConfig()
expected.Dialer = conn.config.Dialer // dialer resolved at construction
assert.Equal(t, expected, conn.config)
} else {
assert.Equal(t, tc.config, conn.config)
expected := *tc.config
expected.Dialer = conn.config.Dialer
assert.Equal(t, expected, conn.config)
}
})
}
@@ -152,13 +156,13 @@ func TestNewConnectionFromSocket(t *testing.T) {
{
name: "valid socket with valid config",
socket: honeybeetest.NewMockSocket(),
config: &ConnectionConfig{WriteTimeout: 30 * time.Second},
config: &ConnectionConfig{WriteTimeout: 30 * time.Second, Retry: RetryConfig{Disabled: true}},
},
{
name: "invalid config",
socket: honeybeetest.NewMockSocket(),
config: &ConnectionConfig{
Retry: &RetryConfig{
Retry: RetryConfig{
InitialDelay: 10 * time.Second,
MaxDelay: 1 * time.Second,
},
@@ -173,6 +177,7 @@ func TestNewConnectionFromSocket(t *testing.T) {
CloseHandler: func(code int, text string) error {
return nil
},
Retry: RetryConfig{Disabled: true},
},
},
}
@@ -194,7 +199,7 @@ func TestNewConnectionFromSocket(t *testing.T) {
}
}
conn, err := NewConnectionFromSocket(tc.socket, tc.config, nil)
conn, err := NewConnectionFromSocket(context.Background(), tc.socket, tc.config, nil)
if tc.wantErr {
assert.Error(t, err)
@@ -219,11 +224,19 @@ func TestNewConnectionFromSocket(t *testing.T) {
assert.Equal(t, StateConnected, conn.state)
assert.False(t, conn.closed)
// Verify default config is used if nil is passed
// Verify default config is used if nil is passed.
// CloseHandler is a func; exclude it from the struct comparison
// (identity is verified separately via closeHandlerSet).
gotCfg := conn.config
gotCfg.CloseHandler = nil
if tc.config == nil {
assert.Equal(t, GetDefaultConnectionConfig(), conn.config)
expected := *GetDefaultConnectionConfig()
expected.CloseHandler = nil
assert.Equal(t, expected, gotCfg)
} else {
assert.Equal(t, tc.config, conn.config)
expected := *tc.config
expected.CloseHandler = nil
assert.Equal(t, expected, gotCfg)
}
// Verify close handler was set if provided
@@ -236,7 +249,7 @@ func TestNewConnectionFromSocket(t *testing.T) {
func TestConnect(t *testing.T) {
t.Run("connect fails when socket already present", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
conn.socket = honeybeetest.NewMockSocket()
@@ -248,7 +261,7 @@ func TestConnect(t *testing.T) {
})
t.Run("connect fails when connection closed", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
conn.Close()
@@ -260,9 +273,6 @@ func TestConnect(t *testing.T) {
})
t.Run("connect succeeds and starts goroutines", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
assert.NoError(t, err)
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
mockSocket := honeybeetest.NewMockSocket()
@@ -276,7 +286,9 @@ func TestConnect(t *testing.T) {
return mockSocket, nil, nil
},
}
conn.dialer = mockDialer
conn, err := NewConnection(context.Background(), "ws://test",
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, nil)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.NoError(t, err)
@@ -298,17 +310,6 @@ func TestConnect(t *testing.T) {
})
t.Run("connect retries on dial failure", func(t *testing.T) {
config := &ConnectionConfig{
Retry: &RetryConfig{
MaxRetries: 2,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
}
conn, err := NewConnection("ws://test", config, nil)
assert.NoError(t, err)
attemptCount := 0
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
@@ -319,7 +320,17 @@ func TestConnect(t *testing.T) {
return honeybeetest.NewMockSocket(), nil, nil
},
}
conn.dialer = mockDialer
config := &ConnectionConfig{
Retry: RetryConfig{
MaxRetries: 2,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
Dialer: mockDialer,
}
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.NoError(t, err)
@@ -330,23 +341,22 @@ func TestConnect(t *testing.T) {
})
t.Run("connect fails after max retries", func(t *testing.T) {
config := &ConnectionConfig{
Retry: &RetryConfig{
MaxRetries: 2,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
}
conn, err := NewConnection("ws://test", config, nil)
assert.NoError(t, err)
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return nil, nil, fmt.Errorf("dial failed")
},
}
conn.dialer = mockDialer
config := &ConnectionConfig{
Retry: RetryConfig{
MaxRetries: 2,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
Dialer: mockDialer,
}
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.Error(t, err)
@@ -355,18 +365,20 @@ func TestConnect(t *testing.T) {
})
t.Run("state transitions during connect", func(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
assert.NoError(t, err)
assert.Equal(t, StateDisconnected, conn.State())
stateDuringDial := StateDisconnected
// conn captured after construction; closure safe because dialer runs during Connect
var conn *Connection
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
stateDuringDial = conn.state
return honeybeetest.NewMockSocket(), nil, nil
},
}
conn.dialer = mockDialer
var err error
conn, err = NewConnection(context.Background(), "ws://test",
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, nil)
assert.NoError(t, err)
assert.Equal(t, StateDisconnected, conn.State())
conn.Connect(context.Background())
@@ -378,25 +390,24 @@ func TestConnect(t *testing.T) {
t.Run("close handler configured when provided", func(t *testing.T) {
handlerSet := false
config := &ConnectionConfig{
CloseHandler: func(code int, text string) error {
return nil
},
}
conn, err := NewConnection("ws://test", config, nil)
assert.NoError(t, err)
mockSocket := honeybeetest.NewMockSocket()
mockSocket.SetCloseHandlerFunc = func(h func(int, string) error) {
handlerSet = true
}
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return mockSocket, nil, nil
},
}
conn.dialer = mockDialer
config := &ConnectionConfig{
CloseHandler: func(code int, text string) error {
return nil
},
Retry: RetryConfig{Disabled: true},
Dialer: mockDialer,
}
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
assert.NoError(t, err)
conn.Connect(context.Background())
@@ -407,17 +418,16 @@ func TestConnect(t *testing.T) {
t.Run("passes headers when configured", func(t *testing.T) {
header := http.Header{"X-Custom": []string{"val"}}
conf, _ := NewConnectionConfig(WithRequestHeader(header))
conn, _ := NewConnection("ws://test", conf, nil)
dialCalled := false
conn.dialer = &honeybeetest.MockDialer{
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(ctx context.Context, url string, h http.Header) (types.Socket, *http.Response, error) {
assert.Equal(t, "val", h.Get("X-Custom"))
dialCalled = true
return honeybeetest.NewMockSocket(), nil, nil
},
}
conf, _ := NewConnectionConfig(WithRequestHeader(header), WithConnectionDialer(mockDialer))
conn, _ := NewConnection(context.Background(), "ws://test", conf, nil)
err := conn.Connect(context.Background())
@@ -429,25 +439,25 @@ func TestConnect(t *testing.T) {
func TestConnectContextCancellation(t *testing.T) {
t.Run("context cancelled during connect returns before retries exhaust", func(t *testing.T) {
config := &ConnectionConfig{
Retry: &RetryConfig{
Retry: RetryConfig{
MaxRetries: 100,
InitialDelay: 500 * time.Millisecond,
MaxDelay: 1 * time.Second,
JitterFactor: 0.0,
},
}
conn, err := NewConnection("ws://test", config, nil)
assert.NoError(t, err)
dialCount := atomic.Int32{}
ctx, cancel := context.WithCancel(context.Background())
conn.dialer = &honeybeetest.MockDialer{
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(ctx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
dialCount.Add(1)
return nil, nil, fmt.Errorf("dial failed")
},
}
config.Dialer = mockDialer
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
assert.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
@@ -475,7 +485,7 @@ func TestConnectContextCancellation(t *testing.T) {
// Connection method tests
func TestConnectionIncoming(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil)
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
assert.NoError(t, err)
incoming := conn.Incoming()
@@ -498,7 +508,7 @@ func TestConnectionErrors(t *testing.T) {
}
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
assert.NoError(t, err)
defer conn.Close()
@@ -521,7 +531,7 @@ func TestConnectionErrors(t *testing.T) {
}
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
assert.NoError(t, err)
defer conn.Close()
@@ -541,7 +551,7 @@ func TestConnectionErrors(t *testing.T) {
return 0, nil, io.EOF
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
assert.NoError(t, err)
defer conn.Close()
@@ -573,7 +583,7 @@ func TestConnectionHeartbeat(t *testing.T) {
)
assert.NoError(t, err)
conn, _ := NewConnectionFromSocket(socket, conf, nil)
conn, _ := NewConnectionFromSocket(context.Background(), socket, conf, nil)
defer conn.Close()
honeybeetest.Eventually(t,
@@ -586,7 +596,7 @@ func TestConnectionHeartbeat(t *testing.T) {
socket, _, _ := honeybeetest.SetupTestSocket(t)
socket.SetPongHandlerFunc = func(h func(string) error) { handler = h }
conn, _ := NewConnectionFromSocket(socket, nil, nil)
conn, _ := NewConnectionFromSocket(context.Background(), socket, nil, nil)
defer conn.Close()
honeybeetest.Eventually(t, func() bool {
@@ -620,7 +630,7 @@ func setupTestConnection(t *testing.T) (
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
var err error
conn, err = NewConnectionFromSocket(socket, nil, nil)
conn, err = NewConnectionFromSocket(context.Background(), socket, nil, nil)
assert.NoError(t, err)
return
}
+51 -64
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"testing"
"time"
// slog used for ExpectedLog level constants
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/types"
@@ -26,10 +27,6 @@ func log(level slog.Level, msg string, attrs map[string]any) honeybeetest.Expect
func TestConnectLogging(t *testing.T) {
t.Run("success", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
conn, err := NewConnection("ws://test", nil, logger)
assert.NoError(t, err)
mockSocket := honeybeetest.NewMockSocket()
mockDialer := &honeybeetest.MockDialer{
@@ -37,7 +34,9 @@ func TestConnectLogging(t *testing.T) {
return mockSocket, nil, nil
},
}
conn.dialer = mockDialer
conn, err := NewConnection(context.Background(), "ws://test",
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, mockHandler)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.NoError(t, err)
@@ -49,7 +48,7 @@ func TestConnectLogging(t *testing.T) {
log(slog.LevelDebug, "connecting", map[string]any{}),
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 1}),
log(slog.LevelDebug, "dial successful", map[string]any{"attempt": 1}),
log(slog.LevelInfo, "connected", map[string]any{}),
log(slog.LevelDebug, "connected", map[string]any{}),
}
honeybeetest.AssertLogSequence(t, records, expected)
@@ -57,19 +56,6 @@ func TestConnectLogging(t *testing.T) {
t.Run("max retries failure", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
config := &ConnectionConfig{
Retry: &RetryConfig{
MaxRetries: 2,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
}
conn, err := NewConnection("ws://test", config, logger)
assert.NoError(t, err)
dialErr := fmt.Errorf("dial error")
mockDialer := &honeybeetest.MockDialer{
@@ -77,7 +63,18 @@ func TestConnectLogging(t *testing.T) {
return nil, nil, dialErr
},
}
conn.dialer = mockDialer
config := &ConnectionConfig{
Retry: RetryConfig{
MaxRetries: 2,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
Dialer: mockDialer,
}
conn, err := NewConnection(context.Background(), "ws://test", config, mockHandler)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.Error(t, err)
@@ -91,8 +88,8 @@ func TestConnectLogging(t *testing.T) {
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 2}),
log(slog.LevelWarn, "dial failed, retrying", map[string]any{"attempt": 2, "error": dialErr}),
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 3}),
log(slog.LevelError, "dial failed, max retries reached", map[string]any{"attempt": 3, "error": dialErr}),
log(slog.LevelError, "connection failed", map[string]any{"error": dialErr}),
log(slog.LevelDebug, "dial failed, max retries reached", map[string]any{"attempt": 3, "error": dialErr}),
log(slog.LevelWarn, "connection failed", map[string]any{"error": dialErr}),
}
honeybeetest.AssertLogSequence(t, records, expected)
@@ -100,19 +97,6 @@ func TestConnectLogging(t *testing.T) {
t.Run("success after retry", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
config := &ConnectionConfig{
Retry: &RetryConfig{
MaxRetries: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
}
conn, err := NewConnection("ws://test", config, logger)
assert.NoError(t, err)
attemptCount := 0
dialErr := fmt.Errorf("dial error")
@@ -125,7 +109,18 @@ func TestConnectLogging(t *testing.T) {
return honeybeetest.NewMockSocket(), nil, nil
},
}
conn.dialer = mockDialer
config := &ConnectionConfig{
Retry: RetryConfig{
MaxRetries: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
JitterFactor: 0.0,
},
Dialer: mockDialer,
}
conn, err := NewConnection(context.Background(), "ws://test", config, mockHandler)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.NoError(t, err)
@@ -141,7 +136,7 @@ func TestConnectLogging(t *testing.T) {
log(slog.LevelWarn, "dial failed, retrying", map[string]any{"attempt": 2, "error": dialErr}),
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 3}),
log(slog.LevelDebug, "dial successful", map[string]any{"attempt": 3}),
log(slog.LevelInfo, "connected", map[string]any{}),
log(slog.LevelDebug, "connected", map[string]any{}),
}
honeybeetest.AssertLogSequence(t, records, expected)
@@ -151,24 +146,23 @@ func TestConnectLogging(t *testing.T) {
func TestCloseLogging(t *testing.T) {
t.Run("normal close", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
mockSocket := honeybeetest.NewMockSocket()
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
assert.NoError(t, err)
conn.Close()
honeybeetest.Eventually(t, func() bool {
return honeybeetest.FindLogRecord(
mockHandler.GetRecords(), slog.LevelInfo, "closed") != nil
mockHandler.GetRecords(), slog.LevelDebug, "closed") != nil
}, "expected log")
records := mockHandler.GetRecords()
expected := []honeybeetest.ExpectedLog{
log(slog.LevelInfo, "closing", map[string]any{}),
log(slog.LevelInfo, "closed", map[string]any{}),
log(slog.LevelDebug, "closing", map[string]any{}),
log(slog.LevelDebug, "closed", map[string]any{}),
}
honeybeetest.AssertLogSequence(t, records, expected)
@@ -176,7 +170,6 @@ func TestCloseLogging(t *testing.T) {
t.Run("close with socket error", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
closeErr := fmt.Errorf("close error")
mockSocket := honeybeetest.NewMockSocket()
@@ -184,7 +177,7 @@ func TestCloseLogging(t *testing.T) {
return closeErr
}
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
assert.NoError(t, err)
conn.Close()
@@ -197,7 +190,7 @@ func TestCloseLogging(t *testing.T) {
records := mockHandler.GetRecords()
expected := []honeybeetest.ExpectedLog{
log(slog.LevelInfo, "closing", map[string]any{}),
log(slog.LevelDebug, "closing", map[string]any{}),
log(slog.LevelError, "socket close failed", map[string]any{"error": closeErr}),
}
@@ -208,7 +201,6 @@ func TestCloseLogging(t *testing.T) {
func TestReaderLogging(t *testing.T) {
t.Run("clean close by peer", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
@@ -218,16 +210,16 @@ func TestReaderLogging(t *testing.T) {
}
}
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
assert.NoError(t, err)
defer conn.Close()
honeybeetest.Eventually(t, func() bool {
return honeybeetest.FindLogRecord(
mockHandler.GetRecords(), slog.LevelInfo, "connection closed by peer") != nil
mockHandler.GetRecords(), slog.LevelDebug, "connection closed by peer") != nil
}, "expected log")
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelInfo, "connection closed by peer")
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelDebug, "connection closed by peer")
assert.NotNil(t, record)
honeybeetest.AssertAttributePresent(t, *record, "code", websocket.CloseNormalClosure)
honeybeetest.AssertAttributePresent(t, *record, "text", "goodbye")
@@ -236,7 +228,6 @@ func TestReaderLogging(t *testing.T) {
t.Run("unexpected close", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
@@ -246,16 +237,16 @@ func TestReaderLogging(t *testing.T) {
}
}
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
assert.NoError(t, err)
defer conn.Close()
honeybeetest.Eventually(t, func() bool {
return honeybeetest.FindLogRecord(
mockHandler.GetRecords(), slog.LevelError, "unexpected close") != nil
mockHandler.GetRecords(), slog.LevelWarn, "unexpected close") != nil
}, "expected log")
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelError, "unexpected close")
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelWarn, "unexpected close")
assert.NotNil(t, record)
honeybeetest.AssertAttributePresent(t, *record, "code", websocket.CloseProtocolError)
honeybeetest.AssertAttributePresent(t, *record, "text", "bad protocol")
@@ -264,14 +255,13 @@ func TestReaderLogging(t *testing.T) {
t.Run("read error", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
return 0, nil, io.EOF
}
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
assert.NoError(t, err)
defer conn.Close()
@@ -285,9 +275,8 @@ func TestReaderLogging(t *testing.T) {
func TestWriterLogging(t *testing.T) {
t.Run("write deadline error", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond}
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond, Retry: RetryConfig{Disabled: true}}
deadlineErr := fmt.Errorf("deadline error")
mockSocket := honeybeetest.NewMockSocket()
@@ -295,7 +284,7 @@ func TestWriterLogging(t *testing.T) {
return deadlineErr
}
conn, err := NewConnectionFromSocket(mockSocket, config, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, mockHandler)
assert.NoError(t, err)
err = conn.Send([]byte("test"))
@@ -317,7 +306,6 @@ func TestWriterLogging(t *testing.T) {
t.Run("write message error", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
logger := slog.New(mockHandler)
writeErr := fmt.Errorf("write error")
mockSocket := honeybeetest.NewMockSocket()
@@ -325,7 +313,7 @@ func TestWriterLogging(t *testing.T) {
return writeErr
}
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
assert.NoError(t, err)
err = conn.Send([]byte("test"))
@@ -350,16 +338,15 @@ func TestLoggingDisabled(t *testing.T) {
t.Run("nil logger produces no logs", func(t *testing.T) {
mockHandler := honeybeetest.NewMockSlogHandler()
conn, err := NewConnection("ws://test", nil, nil)
assert.NoError(t, err)
mockSocket := honeybeetest.NewMockSocket()
mockDialer := &honeybeetest.MockDialer{
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
return mockSocket, nil, nil
},
}
conn.dialer = mockDialer
conn, err := NewConnection(context.Background(), "ws://test",
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, nil)
assert.NoError(t, err)
err = conn.Connect(context.Background())
assert.NoError(t, err)
+10 -16
View File
@@ -7,16 +7,16 @@ import (
)
type RetryManager struct {
config *RetryConfig
config RetryConfig
retryCount int
saturation int
}
func NewRetryManager(config *RetryConfig) *RetryManager {
func NewRetryManager(config RetryConfig) *RetryManager {
// saturationCount: retry count at which base delay meets or exceeds MaxDelay.
// Conservative by two to preserve jitter variance near the boundary.
saturation := 0
if config != nil &&
if !config.Disabled &&
config.InitialDelay > 0 &&
config.InitialDelay <= config.MaxDelay {
ratio := float64(config.MaxDelay) / float64(config.InitialDelay)
@@ -31,7 +31,7 @@ func NewRetryManager(config *RetryConfig) *RetryManager {
}
func (r *RetryManager) ShouldRetry() bool {
if r.config == nil {
if r.config.Disabled {
return false
}
@@ -43,7 +43,7 @@ func (r *RetryManager) ShouldRetry() bool {
}
func (r *RetryManager) CalculateDelay() time.Duration {
if r.config == nil {
if r.config.Disabled {
return time.Second
}
@@ -54,27 +54,21 @@ func (r *RetryManager) CalculateDelay() time.Duration {
// if saturation is reached, calculated backoff will always be higher than
// the maximum delay
if r.config != nil && r.retryCount >= r.saturation {
if r.retryCount >= r.saturation {
return r.config.MaxDelay
}
// Exponential backoff: InitialDelay * 2^(attempts-1)
shift := r.retryCount - 1
if shift > 62 {
shift = 62
} // prevent overflow
shift := min(r.retryCount-1, 62) // prevent overflow
backoffMultiplier := float64(int64(1) << shift)
baseDelay := float64(r.config.InitialDelay) * backoffMultiplier
// Apply jitter: delay * (1 + jitterFactor * (random - 0.5))
random := rand.Float64()
jitterMultiplier := 1 + r.config.JitterFactor*(random-0.5)
delay := time.Duration(baseDelay * jitterMultiplier)
// Cap at MaxDelay
if delay > r.config.MaxDelay {
delay = r.config.MaxDelay
}
delay := min(
// Cap at MaxDelay
time.Duration(baseDelay*jitterMultiplier), r.config.MaxDelay)
return delay
}
+13 -13
View File
@@ -7,7 +7,7 @@ import (
)
func TestNewRetryManager(t *testing.T) {
config := &RetryConfig{
config := RetryConfig{
MaxRetries: 0,
}
@@ -16,14 +16,14 @@ func TestNewRetryManager(t *testing.T) {
assert.Equal(t, config, mgr.config)
assert.Equal(t, 0, mgr.retryCount)
// Should accept nil config
mgr = NewRetryManager(nil)
assert.Nil(t, mgr.config)
// Should accept a disabled config
mgr = NewRetryManager(RetryConfig{Disabled: true})
assert.True(t, mgr.config.Disabled)
assert.Equal(t, 0, mgr.retryCount)
}
func TestRecordRetry(t *testing.T) {
mgr := NewRetryManager(nil)
mgr := NewRetryManager(RetryConfig{Disabled: true})
assert.Equal(t, mgr.retryCount, 0)
mgr.RecordRetry()
@@ -34,13 +34,13 @@ func TestRecordRetry(t *testing.T) {
}
func TestShouldRetry(t *testing.T) {
// never retry if config is nil
mgr := NewRetryManager(nil)
// never retry if config is disabled
mgr := NewRetryManager(RetryConfig{Disabled: true})
assert.False(t, mgr.ShouldRetry())
// always retry if max attempt count is zero
mgr = &RetryManager{
config: &RetryConfig{
config: RetryConfig{
MaxRetries: 0,
},
retryCount: 1000,
@@ -49,7 +49,7 @@ func TestShouldRetry(t *testing.T) {
// retry if below max attempt count
mgr = &RetryManager{
config: &RetryConfig{
config: RetryConfig{
MaxRetries: 10,
},
retryCount: 5,
@@ -58,7 +58,7 @@ func TestShouldRetry(t *testing.T) {
// do not retry if above max attempt count
mgr = &RetryManager{
config: &RetryConfig{
config: RetryConfig{
MaxRetries: 10,
},
retryCount: 11,
@@ -68,12 +68,12 @@ func TestShouldRetry(t *testing.T) {
func TestCalculateDelayDisabled(t *testing.T) {
// default delay if retry is disabled
mgr := NewRetryManager(nil)
mgr := NewRetryManager(RetryConfig{Disabled: true})
assert.Equal(t, time.Second, mgr.CalculateDelay())
}
func TestCalculateDelayWithoutJitter(t *testing.T) {
mgr := NewRetryManager(&RetryConfig{
mgr := NewRetryManager(RetryConfig{
MaxRetries: 0,
InitialDelay: 1 * time.Second,
MaxDelay: 5 * time.Second,
@@ -105,7 +105,7 @@ func TestCalculateDelayWithoutJitter(t *testing.T) {
}
func TestCalculateDelayWithJitter(t *testing.T) {
mgr := NewRetryManager(&RetryConfig{
mgr := NewRetryManager(RetryConfig{
MaxRetries: 0,
InitialDelay: 1 * time.Second,
MaxDelay: 5 * time.Second,
+5 -1
View File
@@ -69,6 +69,7 @@ func AcquireSocket(
logger.Debug("dialing", "attempt", retryMgr.RetryCount()+1)
}
// dial
socket, resp, err := dialer.DialContext(ctx, url, header)
if err == nil {
if logger != nil {
@@ -77,9 +78,11 @@ func AcquireSocket(
return socket, resp, nil
}
// dial failed, retry
if !retryMgr.ShouldRetry() {
// retry policy expired
if logger != nil {
logger.Error("dial failed, max retries reached",
logger.Debug("dial failed, max retries reached",
"error", err,
"attempt", retryMgr.RetryCount()+1)
}
@@ -95,6 +98,7 @@ func AcquireSocket(
"next_delay", delay)
}
// context cancellable backoff
select {
case <-time.After(delay):
case <-ctx.Done():
+7 -6
View File
@@ -77,7 +77,7 @@ func TestAcquireSocket(t *testing.T) {
},
}
retryMgr := NewRetryManager(&RetryConfig{
retryMgr := NewRetryManager(RetryConfig{
MaxRetries: tc.maxRetries,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 5 * time.Millisecond,
@@ -106,7 +106,8 @@ func TestAcquireSocketGuards(t *testing.T) {
return honeybeetest.NewMockSocket(), nil, nil
},
}
validRetryMgr := NewRetryManager(GetDefaultRetryConfig())
validRetryConfig := GetDefaultConnectionConfig().Retry
validRetryMgr := NewRetryManager(validRetryConfig)
cases := []struct {
name string
@@ -167,7 +168,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
// cancel before acquiring socket
cancel()
retryMgr := NewRetryManager(GetDefaultRetryConfig())
retryMgr := NewRetryManager(GetDefaultConnectionConfig().Retry)
_, _, err := AcquireSocket(ctx, retryMgr, mockDialer, "ws://test", nil, nil)
assert.ErrorIs(t, err, context.Canceled)
@@ -186,7 +187,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
retryMgr := NewRetryManager(&RetryConfig{
retryMgr := NewRetryManager(RetryConfig{
MaxRetries: 10,
InitialDelay: 1 * time.Second,
MaxDelay: 1 * time.Second,
@@ -230,7 +231,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
retryMgr := NewRetryManager(GetDefaultRetryConfig())
retryMgr := NewRetryManager(GetDefaultConnectionConfig().Retry)
done := make(chan error, 1)
go func() {
_, _, err := AcquireSocket(ctx, retryMgr, mockDialer, "ws://test", nil, nil)
@@ -263,7 +264,7 @@ func TestAcquireSocketPassesHeaders(t *testing.T) {
},
}
retryMgr := NewRetryManager(&RetryConfig{MaxRetries: 0})
retryMgr := NewRetryManager(RetryConfig{MaxRetries: 0, InitialDelay: 1 * time.Millisecond, MaxDelay: 5 * time.Millisecond})
_, _, err := AcquireSocket(context.Background(), retryMgr, mockDialer, "ws://test", header, nil)
assert.NoError(t, err)
+45
View File
@@ -0,0 +1,45 @@
package transport
import (
"context"
"time"
)
func IdleWatchdog(
ctx context.Context,
activity <-chan struct{},
timeout time.Duration,
onTimeout func(),
) {
// disable watchdog timeout if not configured
if timeout <= 0 {
for {
select {
case <-activity:
case <-ctx.Done():
return
}
}
}
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-activity:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(timeout)
case <-timer.C:
onTimeout()
return
}
}
}
@@ -1,4 +1,4 @@
package inbound
package transport
import (
"context"
@@ -9,38 +9,39 @@ import (
"time"
)
func TestRunWatchdog(t *testing.T) {
t.Run("heartbeat resets timer, onInactive not called", func(t *testing.T) {
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
func TestIdleWatchdog(t *testing.T) {
t.Run("heartbeat resets timer, onTimeout not called", func(t *testing.T) {
activity := make(chan struct{})
ctx := t.Context()
called := atomic.Bool{}
go RunWatchdog(ctx, func(WorkerExitKind) { called.Store(true) }, heartbeat, 200*time.Millisecond, nil)
go IdleWatchdog(
ctx, activity, 200*time.Millisecond, func() { called.Store(true) },
)
for i := 0; i < 5; i++ {
for range 5 {
time.Sleep(20 * time.Millisecond)
heartbeat <- struct{}{}
activity <- struct{}{}
}
honeybeetest.Never(t, func() bool {
return called.Load()
}, "unexpected onInactive call")
}, "unexpected onTimeout call")
})
t.Run("timeout fires onInactive exactly once", func(t *testing.T) {
heartbeat := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
t.Run("timeout fires onTimeout exactly once", func(t *testing.T) {
activity := make(chan struct{})
ctx := t.Context()
var gotKind WorkerExitKind
count := atomic.Int32{}
done := make(chan struct{})
go RunWatchdog(ctx, func(kind WorkerExitKind) {
count.Add(1)
gotKind = kind
close(done)
}, heartbeat, 20*time.Millisecond, nil)
go IdleWatchdog(
ctx, activity, 20*time.Millisecond, func() {
// will panic on second close
count.Add(1)
close(done)
},
)
honeybeetest.Eventually(t, func() bool {
select {
@@ -49,20 +50,21 @@ func TestRunWatchdog(t *testing.T) {
default:
return false
}
}, "expected onInactive")
}, "expected onTimeout")
assert.Equal(t, int32(1), count.Load())
assert.Equal(t, ExitPolicy, gotKind)
assert.Equal(t, 1, int(count.Load()))
})
t.Run("ctx.Done exits without calling onInactive", func(t *testing.T) {
heartbeat := make(chan struct{})
t.Run("ctx.Done exits without calling onTimeout", func(t *testing.T) {
activity := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
called := atomic.Bool{}
done := make(chan struct{})
go func() {
RunWatchdog(ctx, func(WorkerExitKind) { called.Store(true) }, heartbeat, 20*time.Second, nil)
IdleWatchdog(
ctx, activity, 20*time.Second, func() { called.Store(true) },
)
close(done)
}()
@@ -80,14 +82,16 @@ func TestRunWatchdog(t *testing.T) {
assert.False(t, called.Load())
})
t.Run("zero timeout exits on ctx.Done without firing onInactive", func(t *testing.T) {
heartbeat := make(chan struct{})
t.Run("zero timeout exits on ctx.Done without firing onTimeout", func(t *testing.T) {
activity := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
called := atomic.Bool{}
done := make(chan struct{})
go func() {
RunWatchdog(ctx, func(WorkerExitKind) { called.Store(true) }, heartbeat, 0, nil)
IdleWatchdog(
ctx, activity, 0, func() { called.Store(true) },
)
close(done)
}()
@@ -105,20 +109,20 @@ func TestRunWatchdog(t *testing.T) {
assert.False(t, called.Load())
})
t.Run("disabled keepalive drains heartbeats without blocking", func(t *testing.T) {
heartbeat := make(chan struct{})
t.Run("zero timeout drains activity without blocking", func(t *testing.T) {
activity := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
RunWatchdog(ctx, func(WorkerExitKind) {}, heartbeat, 0, nil)
IdleWatchdog(ctx, activity, 0, func() {})
close(done)
}()
// these must not block
for i := 0; i < 5; i++ {
heartbeat <- struct{}{}
for range 5 {
activity <- struct{}{}
}
cancel()
+401
View File
@@ -0,0 +1,401 @@
package honeybee
import (
"context"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
"git.wisehodl.dev/jay/go-honeybee/transport"
"git.wisehodl.dev/jay/go-honeybee/types"
"git.wisehodl.dev/jay/go-mana-component"
)
// ----------------------------------------------------------------------------
// Worker
// ----------------------------------------------------------------------------
// ---------------------------/
// Types
// -------------------------/
type WorkerFactory func(
ctx context.Context,
id string,
handler slog.Handler,
) (Worker, error)
type Worker interface {
Start(pool PoolPlugin)
Stop()
Send(data []byte) error
Stats() WorkerStats
}
type WorkerStats struct {
IncomingAvailable bool
ChanIncoming int
ConnectionAvailable bool
Connection transport.ConnectionStats
TotalProcessed uint64
TotalSent uint64
TotalRestarts uint64
}
type DefaultWorker struct {
id string
conn atomic.Pointer[transport.Connection]
sendHeartbeat chan struct{}
ctx context.Context
cancel context.CancelFunc
config *WorkerConfig
handler slog.Handler
logger *slog.Logger
processedCount *atomic.Uint64
outgoingCount *atomic.Uint64
restartCount *atomic.Uint64
}
// ---------------------------/
// Constructor
// -------------------------/
func NewWorker(
ctx context.Context,
id string,
config *WorkerConfig,
handler slog.Handler,
) (*DefaultWorker, error) {
if config == nil {
config = GetDefaultWorkerConfig()
}
if err := ValidateWorkerConfig(config); err != nil {
return nil, err
}
if component.FromContext(ctx) == nil {
ctx = component.MustNew(ctx, "honeybee", "worker")
} else {
ctx = component.MustExtend(ctx, "worker")
}
ctx, cancel := context.WithCancel(ctx)
w := &DefaultWorker{
id: id,
sendHeartbeat: make(chan struct{}),
ctx: ctx,
cancel: cancel,
config: config,
processedCount: &atomic.Uint64{},
outgoingCount: &atomic.Uint64{},
restartCount: &atomic.Uint64{},
}
if handler != nil {
comp := component.FromContext(ctx)
w.handler = handler.WithAttrs([]slog.Attr{slog.String("peer", id)})
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)
})
if w.logger != nil {
w.logger.Debug("started")
}
wg.Wait()
if w.logger != nil {
w.logger.Debug("stopped")
}
}
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()
}
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()
}
}
// setup new connection
w.conn.Store(conn)
pool.Events <- PoolEvent{ID: w.id, Kind: EventConnected, At: time.Now()}
if w.logger != nil {
w.logger.Debug("session: started")
}
// run session loop
conn_loop:
for {
select {
case <-ctx.Done():
break conn_loop
case data, ok := <-conn.Incoming():
if !ok {
var reason error
select {
case reason = <-conn.Errors():
default:
reason = fmt.Errorf("unknown")
}
if w.logger != nil {
w.logger.Info("websocket: closed", "reason", reason)
}
break conn_loop
}
pool.Inbox <- types.InboxMessage{
ID: w.id, Data: data, ReceivedAt: time.Now()}
pool.InboxCounter.Add(1)
w.processedCount.Add(1)
heartbeat()
case <-conn.Heartbeat():
heartbeat()
case <-w.sendHeartbeat:
heartbeat()
case <-inactive():
if w.logger != nil {
w.logger.Warn("keepalive: no activity observed")
}
timer.Reset(w.config.KeepaliveTimeout)
break conn_loop
}
}
// session ended
conn.Close()
if w.logger != nil {
w.logger.Info("disconnected")
}
if w.logger != nil {
w.logger.Debug("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) setupHeartbeat() (
timer *time.Timer, inactive func() <-chan time.Time, heartbeat 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)
} else {
if w.logger != nil {
w.logger.Debug("keepalive: disabled")
}
}
heartbeat = func() {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(w.config.KeepaliveTimeout)
}
inactive = func() <-chan time.Time {
if timer == nil {
return nil
}
return timer.C
}
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")
}
go func() {
conn, err := connect(w.id, dialCtx, pool, w.handler)
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,
) (*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)
}
// ---------------------------/
// Methods
// -------------------------/
func (w *DefaultWorker) Stop() {
if w.logger != nil {
w.logger.Info("shutting down")
}
w.cancel()
}
func (w *DefaultWorker) Send(data []byte) error {
conn := w.conn.Load()
if conn == nil {
// connection not established by session
return NewWorkerError(w.id, ErrConnectionUnavailable)
}
err := conn.Send(data)
if err != nil {
return NewWorkerError(w.id, err)
}
select {
case w.sendHeartbeat <- struct{}{}:
case <-w.ctx.Done():
}
w.outgoingCount.Add(1)
return nil
}
func (w *DefaultWorker) Stats() WorkerStats {
connectionAvailable := false
incomingLen := 0
connStats := transport.ConnectionStats{}
conn := w.conn.Load()
if conn != nil {
connectionAvailable = true
incomingLen = len(conn.Incoming())
connStats = conn.Stats()
}
return WorkerStats{
IncomingAvailable: connectionAvailable,
ChanIncoming: incomingLen,
ConnectionAvailable: connectionAvailable,
Connection: connStats,
TotalProcessed: w.processedCount.Load(),
TotalRestarts: w.restartCount.Load(),
TotalSent: w.outgoingCount.Load(),
}
}
+749
View File
@@ -0,0 +1,749 @@
package honeybee
import (
"context"
"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"
"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{},
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
}
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,
sendHeartbeat: 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.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.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)
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")
},
}
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,
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()
},
}
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.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")
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.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")
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,
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)
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,
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)
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,
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)
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.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")
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.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)
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.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 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")
})
}
func TestWorkerSend(t *testing.T) {
t.Run("data sent to mock socket", func(t *testing.T) {
conn, _, _, outgoingData := setupTestConnection(t)
defer conn.Close()
ctx, cancel := context.WithCancel(context.Background())
heartbeat := make(chan struct{})
heartbeatCount := atomic.Int32{}
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
sendHeartbeat: heartbeat,
outgoingCount: &atomic.Uint64{},
}
w.conn.Store(conn)
defer w.cancel()
go func() {
for range heartbeat {
heartbeatCount.Add(1)
}
}()
testData := []byte("hello")
err := w.Send(testData)
assert.NoError(t, err)
// at least one heartbeat was sent
honeybeetest.Eventually(t, func() bool {
return heartbeatCount.Load() >= 1
}, "expected heartbeats")
// message was sent by the socket
honeybeetest.Eventually(t, func() bool {
select {
case msg := <-outgoingData:
return string(msg.Data) == "hello"
default:
return false
}
}, "expected message")
})
t.Run("sends one heartbeat per successful send", func(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
defer conn.Close()
ctx, cancel := context.WithCancel(context.Background())
heartbeat := make(chan struct{})
heartbeatCount := atomic.Int32{}
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
sendHeartbeat: heartbeat,
outgoingCount: &atomic.Uint64{},
}
w.conn.Store(conn)
defer w.cancel()
go func() {
for range heartbeat {
heartbeatCount.Add(1)
}
}()
const count = 3
for i := range count {
err := w.Send(fmt.Appendf(nil, "msg-%d", i))
assert.NoError(t, err)
}
honeybeetest.Eventually(t, func() bool {
return heartbeatCount.Load() == count
}, "expected heartbeats")
})
t.Run("returns error if connection is unavailable", func(t *testing.T) {
// no connection available to worker
ctx, cancel := context.WithCancel(context.Background())
heartbeat := make(chan struct{})
w := &DefaultWorker{
ctx: ctx,
cancel: cancel,
id: "wss://test",
sendHeartbeat: heartbeat,
}
defer w.cancel()
go func() {
for range heartbeat {
}
}()
err := w.Send([]byte("hello"))
assert.ErrorIs(t, err, ErrConnectionUnavailable)
})
}