Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90c0783953 | |||
| c8c8a528f6 | |||
| f1afca7921 | |||
| cda6d286ab | |||
| b44a46ed2f | |||
| 5b31db304a | |||
| 59f7b86a2e | |||
| ecd036b4eb | |||
| 6facb6eed0 | |||
| 093a56ea56 | |||
| ba5484e0dd | |||
| 8c7e3c3ee6 | |||
| 09257e39b4 |
@@ -0,0 +1,4 @@
|
||||
# go-honeybee
|
||||
|
||||
## Build
|
||||
- Run `go fmt` on every edited file before staging.
|
||||
@@ -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 | `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] |
|
||||
| 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`.
|
||||
|
||||
@@ -38,7 +63,7 @@ Sets the capacity of the channel that carries connection-level errors to the con
|
||||
|
||||
### 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()`**
|
||||
Disables retry entirely. `Connect()` returns on the first dial failure.
|
||||
@@ -63,133 +88,49 @@ 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.
|
||||
|
||||
### Wiring
|
||||
|
||||
**`outbound.WithConnectionConfig(*transport.ConnectionConfig)`**
|
||||
**`honeybee.WithConnectionConfig(*transport.ConnectionConfig)`**
|
||||
Supplies a connection config used when dialing each peer.
|
||||
|
||||
**`outbound.WithWorkerConfig(*outbound.WorkerConfig)`**
|
||||
**`honeybee.WithWorkerConfig(*honeybee.WorkerConfig)`**
|
||||
Supplies a worker config applied to every worker the pool creates.
|
||||
|
||||
**`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 | — | |
|
||||
|
||||
@@ -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,22 +30,12 @@ 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
|
||||
Dialer honeybee.Dialer
|
||||
ConnectionConfig *transport.ConnectionConfig
|
||||
Handler slog.Handler
|
||||
}
|
||||
@@ -53,100 +43,40 @@ type PoolPlugin struct {
|
||||
|
||||
**`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,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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
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
|
||||
WorkerFactory WorkerFactory
|
||||
WorkerConfig *WorkerConfig
|
||||
@@ -29,6 +21,8 @@ type PoolConfig struct {
|
||||
|
||||
type PoolOption func(*PoolConfig) error
|
||||
|
||||
// Constructor
|
||||
|
||||
func NewPoolConfig(options ...PoolOption) (*PoolConfig, error) {
|
||||
conf := GetDefaultPoolConfig()
|
||||
if err := applyPoolOptions(conf, options...); err != nil {
|
||||
@@ -44,8 +38,6 @@ func GetDefaultPoolConfig() *PoolConfig {
|
||||
return &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
LoggingEnabled: true,
|
||||
LogLevel: nil,
|
||||
ConnectionConfig: nil,
|
||||
WorkerFactory: nil,
|
||||
WorkerConfig: nil,
|
||||
@@ -61,6 +53,8 @@ func applyPoolOptions(config *PoolConfig, options ...PoolOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validation
|
||||
|
||||
func ValidatePoolConfig(config *PoolConfig) error {
|
||||
var err error
|
||||
|
||||
@@ -88,6 +82,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,21 +104,6 @@ func WithEventsBufferSize(value int) PoolOption {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
err := transport.ValidateConnectionConfig(cc)
|
||||
@@ -152,18 +133,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 +163,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 +175,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 +200,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 +224,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,8 +14,6 @@ func TestNewPoolConfig(t *testing.T) {
|
||||
assert.Equal(t, conf, &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
LoggingEnabled: true,
|
||||
LogLevel: nil,
|
||||
ConnectionConfig: nil,
|
||||
WorkerConfig: nil,
|
||||
WorkerFactory: nil,
|
||||
@@ -28,8 +26,6 @@ func TestDefaultPoolConfig(t *testing.T) {
|
||||
assert.Equal(t, conf, &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
LoggingEnabled: true,
|
||||
LogLevel: nil,
|
||||
ConnectionConfig: nil,
|
||||
WorkerConfig: nil,
|
||||
WorkerFactory: nil,
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,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
@@ -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...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
}()
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
+37
-49
@@ -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,16 @@ 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
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pool
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type Peer struct {
|
||||
id string
|
||||
@@ -67,34 +70,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 +100,8 @@ 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) {
|
||||
return NewWorker(ctx, id, config.WorkerConfig, handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,27 +109,29 @@ 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))
|
||||
}
|
||||
|
||||
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: transport.NewDialer(),
|
||||
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 +140,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
|
||||
@@ -254,33 +252,23 @@ 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
|
||||
}
|
||||
|
||||
pool := PoolPlugin{
|
||||
ID: p.id,
|
||||
Inbox: p.inbox,
|
||||
Events: p.events,
|
||||
InboxCounter: p.inboxCounter,
|
||||
Dialer: p.dialer,
|
||||
ConnectionConfig: p.config.ConnectionConfig,
|
||||
Handler: p.handler,
|
||||
}
|
||||
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
p.wg.Go(func() {
|
||||
worker.Start(pool)
|
||||
p.wg.Done()
|
||||
}()
|
||||
})
|
||||
|
||||
p.peers[id] = &Peer{id: id, worker: worker}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package outbound
|
||||
package honeybee
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func setupPool(t *testing.T) (*Pool, *honeybeetest.MockDialer) {
|
||||
t.Helper()
|
||||
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
|
||||
pool, err := NewPool(context.Background(), nil, nil)
|
||||
assert.NoError(t, err)
|
||||
dialer := &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||
@@ -45,11 +45,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)
|
||||
@@ -90,7 +85,7 @@ func TestPoolConnect(t *testing.T) {
|
||||
|
||||
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 +94,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,7 +152,7 @@ func TestPoolSend(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
|
||||
pool, err := NewPool(context.Background(), nil, nil)
|
||||
assert.NoError(t, err)
|
||||
pool.dialer = mockDialer
|
||||
|
||||
-131
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
+12
-20
@@ -1,11 +1,16 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Connection Config
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Types
|
||||
|
||||
type CloseHandler func(code int, text string) error
|
||||
|
||||
type ConnectionConfig struct {
|
||||
@@ -15,8 +20,6 @@ type ConnectionConfig struct {
|
||||
PingInterval time.Duration
|
||||
IncomingBufferSize int
|
||||
ErrorsBufferSize int
|
||||
LoggingEnabled bool
|
||||
LogLevel *slog.Level
|
||||
Retry *RetryConfig
|
||||
}
|
||||
|
||||
@@ -29,6 +32,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,8 +55,6 @@ func GetDefaultConnectionConfig() *ConnectionConfig {
|
||||
PingInterval: 20 * time.Second,
|
||||
IncomingBufferSize: 100,
|
||||
ErrorsBufferSize: 10,
|
||||
LoggingEnabled: true,
|
||||
LogLevel: nil,
|
||||
Retry: GetDefaultRetryConfig(),
|
||||
}
|
||||
}
|
||||
@@ -74,6 +77,8 @@ func applyConnectionOptions(config *ConnectionConfig, options ...ConnectionOptio
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validation
|
||||
|
||||
func ValidateConnectionConfig(config *ConnectionConfig) error {
|
||||
err := validateWriteTimeout(config.WriteTimeout)
|
||||
if err != nil {
|
||||
@@ -158,6 +163,8 @@ func validateJitterFactor(value float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options
|
||||
|
||||
func WithCloseHandler(handler CloseHandler) ConnectionOption {
|
||||
return func(c *ConnectionConfig) error {
|
||||
c.CloseHandler = handler
|
||||
@@ -216,21 +223,6 @@ func WithErrorsBufferSize(value int) ConnectionOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoggingEnabled(value bool) ConnectionOption {
|
||||
return func(c *ConnectionConfig) error {
|
||||
c.LoggingEnabled = value
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogLevel(level slog.Level) ConnectionOption {
|
||||
return func(c *ConnectionConfig) error {
|
||||
l := level
|
||||
c.LogLevel = &l
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithoutRetry() ConnectionOption {
|
||||
return func(c *ConnectionConfig) error {
|
||||
c.Retry = nil
|
||||
|
||||
@@ -2,7 +2,6 @@ package transport
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -36,8 +35,6 @@ func TestDefaultConnectionConfig(t *testing.T) {
|
||||
PingInterval: 20 * time.Second,
|
||||
IncomingBufferSize: 100,
|
||||
ErrorsBufferSize: 10,
|
||||
LoggingEnabled: true,
|
||||
LogLevel: nil,
|
||||
Retry: GetDefaultRetryConfig(),
|
||||
})
|
||||
}
|
||||
@@ -61,8 +58,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 +66,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)
|
||||
|
||||
+277
-223
@@ -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,6 +53,14 @@ type ConnectionStats struct {
|
||||
TotalHeartbeats uint64
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Connection
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------/
|
||||
// Constructors
|
||||
// -------------------------/
|
||||
|
||||
type Connection struct {
|
||||
url *url.URL
|
||||
dialer types.Dialer
|
||||
@@ -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,12 +101,17 @@ 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")
|
||||
}
|
||||
|
||||
conn := &Connection{
|
||||
url: url,
|
||||
dialer: NewDialer(),
|
||||
socket: nil,
|
||||
config: config,
|
||||
logger: logger,
|
||||
incoming: make(chan []byte, config.IncomingBufferSize),
|
||||
heartbeat: make(chan struct{}, 1),
|
||||
errors: make(chan error, config.ErrorsBufferSize),
|
||||
@@ -104,11 +122,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,12 +145,17 @@ func NewConnectionFromSocket(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if component.FromContext(ctx) == nil {
|
||||
ctx = component.MustNew(ctx, "honeybee", "connection")
|
||||
} else {
|
||||
ctx = component.MustExtend(ctx, "connection")
|
||||
}
|
||||
|
||||
conn := &Connection{
|
||||
url: nil,
|
||||
dialer: nil,
|
||||
socket: socket,
|
||||
config: config,
|
||||
logger: logger,
|
||||
incoming: make(chan []byte, config.IncomingBufferSize),
|
||||
heartbeat: make(chan struct{}, 1),
|
||||
errors: make(chan error, config.ErrorsBufferSize),
|
||||
@@ -138,17 +166,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,17 +203,20 @@ 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)
|
||||
@@ -179,235 +224,32 @@ func (c *Connection) Connect(ctx context.Context) error {
|
||||
return NewConnectionError(err)
|
||||
}
|
||||
|
||||
// got socket
|
||||
c.socket = socket
|
||||
c.state = StateConnected
|
||||
|
||||
// initialize
|
||||
if c.config.CloseHandler != nil {
|
||||
c.socket.SetCloseHandler(c.config.CloseHandler)
|
||||
}
|
||||
|
||||
c.setupPongHandler()
|
||||
|
||||
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.Info("connected")
|
||||
}
|
||||
|
||||
c.setupPongHandler()
|
||||
c.startPinger()
|
||||
c.startReader()
|
||||
|
||||
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 +258,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 +268,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)
|
||||
}
|
||||
@@ -468,3 +314,211 @@ 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.Info("connection closed by peer",
|
||||
"code", closeErr.Code,
|
||||
"text", closeErr.Text,
|
||||
)
|
||||
}
|
||||
classifiedError = fmt.Errorf("%w: %w", ErrPeerClosedClean, err)
|
||||
|
||||
default:
|
||||
if c.logger != nil {
|
||||
c.logger.Error("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.Info("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.Info("closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -194,7 +194,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)
|
||||
@@ -236,7 +236,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 +248,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,7 +260,7 @@ func TestConnect(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("connect succeeds and starts goroutines", func(t *testing.T) {
|
||||
conn, err := NewConnection("ws://test", nil, nil)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
||||
@@ -306,7 +306,7 @@ func TestConnect(t *testing.T) {
|
||||
JitterFactor: 0.0,
|
||||
},
|
||||
}
|
||||
conn, err := NewConnection("ws://test", config, nil)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
attemptCount := 0
|
||||
@@ -338,7 +338,7 @@ func TestConnect(t *testing.T) {
|
||||
JitterFactor: 0.0,
|
||||
},
|
||||
}
|
||||
conn, err := NewConnection("ws://test", config, nil)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockDialer := &honeybeetest.MockDialer{
|
||||
@@ -355,7 +355,7 @@ func TestConnect(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("state transitions during connect", 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())
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestConnect(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
conn, err := NewConnection("ws://test", config, nil)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
@@ -408,7 +408,7 @@ 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)
|
||||
conn, _ := NewConnection(context.Background(), "ws://test", conf, nil)
|
||||
|
||||
dialCalled := false
|
||||
conn.dialer = &honeybeetest.MockDialer{
|
||||
@@ -436,7 +436,7 @@ func TestConnectContextCancellation(t *testing.T) {
|
||||
JitterFactor: 0.0,
|
||||
},
|
||||
}
|
||||
conn, err := NewConnection("ws://test", config, nil)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
dialCount := atomic.Int32{}
|
||||
@@ -475,7 +475,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 +498,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 +521,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 +541,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 +573,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 +586,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 +620,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
|
||||
}
|
||||
|
||||
+12
-21
@@ -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,9 +27,8 @@ 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)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", nil, mockHandler)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
@@ -57,7 +57,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{
|
||||
@@ -68,7 +67,7 @@ func TestConnectLogging(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := NewConnection("ws://test", config, logger)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", config, mockHandler)
|
||||
assert.NoError(t, err)
|
||||
|
||||
dialErr := fmt.Errorf("dial error")
|
||||
@@ -100,7 +99,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{
|
||||
@@ -111,7 +109,7 @@ func TestConnectLogging(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := NewConnection("ws://test", config, logger)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", config, mockHandler)
|
||||
assert.NoError(t, err)
|
||||
|
||||
attemptCount := 0
|
||||
@@ -151,10 +149,9 @@ 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()
|
||||
@@ -176,7 +173,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 +180,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()
|
||||
@@ -208,7 +204,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,7 +213,7 @@ 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()
|
||||
|
||||
@@ -236,7 +231,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,7 +240,7 @@ 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()
|
||||
|
||||
@@ -264,14 +258,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,7 +278,6 @@ 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}
|
||||
|
||||
@@ -295,7 +287,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 +309,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 +316,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,7 +341,7 @@ 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)
|
||||
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
|
||||
+4
-10
@@ -59,22 +59,16 @@ func (r *RetryManager) CalculateDelay() time.Duration {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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,7 +78,9 @@ 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",
|
||||
"error", err,
|
||||
@@ -95,6 +98,7 @@ func AcquireSocket(
|
||||
"next_delay", delay)
|
||||
}
|
||||
|
||||
// context cancellable backoff
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,398 @@
|
||||
package honeybee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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,
|
||||
handler: handler,
|
||||
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
|
||||
if handler != nil {
|
||||
comp := component.FromContext(ctx)
|
||||
w.logger = slog.New(handler).With(slog.Any("component", comp), slog.String("peer_id", id))
|
||||
}
|
||||
|
||||
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.Info("started")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if w.logger != nil {
|
||||
w.logger.Info("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.Debug("session: connected")
|
||||
}
|
||||
break preConn
|
||||
|
||||
case <-w.sendHeartbeat:
|
||||
heartbeat()
|
||||
|
||||
case <-inactive():
|
||||
if w.logger != nil {
|
||||
w.logger.Info("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.Info("session: started")
|
||||
}
|
||||
|
||||
// run session loop
|
||||
conn_loop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break conn_loop
|
||||
|
||||
case data, ok := <-conn.Incoming():
|
||||
if !ok {
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("reader: disconnected")
|
||||
}
|
||||
break conn_loop
|
||||
}
|
||||
|
||||
pool.Inbox <- types.InboxMessage{
|
||||
ID: w.id, Data: data, ReceivedAt: time.Now()}
|
||||
|
||||
pool.InboxCounter.Add(1)
|
||||
w.processedCount.Add(1)
|
||||
|
||||
heartbeat()
|
||||
|
||||
case <-conn.Heartbeat():
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("ping-pong heartbeat")
|
||||
}
|
||||
heartbeat()
|
||||
|
||||
case <-w.sendHeartbeat:
|
||||
heartbeat()
|
||||
|
||||
case <-inactive():
|
||||
if w.logger != nil {
|
||||
w.logger.Info("keepalive: no activity observed")
|
||||
}
|
||||
timer.Reset(w.config.KeepaliveTimeout)
|
||||
break conn_loop
|
||||
}
|
||||
}
|
||||
|
||||
// session ended
|
||||
conn.Close()
|
||||
|
||||
if w.logger != nil {
|
||||
w.logger.Info("session: ended")
|
||||
}
|
||||
|
||||
// tear down connection
|
||||
w.conn.Store(nil)
|
||||
pool.Events <- PoolEvent{ID: w.id, Kind: EventDisconnected, At: time.Now()}
|
||||
|
||||
// exit if worker is shutting down
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// refresh session
|
||||
time.Sleep(w.config.ReconnectDelay)
|
||||
w.restartCount.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DefaultWorker) 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: requesting connection")
|
||||
}
|
||||
|
||||
go func() {
|
||||
conn, err := connect(w.id, dialCtx, pool, w.handler)
|
||||
|
||||
if err != nil {
|
||||
if w.logger != nil {
|
||||
w.logger.Warn("dialer: dial failed", "error", err)
|
||||
}
|
||||
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) {
|
||||
conn, err := transport.NewConnection(ctx, id, pool.ConnectionConfig, handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.SetDialer(pool.Dialer)
|
||||
return conn, conn.Connect(ctx)
|
||||
}
|
||||
|
||||
// ---------------------------/
|
||||
// Methods
|
||||
// -------------------------/
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
+748
@@ -0,0 +1,748 @@
|
||||
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{},
|
||||
}
|
||||
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.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.Start(pool)
|
||||
})
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.ID == w.id && e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
})
|
||||
|
||||
t.Run("dial failure exhausted - session stays alive, no events emitted", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
pool.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||
return nil, nil, errors.New("connection refused")
|
||||
},
|
||||
}
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithoutRetry())
|
||||
pool.ConnectionConfig = cc
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
honeybeetest.Never(t, func() bool {
|
||||
select {
|
||||
case <-events:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected no events when dial fails")
|
||||
|
||||
// worker goroutine is still running
|
||||
assert.False(t, func() bool {
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
return false
|
||||
}
|
||||
}(), "expected worker to still be running after dial failure")
|
||||
})
|
||||
|
||||
t.Run("keepalive fires before connection - dial is cancelled and replaced", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(20*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, _, pool := makeWorkerContext(t)
|
||||
|
||||
var dialCount atomic.Uint64
|
||||
pool.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||
dialCount.Add(1)
|
||||
<-dialCtx.Done()
|
||||
return nil, nil, dialCtx.Err()
|
||||
},
|
||||
}
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithoutRetry())
|
||||
pool.ConnectionConfig = cc
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
// keepalive fires after 20ms; a second dial goroutine must be spawned
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
return dialCount.Load() >= 2
|
||||
}, "expected at least two dial attempts after keepalive fired")
|
||||
})
|
||||
|
||||
t.Run("Stop before connection established - exits cleanly, no events", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
pool.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||
<-dialCtx.Done()
|
||||
return nil, nil, dialCtx.Err()
|
||||
},
|
||||
}
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithoutRetry())
|
||||
pool.ConnectionConfig = cc
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
w.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected Start to return after Stop")
|
||||
|
||||
assert.Empty(t, events, "expected no events when stopped before connection")
|
||||
})
|
||||
|
||||
t.Run("Send delivers data to socket", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, _, outgoingData := setupTestConnection(t)
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.Start(pool)
|
||||
})
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
err := w.Send([]byte("hello"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case msg := <-outgoingData:
|
||||
return string(msg.Data) == "hello"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected data on socket")
|
||||
})
|
||||
|
||||
t.Run("socket data arrives on Inbox", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
inbox, events, pool := makeWorkerContext(t)
|
||||
|
||||
incomingData := make(chan honeybeetest.MockIncomingData, 10)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
|
||||
mockSocket.CloseFunc = func() error {
|
||||
mockSocket.Once.Do(func() { close(mockSocket.Closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
||||
select {
|
||||
case data := <-incomingData:
|
||||
return data.MsgType, data.Data, data.Err
|
||||
}
|
||||
}
|
||||
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.Start(pool)
|
||||
})
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
incomingData <- honeybeetest.MockIncomingData{
|
||||
MsgType: websocket.TextMessage,
|
||||
Data: []byte("hello"),
|
||||
}
|
||||
|
||||
var received types.InboxMessage
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case msg := <-inbox:
|
||||
received = msg
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected message on Inbox")
|
||||
assert.Equal(t, w.id, received.ID)
|
||||
assert.Equal(t, []byte("hello"), received.Data)
|
||||
assert.False(t, received.ReceivedAt.IsZero(), "expected non-zero ReceivedAt")
|
||||
})
|
||||
|
||||
t.Run("sustained incoming messages reset keepalive - no disconnect", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(60*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
// send messages every 20ms for 100ms — well within the 60ms timeout each cycle
|
||||
go func() {
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
select {
|
||||
case incomingData <- honeybeetest.MockIncomingData{MsgType: websocket.TextMessage, Data: []byte("ping")}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
honeybeetest.Never(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDisconnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected no EventDisconnected while messages are arriving")
|
||||
})
|
||||
|
||||
t.Run("pong heartbeat resets keepalive - no disconnect", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(60*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
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.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.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
// no activity — keepalive fires after 30ms
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDisconnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventDisconnected after keepalive timeout")
|
||||
|
||||
// session must redial — a second EventConnected follows
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected after redial")
|
||||
})
|
||||
|
||||
t.Run("socket close produces EventDisconnected then EventConnected", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.Start(pool)
|
||||
})
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
close(incomingData)
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDisconnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventDisconnected")
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected second EventConnected")
|
||||
})
|
||||
|
||||
t.Run("connection pointer is nil between disconnect and reconnect", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
close(incomingData)
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDisconnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventDisconnected")
|
||||
|
||||
// conn.Store(nil) happens before EventDisconnected is sent
|
||||
assert.Nil(t, w.conn.Load(), "expected connection pointer to be nil after disconnect")
|
||||
})
|
||||
|
||||
t.Run("Stop produces EventDisconnected and wg drains", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.Start(pool)
|
||||
})
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
w.Stop()
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDisconnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventDisconnected")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected wg to drain")
|
||||
})
|
||||
|
||||
t.Run("parent context cancel exits cleanly and wg drains", func(t *testing.T) {
|
||||
parentCtx, parentCancel := context.WithCancel(context.Background())
|
||||
workerCtx, workerCancel := context.WithCancel(parentCtx)
|
||||
|
||||
w := makeWorker(t, workerCtx, workerCancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
pool.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.Start(pool)
|
||||
})
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
// drain events after parent cancel — we don't assert what they are,
|
||||
// only that the worker exits
|
||||
parentCancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected wg to drain after parent cancel")
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user