Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d04341bfa2 | |||
| 8c1371e3a0 | |||
| d4da16f82a | |||
| 695389798e | |||
| fac62c0675 | |||
| c82e0184f5 | |||
| c4d35fe6fa | |||
| bcbdb79b32 | |||
| a721eabd48 | |||
| f144a2a724 | |||
| 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.
|
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.
|
Logging can be controlled independently at the pool, worker, and connection levels. Each scope has a `LoggingEnabled` flag and a `LogLevel` override. When `LoggingEnabled` is false, no logger is constructed for that scope regardless of what handler is passed to the pool. When `LogLevel` is set, it overrides the handler's own level filter for that scope only, using a wrapping handler that enforces the minimum level before delegating. When `LogLevel` is nil, the handler's own level filtering applies unchanged.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
| Scope | Setting | Default | Disabled by | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Connection | `WriteTimeout` | 30s | `0` | Per-message write deadline |
|
||||||
|
| Connection | `RequestHeader` | User-Agent | — | honeybee/0.1.0 |
|
||||||
|
| Connection | `PingInterval` | 20s | `0` | ±10% jitter applied per interval |
|
||||||
|
| Connection | `IncomingBufferSize` | 100 | — | Must be positive |
|
||||||
|
| Connection | `ErrorsBufferSize` | 10 | — | Must be positive |
|
||||||
|
| Connection | `LoggingEnabled` | true | `false` | |
|
||||||
|
| Connection | `LogLevel` | nil | — | nil defers to handler's own filter |
|
||||||
|
| Retry | enabled | yes | `WithRetryDisabled()` | Governs `Connect()` only |
|
||||||
|
| Retry | `MaxRetries` | 0 | — | 0 means infinite |
|
||||||
|
| Retry | `InitialDelay` | 1s | — | Must be positive |
|
||||||
|
| Retry | `MaxDelay` | 60s | — | Must be ≥ InitialDelay |
|
||||||
|
| Retry | `JitterFactor` | 0.2 | `0.0` | Range [0.0, 1.0] |
|
||||||
|
| Pool | `InboxBufferSize` | 256 | — | Must be positive |
|
||||||
|
| Pool | `EventsBufferSize` | 10 | — | Must be positive |
|
||||||
|
| Pool | `LoggingEnabled` | true | `false` | |
|
||||||
|
| Pool | `LogLevel` | nil | — | |
|
||||||
|
| Worker | `KeepaliveTimeout` | 60s | `0` | 0 disables keepalive |
|
||||||
|
| Worker | `ReconnectDelay` | 2s | `0` | 0 means reconnect immediately |
|
||||||
|
| Worker | `LoggingEnabled` | true | `false` | |
|
||||||
|
| Worker | `LogLevel` | nil | — | |
|
||||||
|
|
||||||
## Connection Options
|
## 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`.
|
These options are passed to `transport.NewConnectionConfig`.
|
||||||
|
|
||||||
@@ -36,11 +61,16 @@ Sets the capacity of the channel that buffers inbound messages between the reade
|
|||||||
**`WithErrorsBufferSize(int)`**
|
**`WithErrorsBufferSize(int)`**
|
||||||
Sets the capacity of the channel that carries connection-level errors to the consumer. Must be at least 1.
|
Sets the capacity of the channel that carries connection-level errors to the consumer. Must be at least 1.
|
||||||
|
|
||||||
|
### Dialer
|
||||||
|
|
||||||
|
**`WithConnectionDialer(types.Dialer)`**
|
||||||
|
Overrides the dialer used to establish the WebSocket connection. When not set, the connection uses the default dialer. Useful in tests or when routing connections through a custom transport.
|
||||||
|
|
||||||
### Retry
|
### Retry
|
||||||
|
|
||||||
The retry policy governs the `Connect()` call only. It does not affect outbound worker reconnection, which is controlled by `ReconnectDelay` on the worker config.
|
The retry policy governs the `Connect()` call only. It does not affect worker reconnection, which is controlled by `ReconnectDelay` on the worker config.
|
||||||
|
|
||||||
**`WithoutRetry()`**
|
**`WithRetryDisabled()`**
|
||||||
Disables retry entirely. `Connect()` returns on the first dial failure.
|
Disables retry entirely. `Connect()` returns on the first dial failure.
|
||||||
|
|
||||||
**`WithRetryMaxRetries(int)`**
|
**`WithRetryMaxRetries(int)`**
|
||||||
@@ -63,133 +93,54 @@ Enables or disables logging for the connection. When false, no logger is constru
|
|||||||
**`WithConnectionLogLevel(slog.Level)`**
|
**`WithConnectionLogLevel(slog.Level)`**
|
||||||
Overrides the minimum log level for connection-scoped records. Does not affect pool or worker logging.
|
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
|
### 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.
|
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.
|
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.
|
Enables or disables pool-level logging.
|
||||||
|
|
||||||
**`inbound.WithPoolLogLevel(slog.Level)`**
|
**`honeybee.WithPoolLogLevel(slog.Level)`**
|
||||||
Overrides the minimum log level for pool-scoped records only.
|
Overrides the minimum log level for pool-scoped records only.
|
||||||
|
|
||||||
### Worker
|
### 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)`**
|
**`honeybee.WithKeepaliveTimeout(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)`**
|
|
||||||
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.
|
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.
|
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)`**
|
**`honeybee.WithWorkerLoggingEnabled(bool)`**
|
||||||
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)`**
|
|
||||||
Enables or disables worker-level logging.
|
Enables or disables worker-level logging.
|
||||||
|
|
||||||
**`outbound.WithWorkerLogLevel(slog.Level)`**
|
**`honeybee.WithWorkerLogLevel(slog.Level)`**
|
||||||
Overrides the minimum log level for worker-scoped records only.
|
Overrides the minimum log level for worker-scoped records only.
|
||||||
|
|
||||||
|
### Per-connection
|
||||||
|
|
||||||
|
**`honeybee.WithDialer(types.Dialer)`**
|
||||||
|
Overrides the dialer for a single `Connect` call. Passed as a variadic option: `pool.Connect(id, honeybee.WithDialer(d))`. When provided, it takes precedence over the dialer resolved from `ConnectionConfig`. Existing callers that pass no options are unaffected.
|
||||||
|
|
||||||
### Wiring
|
### Wiring
|
||||||
|
|
||||||
**`outbound.WithConnectionConfig(*transport.ConnectionConfig)`**
|
**`honeybee.WithConnectionConfig(transport.ConnectionConfig)`**
|
||||||
Supplies a connection config used when dialing each peer.
|
Supplies a connection config used when dialing each peer. Accepted by value; the pool stores its own copy.
|
||||||
|
|
||||||
**`outbound.WithWorkerConfig(*outbound.WorkerConfig)`**
|
**`honeybee.WithWorkerConfig(honeybee.WorkerConfig)`**
|
||||||
Supplies a worker config applied to every worker the pool creates.
|
Supplies a worker config applied to every worker the pool creates. Accepted by value; the pool stores its own copy.
|
||||||
|
|
||||||
**`outbound.WithWorkerFactory(outbound.WorkerFactory)`**
|
**`honeybee.WithWorkerFactory(honeybee.WorkerFactory)`**
|
||||||
Replaces the default worker constructor. See [EXTEND.md](EXTEND.md) for the factory contract.
|
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
|
## The Worker Interface
|
||||||
|
|
||||||
Both pools accept any type that satisfies:
|
The pool accepts any type that satisfies:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Worker interface {
|
type Worker interface {
|
||||||
@@ -13,7 +13,7 @@ type Worker interface {
|
|||||||
Send(data []byte) error
|
Send(data []byte) error
|
||||||
Stats() WorkerStats
|
Stats() WorkerStats
|
||||||
}
|
}
|
||||||
````
|
```
|
||||||
|
|
||||||
The behavioral contract for each method:
|
The behavioral contract for each method:
|
||||||
|
|
||||||
@@ -30,123 +30,52 @@ The behavioral contract for each method:
|
|||||||
The pool constructs a `PoolPlugin` and passes it to `Start`. It gives the worker access to pool-level channels and the logging handler.
|
The pool constructs a `PoolPlugin` and passes it to `Start`. It gives the worker access to pool-level channels and the logging handler.
|
||||||
|
|
||||||
```go
|
```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 {
|
type PoolPlugin struct {
|
||||||
ID string
|
ID string
|
||||||
Inbox chan<- outbound.InboxMessage
|
Inbox chan<- honeybee.InboxMessage
|
||||||
Events chan<- outbound.PoolEvent
|
Events chan<- honeybee.PoolEvent
|
||||||
InboxCounter *atomic.Uint64
|
InboxCounter *atomic.Uint64
|
||||||
Dialer outbound.Dialer
|
ConnectionConfig transport.ConnectionConfig
|
||||||
ConnectionConfig *transport.ConnectionConfig
|
|
||||||
Handler slog.Handler
|
Handler slog.Handler
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**`Inbox`** The shared channel that delivers received messages to the pool's consumer. All peers in the pool deliver to the same inbox channel. Workers must include their peer ID in each `InboxMessage`.
|
**`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()`.
|
**`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.
|
**`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
|
### Factory Signature
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type inbound.WorkerFactory func(
|
type honeybee.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(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
id string,
|
id string,
|
||||||
logger *slog.Logger,
|
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.
|
`DefaultWorker`'s source is the authoritative reference for how those responsibilities are met.
|
||||||
|
|
||||||
**`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`.
|
|
||||||
|
|
||||||
## Factory Constraints
|
## 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 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
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
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
|
## Library Map
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
transport/ single-connection primitives
|
honeybee.go Pool, Worker, public types
|
||||||
connection.go *Connection, state machine, reader goroutine, pinger
|
|
||||||
|
transport/ single-connection primitives and helpers
|
||||||
|
connection.go Connection, state machine, reader goroutine, pinger
|
||||||
config.go ConnectionConfig, RetryConfig, options
|
config.go ConnectionConfig, RetryConfig, options
|
||||||
retry.go exponential backoff with jitter
|
retry.go exponential backoff with jitter
|
||||||
socket.go Dialer interface, AcquireSocket
|
socket.go Dialer interface, AcquireSocket
|
||||||
url.go parsing and normalization
|
url.go parsing and normalization
|
||||||
|
watchdog.go IdleWatchdog helper
|
||||||
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
|
|
||||||
|
|
||||||
logging/ structured log construction
|
logging/ structured log construction
|
||||||
logging.go logger constructors, ForcedLevelHandler
|
logging.go logger constructors, ForcedLevelHandler
|
||||||
|
|
||||||
types/ internal interfaces
|
types/ shared interfaces (Dialer, Socket, ReceivedMessage)
|
||||||
honeybeetest/ test helpers and mocks for consumers
|
honeybeetest/ test helpers and mocks for consumers
|
||||||
````
|
```
|
||||||
|
|
||||||
## What This Library Does
|
## 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.
|
- Client-Side: Manage a pool of outbound peer connections that reconnect automatically and surface lifecycle events.
|
||||||
- Provides two pools: one to manage outbound peers and another to manage inbound peers.
|
- 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.
|
||||||
- Exposes statistics at the connection, worker, and pool levels.
|
- 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 replace the internal pool worker to inject custom extensions.
|
- Exposes a means to completely replace the internal pool worker to inject custom behavior.
|
||||||
|
|
||||||
## What This Library Does Not Do
|
## 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:
|
Honeybee does not provide:
|
||||||
|
|
||||||
- interpretation of message content. All messages are treated equally.
|
- 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.
|
- rate limiting, circuit breakers, token buckets, or adaptive throttling.
|
||||||
- broadcast, fanout, or any many-to-many message routing.
|
- broadcast, fanout, or any many-to-many message routing.
|
||||||
- compression strategies, prepared message caching, or encoding optimization.
|
- 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
|
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
|
```go
|
||||||
import "git.wisehodl.dev/jay/go-honeybee/transport"
|
import "git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
@@ -88,14 +216,15 @@ go func() {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for err := range conn.Errors() {
|
for err := range conn.Errors() {
|
||||||
// log or handle
|
// log or handle disconnects / read errors
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Send can be called concurrently
|
||||||
conn.Send([]byte("hello"))
|
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.
|
`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
|
- `ErrPeerClosedUnexpected` for abnormal close codes
|
||||||
- `ErrReadError` for anything else
|
- `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
|
## 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.
|
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 by value via `honeybee.WithConnectionConfig`, or supply it directly to `NewConnection` and `NewConnectionFromSocket`. The default is 20 seconds. Set to zero to disable pings entirely, in which case only data messages and outbound sends generate heartbeats.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Statistics
|
## 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
|
```go
|
||||||
// Pool-level snapshot
|
// Pool-level snapshot
|
||||||
@@ -229,8 +257,7 @@ stats := pool.Stats()
|
|||||||
|
|
||||||
// Single peer
|
// Single peer
|
||||||
peerStats, err := pool.PeerStats(peerID)
|
peerStats, err := pool.PeerStats(peerID)
|
||||||
// peerStats.Worker — queue depths, buffer depth, processed/dropped/sent counts
|
// peerStats.Worker — channel depths, processed/sent counts
|
||||||
// peerStats.Connection — channel depths, receive/send/heartbeat counts (inbound)
|
|
||||||
|
|
||||||
// Bare connection (transport package)
|
// Bare connection (transport package)
|
||||||
connStats := conn.Stats() // conn is a *transport.Connection
|
connStats := conn.Stats() // conn is a *transport.Connection
|
||||||
@@ -239,9 +266,9 @@ connStats := conn.Stats() // conn is a *transport.Connection
|
|||||||
|
|
||||||
## Extending Pools
|
## Extending Pools
|
||||||
|
|
||||||
The pool owns peer registration, event plumbing, and lifecycle. The worker owns what happens on the wire. The default worker can be replaced entirely or composed from the exported `Run*` building blocks that Honeybee provides.
|
The pool owns peer registration, event plumbing, and lifecycle. The worker owns what happens on the wire. The default worker can be replaced entirely via `WorkerFactory`.
|
||||||
|
|
||||||
See EXTEND.md for the worker interface contract, the `PoolPlugin` fields, and the available building blocks for both inbound and outbound pools.
|
See EXTEND.md for the worker interface contract, the `PoolPlugin` fields, and extension patterns.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
package outbound
|
package honeybee
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
"log/slog"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Types
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type WorkerFactory func(
|
|
||||||
ctx context.Context,
|
|
||||||
id string,
|
|
||||||
logger *slog.Logger,
|
|
||||||
) (Worker, error)
|
|
||||||
|
|
||||||
// Pool Config
|
// Pool Config
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
type PoolConfig struct {
|
type PoolConfig struct {
|
||||||
InboxBufferSize int
|
InboxBufferSize int
|
||||||
EventsBufferSize int
|
EventsBufferSize int
|
||||||
LoggingEnabled bool
|
ConnectionConfig transport.ConnectionConfig
|
||||||
LogLevel *slog.Level
|
|
||||||
ConnectionConfig *transport.ConnectionConfig
|
|
||||||
WorkerFactory WorkerFactory
|
WorkerFactory WorkerFactory
|
||||||
WorkerConfig *WorkerConfig
|
WorkerConfig WorkerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolOption func(*PoolConfig) error
|
type PoolOption func(*PoolConfig) error
|
||||||
|
|
||||||
|
// Constructor
|
||||||
|
|
||||||
func NewPoolConfig(options ...PoolOption) (*PoolConfig, error) {
|
func NewPoolConfig(options ...PoolOption) (*PoolConfig, error) {
|
||||||
conf := GetDefaultPoolConfig()
|
conf := GetDefaultPoolConfig()
|
||||||
if err := applyPoolOptions(conf, options...); err != nil {
|
if err := applyPoolOptions(conf, options...); err != nil {
|
||||||
@@ -44,11 +38,9 @@ func GetDefaultPoolConfig() *PoolConfig {
|
|||||||
return &PoolConfig{
|
return &PoolConfig{
|
||||||
InboxBufferSize: 256,
|
InboxBufferSize: 256,
|
||||||
EventsBufferSize: 10,
|
EventsBufferSize: 10,
|
||||||
LoggingEnabled: true,
|
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||||
LogLevel: nil,
|
|
||||||
ConnectionConfig: nil,
|
|
||||||
WorkerFactory: nil,
|
WorkerFactory: nil,
|
||||||
WorkerConfig: nil,
|
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,21 +53,19 @@ func applyPoolOptions(config *PoolConfig, options ...PoolOption) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
|
||||||
func ValidatePoolConfig(config *PoolConfig) error {
|
func ValidatePoolConfig(config *PoolConfig) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if config.ConnectionConfig != nil {
|
err = transport.ValidateConnectionConfig(&config.ConnectionConfig)
|
||||||
err = transport.ValidateConnectionConfig(config.ConnectionConfig)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.WorkerConfig != nil {
|
err = ValidateWorkerConfig(&config.WorkerConfig)
|
||||||
err = ValidateWorkerConfig(config.WorkerConfig)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -88,6 +78,8 @@ func validateBufferSize(value int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options
|
||||||
|
|
||||||
func WithInboxBufferSize(value int) PoolOption {
|
func WithInboxBufferSize(value int) PoolOption {
|
||||||
return func(c *PoolConfig) error {
|
return func(c *PoolConfig) error {
|
||||||
if err := validateBufferSize(value); err != nil {
|
if err := validateBufferSize(value); err != nil {
|
||||||
@@ -108,24 +100,9 @@ func WithEventsBufferSize(value int) PoolOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithPoolLoggingEnabled(value bool) PoolOption {
|
func WithConnectionConfig(cc transport.ConnectionConfig) PoolOption {
|
||||||
return func(c *PoolConfig) error {
|
return func(c *PoolConfig) error {
|
||||||
c.LoggingEnabled = value
|
err := transport.ValidateConnectionConfig(&cc)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -134,9 +111,9 @@ func WithConnectionConfig(cc *transport.ConnectionConfig) PoolOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithWorkerConfig(wc *WorkerConfig) PoolOption {
|
func WithWorkerConfig(wc WorkerConfig) PoolOption {
|
||||||
return func(c *PoolConfig) error {
|
return func(c *PoolConfig) error {
|
||||||
err := ValidateWorkerConfig(wc)
|
err := ValidateWorkerConfig(&wc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -152,18 +129,21 @@ func WithWorkerFactory(wf WorkerFactory) PoolOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Worker Config
|
// Worker Config
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
type WorkerConfig struct {
|
type WorkerConfig struct {
|
||||||
KeepaliveTimeout time.Duration
|
KeepaliveTimeout time.Duration
|
||||||
ReconnectDelay time.Duration
|
ReconnectDelay time.Duration
|
||||||
MaxQueueSize int
|
|
||||||
LoggingEnabled bool
|
|
||||||
LogLevel *slog.Level
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerOption func(*WorkerConfig) error
|
type WorkerOption func(*WorkerConfig) error
|
||||||
|
|
||||||
|
// Constructor
|
||||||
|
|
||||||
func NewWorkerConfig(options ...WorkerOption) (*WorkerConfig, error) {
|
func NewWorkerConfig(options ...WorkerOption) (*WorkerConfig, error) {
|
||||||
conf := GetDefaultWorkerConfig()
|
conf := GetDefaultWorkerConfig()
|
||||||
if err := applyWorkerOptions(conf, options...); err != nil {
|
if err := applyWorkerOptions(conf, options...); err != nil {
|
||||||
@@ -179,9 +159,6 @@ func GetDefaultWorkerConfig() *WorkerConfig {
|
|||||||
return &WorkerConfig{
|
return &WorkerConfig{
|
||||||
KeepaliveTimeout: 60 * time.Second,
|
KeepaliveTimeout: 60 * time.Second,
|
||||||
ReconnectDelay: 2 * time.Second,
|
ReconnectDelay: 2 * time.Second,
|
||||||
MaxQueueSize: 0, // disabled by default
|
|
||||||
LoggingEnabled: true,
|
|
||||||
LogLevel: nil,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,24 +171,14 @@ func applyWorkerOptions(config *WorkerConfig, options ...WorkerOption) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
|
||||||
func ValidateWorkerConfig(config *WorkerConfig) error {
|
func ValidateWorkerConfig(config *WorkerConfig) error {
|
||||||
err := validateKeepaliveTimeout(config.KeepaliveTimeout)
|
err := validateKeepaliveTimeout(config.KeepaliveTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +196,8 @@ func validateReconnectDelay(value time.Duration) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options
|
||||||
|
|
||||||
// When KeepaliveTimeout is set to zero, keepalive timeouts are disabled.
|
// When KeepaliveTimeout is set to zero, keepalive timeouts are disabled.
|
||||||
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
|
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
|
||||||
return func(c *WorkerConfig) error {
|
return func(c *WorkerConfig) error {
|
||||||
@@ -251,30 +220,3 @@ func WithReconnectDelay(value time.Duration) WorkerOption {
|
|||||||
return nil
|
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 (
|
import (
|
||||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
@@ -14,10 +14,8 @@ func TestNewPoolConfig(t *testing.T) {
|
|||||||
assert.Equal(t, conf, &PoolConfig{
|
assert.Equal(t, conf, &PoolConfig{
|
||||||
InboxBufferSize: 256,
|
InboxBufferSize: 256,
|
||||||
EventsBufferSize: 10,
|
EventsBufferSize: 10,
|
||||||
LoggingEnabled: true,
|
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||||
LogLevel: nil,
|
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||||
ConnectionConfig: nil,
|
|
||||||
WorkerConfig: nil,
|
|
||||||
WorkerFactory: nil,
|
WorkerFactory: nil,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -28,10 +26,8 @@ func TestDefaultPoolConfig(t *testing.T) {
|
|||||||
assert.Equal(t, conf, &PoolConfig{
|
assert.Equal(t, conf, &PoolConfig{
|
||||||
InboxBufferSize: 256,
|
InboxBufferSize: 256,
|
||||||
EventsBufferSize: 10,
|
EventsBufferSize: 10,
|
||||||
LoggingEnabled: true,
|
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||||
LogLevel: nil,
|
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||||
ConnectionConfig: nil,
|
|
||||||
WorkerConfig: nil,
|
|
||||||
WorkerFactory: nil,
|
WorkerFactory: nil,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -40,7 +36,9 @@ func TestApplyPoolOptions(t *testing.T) {
|
|||||||
conf := &PoolConfig{}
|
conf := &PoolConfig{}
|
||||||
err := applyPoolOptions(
|
err := applyPoolOptions(
|
||||||
conf,
|
conf,
|
||||||
WithConnectionConfig(&transport.ConnectionConfig{}),
|
WithConnectionConfig(transport.ConnectionConfig{
|
||||||
|
Retry: transport.RetryConfig{Disabled: true},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -61,15 +59,21 @@ func TestWithBufferSizes(t *testing.T) {
|
|||||||
|
|
||||||
func TestWithConnectionConfig(t *testing.T) {
|
func TestWithConnectionConfig(t *testing.T) {
|
||||||
conf := &PoolConfig{}
|
conf := &PoolConfig{}
|
||||||
opt := WithConnectionConfig(&transport.ConnectionConfig{WriteTimeout: 1 * time.Second})
|
opt := WithConnectionConfig(transport.ConnectionConfig{
|
||||||
|
WriteTimeout: 1 * time.Second,
|
||||||
|
Retry: transport.RetryConfig{Disabled: true},
|
||||||
|
})
|
||||||
err := applyPoolOptions(conf, opt)
|
err := applyPoolOptions(conf, opt)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, conf.ConnectionConfig)
|
|
||||||
assert.Equal(t, 1*time.Second, conf.ConnectionConfig.WriteTimeout)
|
assert.Equal(t, 1*time.Second, conf.ConnectionConfig.WriteTimeout)
|
||||||
|
|
||||||
// invalid config is rejected
|
// invalid config is rejected
|
||||||
conf = &PoolConfig{}
|
conf = &PoolConfig{}
|
||||||
opt = WithConnectionConfig(&transport.ConnectionConfig{WriteTimeout: -1 * time.Second})
|
opt = WithConnectionConfig(
|
||||||
|
transport.ConnectionConfig{
|
||||||
|
WriteTimeout: -1 * time.Second,
|
||||||
|
Retry: transport.RetryConfig{Disabled: true},
|
||||||
|
})
|
||||||
err = applyPoolOptions(conf, opt)
|
err = applyPoolOptions(conf, opt)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
@@ -82,8 +86,12 @@ func TestValidatePoolConfig(t *testing.T) {
|
|||||||
wantErrText string
|
wantErrText string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid empty",
|
name: "valid empty (retry disabled)",
|
||||||
conf: *&PoolConfig{},
|
conf: PoolConfig{
|
||||||
|
ConnectionConfig: transport.ConnectionConfig{
|
||||||
|
Retry: transport.RetryConfig{Disabled: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid defaults",
|
name: "valid defaults",
|
||||||
@@ -92,14 +100,16 @@ func TestValidatePoolConfig(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid complete",
|
name: "valid complete",
|
||||||
conf: PoolConfig{
|
conf: PoolConfig{
|
||||||
ConnectionConfig: &transport.ConnectionConfig{},
|
ConnectionConfig: transport.ConnectionConfig{
|
||||||
|
Retry: transport.RetryConfig{Disabled: true},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid connection config",
|
name: "invalid connection config",
|
||||||
conf: PoolConfig{
|
conf: PoolConfig{
|
||||||
ConnectionConfig: &transport.ConnectionConfig{
|
ConnectionConfig: transport.ConnectionConfig{
|
||||||
Retry: &transport.RetryConfig{
|
Retry: transport.RetryConfig{
|
||||||
InitialDelay: 10 * time.Second,
|
InitialDelay: 10 * time.Second,
|
||||||
MaxDelay: 1 * time.Second,
|
MaxDelay: 1 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package outbound
|
package honeybee
|
||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
@@ -7,14 +7,12 @@ var (
|
|||||||
// Config errors
|
// Config errors
|
||||||
InvalidKeepaliveTimeout = errors.New("keepalive timeout cannot be negative")
|
InvalidKeepaliveTimeout = errors.New("keepalive timeout cannot be negative")
|
||||||
InvalidReconnectDelay = errors.New("reconnect delay 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")
|
InvalidBufferSize = errors.New("buffer size must be greater than zero")
|
||||||
|
|
||||||
// Pool errors
|
// Pool errors
|
||||||
ErrInvalidPoolID = errors.New("pool id cannot be empty")
|
ErrPoolClosed = errors.New("pool is closed")
|
||||||
ErrPoolClosed = errors.New("pool is closed")
|
ErrPeerNotFound = errors.New("peer not found")
|
||||||
ErrPeerNotFound = errors.New("peer not found")
|
ErrPeerExists = errors.New("peer already exists")
|
||||||
ErrPeerExists = errors.New("peer already exists")
|
|
||||||
|
|
||||||
// Worker errors
|
// Worker errors
|
||||||
ErrConnectionUnavailable = errors.New("connection unavailable")
|
ErrConnectionUnavailable = errors.New("connection unavailable")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module git.wisehodl.dev/jay/go-honeybee
|
module git.wisehodl.dev/jay/go-honeybee
|
||||||
|
|
||||||
go 1.23.5
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.wisehodl.dev/jay/go-mana-component v0.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package outbound
|
package honeybee
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -18,7 +19,7 @@ func setupTestConnection(t *testing.T) (
|
|||||||
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
|
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
conn, err = transport.NewConnectionFromSocket(socket, nil, nil)
|
conn, err = transport.NewConnectionFromSocket(context.Background(), socket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TestTimeout = 2 * time.Second
|
TestTimeout = 2 * time.Second
|
||||||
@@ -18,7 +20,9 @@ const (
|
|||||||
NegativeTestTimeout = 100 * time.Millisecond
|
NegativeTestTimeout = 100 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type MockIncomingData struct {
|
type MockIncomingData struct {
|
||||||
MsgType int
|
MsgType int
|
||||||
@@ -37,7 +41,9 @@ type ExpectedLog struct {
|
|||||||
Attrs map[string]any
|
Attrs map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Setup
|
// Setup
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
func SetupTestSocket(t *testing.T) (
|
func SetupTestSocket(t *testing.T) (
|
||||||
socket *MockSocket,
|
socket *MockSocket,
|
||||||
@@ -81,7 +87,9 @@ func SetupTestSocket(t *testing.T) (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
func ExpectIncoming(t *testing.T, incoming <-chan []byte, expected []byte) {
|
func ExpectIncoming(t *testing.T, incoming <-chan []byte, expected []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -126,7 +134,9 @@ func Never(t *testing.T, condition func() bool, msg string) {
|
|||||||
assert.Never(t, condition, NegativeTestTimeout, TestTick, msg)
|
assert.Never(t, condition, NegativeTestTimeout, TestTick, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Logging Helpers
|
// Logging Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
func AssertLogSequence(t *testing.T, records []slog.Record, expected []ExpectedLog) {
|
func AssertLogSequence(t *testing.T, records []slog.Record, expected []ExpectedLog) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
+12
-2
@@ -9,12 +9,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Re-exported types for consumer convenience
|
// ----------------------------------------------------------------------------
|
||||||
|
// Re-exports
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type Socket = types.Socket
|
type Socket = types.Socket
|
||||||
type Dialer = types.Dialer
|
type Dialer = types.Dialer
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Dialer Mocks
|
// Dialer Mocks
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type MockDialer struct {
|
type MockDialer struct {
|
||||||
DialContextFunc func(
|
DialContextFunc func(
|
||||||
@@ -28,7 +32,9 @@ func (m *MockDialer) DialContext(
|
|||||||
return m.DialContextFunc(ctx, url, h)
|
return m.DialContextFunc(ctx, url, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Socket Mocks
|
// Socket Mocks
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type MockSocket struct {
|
type MockSocket struct {
|
||||||
WriteMessageFunc func(int, []byte) error
|
WriteMessageFunc func(int, []byte) error
|
||||||
@@ -93,12 +99,14 @@ func (m *MockSocket) SetPongHandler(h func(s string) error) {
|
|||||||
m.SetPongHandlerFunc(h)
|
m.SetPongHandlerFunc(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Logging mocks
|
// Logging mocks
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type MockSlogHandler struct {
|
type MockSlogHandler struct {
|
||||||
records *[]slog.Record
|
records *[]slog.Record
|
||||||
attrs []slog.Attr
|
attrs []slog.Attr
|
||||||
mu sync.RWMutex
|
mu *sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMockSlogHandler() *MockSlogHandler {
|
func NewMockSlogHandler() *MockSlogHandler {
|
||||||
@@ -106,6 +114,7 @@ func NewMockSlogHandler() *MockSlogHandler {
|
|||||||
return &MockSlogHandler{
|
return &MockSlogHandler{
|
||||||
records: &records,
|
records: &records,
|
||||||
attrs: make([]slog.Attr, 0),
|
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()
|
defer m.mu.RUnlock()
|
||||||
return &MockSlogHandler{
|
return &MockSlogHandler{
|
||||||
records: m.records, // shared records slice
|
records: m.records, // shared records slice
|
||||||
|
mu: m.mu, // shared mutex
|
||||||
attrs: append(m.attrs, attrs...),
|
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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+80
-66
@@ -1,11 +1,12 @@
|
|||||||
package outbound
|
package honeybee
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/logging"
|
"log/slog"
|
||||||
|
|
||||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/types"
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
"log/slog"
|
"git.wisehodl.dev/jay/go-mana-component"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,7 +19,9 @@ type Dialer = types.Dialer
|
|||||||
|
|
||||||
var NormalizeURL = transport.NormalizeURL
|
var NormalizeURL = transport.NormalizeURL
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type PoolEventKind string
|
type PoolEventKind string
|
||||||
|
|
||||||
@@ -50,16 +53,15 @@ type PeerStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PoolPlugin struct {
|
type PoolPlugin struct {
|
||||||
ID string
|
|
||||||
Inbox chan<- types.InboxMessage
|
Inbox chan<- types.InboxMessage
|
||||||
Events chan<- PoolEvent
|
Events chan<- PoolEvent
|
||||||
InboxCounter *atomic.Uint64
|
InboxCounter *atomic.Uint64
|
||||||
Dialer types.Dialer
|
ConnectionConfig transport.ConnectionConfig
|
||||||
ConnectionConfig *transport.ConnectionConfig
|
|
||||||
Handler slog.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
// Pool
|
// Pool
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type Peer struct {
|
type Peer struct {
|
||||||
id string
|
id string
|
||||||
@@ -67,34 +69,27 @@ type Peer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Pool struct {
|
type Pool struct {
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
|
|
||||||
id string
|
|
||||||
|
|
||||||
peers map[string]*Peer
|
peers map[string]*Peer
|
||||||
inbox chan types.InboxMessage
|
inbox chan types.InboxMessage
|
||||||
events chan PoolEvent
|
events chan PoolEvent
|
||||||
|
closed bool
|
||||||
inboxCounter *atomic.Uint64
|
|
||||||
outgoingCount *atomic.Uint64
|
|
||||||
|
|
||||||
dialer types.Dialer
|
dialer types.Dialer
|
||||||
config *PoolConfig
|
config *PoolConfig
|
||||||
handler slog.Handler
|
handler slog.Handler
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
wg sync.WaitGroup
|
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) {
|
) (*Pool, error) {
|
||||||
if id == "" {
|
|
||||||
return nil, ErrInvalidPoolID
|
|
||||||
}
|
|
||||||
|
|
||||||
if config == nil {
|
if config == nil {
|
||||||
config = GetDefaultPoolConfig()
|
config = GetDefaultPoolConfig()
|
||||||
}
|
}
|
||||||
@@ -104,8 +99,9 @@ func NewPool(ctx context.Context, id string, config *PoolConfig, handler slog.Ha
|
|||||||
// deadlocks.
|
// deadlocks.
|
||||||
if config.WorkerFactory == nil {
|
if config.WorkerFactory == nil {
|
||||||
config.WorkerFactory = func(
|
config.WorkerFactory = func(
|
||||||
ctx context.Context, id string, logger *slog.Logger) (Worker, error) {
|
ctx context.Context, id string, handler slog.Handler) (Worker, error) {
|
||||||
return NewWorker(ctx, id, config.WorkerConfig, logger)
|
wc := config.WorkerConfig
|
||||||
|
return NewWorker(ctx, id, &wc, handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,27 +109,36 @@ func NewPool(ctx context.Context, id string, config *PoolConfig, handler slog.Ha
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(component.MustNew(ctx, "honeybee", "pool"))
|
||||||
|
|
||||||
var logger *slog.Logger
|
var logger *slog.Logger
|
||||||
if handler != nil && config.LoggingEnabled {
|
if handler != nil {
|
||||||
logger = logging.NewOutboundPoolLogger(
|
c := component.FromContext(ctx)
|
||||||
logging.WrapOrDefault(config.LogLevel, handler), id)
|
logger = slog.New(handler).With(slog.Any("component", c))
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialer types.Dialer
|
||||||
|
if config.ConnectionConfig.Dialer != nil {
|
||||||
|
dialer = config.ConnectionConfig.Dialer
|
||||||
|
} else {
|
||||||
|
dialer = transport.NewDialer()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Pool{
|
return &Pool{
|
||||||
ctx: pctx,
|
peers: make(map[string]*Peer),
|
||||||
cancel: cancel,
|
inbox: make(chan types.InboxMessage, config.InboxBufferSize),
|
||||||
id: id,
|
events: make(chan PoolEvent, config.EventsBufferSize),
|
||||||
peers: make(map[string]*Peer),
|
|
||||||
inbox: make(chan types.InboxMessage, config.InboxBufferSize),
|
dialer: dialer,
|
||||||
events: make(chan PoolEvent, config.EventsBufferSize),
|
config: config,
|
||||||
|
handler: handler,
|
||||||
|
logger: logger,
|
||||||
|
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
|
||||||
inboxCounter: &atomic.Uint64{},
|
inboxCounter: &atomic.Uint64{},
|
||||||
outgoingCount: &atomic.Uint64{},
|
outgoingCount: &atomic.Uint64{},
|
||||||
dialer: transport.NewDialer(),
|
|
||||||
config: config,
|
|
||||||
handler: handler,
|
|
||||||
logger: logger,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +147,7 @@ func (p *Pool) Peers() []string {
|
|||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|
||||||
ids := make([]string, 0, len(p.peers))
|
ids := make([]string, 0, len(p.peers))
|
||||||
for i, _ := range p.peers {
|
for i := range p.peers {
|
||||||
ids = append(ids, i)
|
ids = append(ids, i)
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
@@ -196,16 +201,9 @@ func (p *Pool) PeerStats(id string) (PeerStats, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pool) SetDialer(d types.Dialer) {
|
|
||||||
if d == nil {
|
|
||||||
panic("dialer cannot be nil")
|
|
||||||
}
|
|
||||||
p.dialer = d
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Pool) Close() {
|
func (p *Pool) Close() {
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Debug("closing")
|
p.logger.Info("closing")
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -233,9 +231,24 @@ func (p *Pool) Close() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pool) Connect(id string) error {
|
// ConnectOption configures a single Connect call.
|
||||||
|
type ConnectOption func(*connectOptions)
|
||||||
|
|
||||||
|
type connectOptions struct {
|
||||||
|
dialer types.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDialer returns a ConnectOption that overrides the pool dialer for this
|
||||||
|
// connection only.
|
||||||
|
func WithDialer(d types.Dialer) ConnectOption {
|
||||||
|
return func(o *connectOptions) {
|
||||||
|
o.dialer = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Connect(id string, opts ...ConnectOption) error {
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Debug("connecting to peer", "peer", id)
|
p.logger.Info("connecting", "peer", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := transport.NormalizeURL(id)
|
id, err := transport.NormalizeURL(id)
|
||||||
@@ -254,38 +267,39 @@ func (p *Pool) Connect(id string) error {
|
|||||||
return NewPoolError(ErrPeerExists)
|
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
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o := &connectOptions{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(o)
|
||||||
|
}
|
||||||
|
effectiveDialer := p.dialer
|
||||||
|
if o.dialer != nil {
|
||||||
|
effectiveDialer = o.dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := p.config.ConnectionConfig.Clone()
|
||||||
|
cc.Dialer = effectiveDialer
|
||||||
|
|
||||||
pool := PoolPlugin{
|
pool := PoolPlugin{
|
||||||
ID: p.id,
|
|
||||||
Inbox: p.inbox,
|
Inbox: p.inbox,
|
||||||
Events: p.events,
|
Events: p.events,
|
||||||
InboxCounter: p.inboxCounter,
|
InboxCounter: p.inboxCounter,
|
||||||
Dialer: p.dialer,
|
ConnectionConfig: cc,
|
||||||
ConnectionConfig: p.config.ConnectionConfig,
|
|
||||||
Handler: p.handler,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p.wg.Add(1)
|
p.wg.Go(func() {
|
||||||
go func() {
|
|
||||||
worker.Start(pool)
|
worker.Start(pool)
|
||||||
p.wg.Done()
|
})
|
||||||
}()
|
|
||||||
|
|
||||||
p.peers[id] = &Peer{id: id, worker: worker}
|
p.peers[id] = &Peer{id: id, worker: worker}
|
||||||
|
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Info("registered peer", "peer", id)
|
p.logger.Debug("registered peer", "peer", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -293,7 +307,7 @@ func (p *Pool) Connect(id string) error {
|
|||||||
|
|
||||||
func (p *Pool) Remove(id string) error {
|
func (p *Pool) Remove(id string) error {
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Debug("disconnecting from peer", "peer", id)
|
p.logger.Info("disconnecting", "peer", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := transport.NormalizeURL(id)
|
id, err := transport.NormalizeURL(id)
|
||||||
@@ -317,7 +331,7 @@ func (p *Pool) Remove(id string) error {
|
|||||||
peer.worker.Stop()
|
peer.worker.Stop()
|
||||||
|
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Info("disconnected from peer", "peer", id)
|
p.logger.Debug("disconnected from peer", "peer", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package outbound
|
package honeybee
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/types"
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -15,14 +16,20 @@ import (
|
|||||||
|
|
||||||
func setupPool(t *testing.T) (*Pool, *honeybeetest.MockDialer) {
|
func setupPool(t *testing.T) (*Pool, *honeybeetest.MockDialer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
dialer := &honeybeetest.MockDialer{
|
dialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
return honeybeetest.NewMockSocket(), nil, nil
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
pool.dialer = dialer
|
cc := *transport.GetDefaultConnectionConfig()
|
||||||
|
cc.Dialer = dialer
|
||||||
|
pool, err := NewPool(context.Background(), &PoolConfig{
|
||||||
|
InboxBufferSize: 256,
|
||||||
|
EventsBufferSize: 10,
|
||||||
|
ConnectionConfig: cc,
|
||||||
|
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||||
|
}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
return pool, dialer
|
return pool, dialer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +52,6 @@ func expectEvent(
|
|||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
|
|
||||||
func TestPoolID(t *testing.T) {
|
|
||||||
_, err := NewPool(context.Background(), "", nil, nil)
|
|
||||||
assert.ErrorIs(t, err, ErrInvalidPoolID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPoolConnect(t *testing.T) {
|
func TestPoolConnect(t *testing.T) {
|
||||||
t.Run("successfully adds connection", func(t *testing.T) {
|
t.Run("successfully adds connection", func(t *testing.T) {
|
||||||
pool, _ := setupPool(t)
|
pool, _ := setupPool(t)
|
||||||
@@ -88,9 +90,54 @@ func TestPoolConnect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPoolConnectWithDialer(t *testing.T) {
|
||||||
|
t.Run("per-call dialer is used instead of pool dialer", func(t *testing.T) {
|
||||||
|
perCallUsed := false
|
||||||
|
perCallDialer := &honeybeetest.MockDialer{
|
||||||
|
DialContextFunc: func(ctx context.Context, url string, h http.Header) (types.Socket, *http.Response, error) {
|
||||||
|
perCallUsed = true
|
||||||
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// pool dialer should NOT be called
|
||||||
|
poolDialer := &honeybeetest.MockDialer{
|
||||||
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
|
t.Error("pool dialer should not be called when per-call dialer is provided")
|
||||||
|
return nil, nil, fmt.Errorf("unexpected call")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := *transport.GetDefaultConnectionConfig()
|
||||||
|
cc.Dialer = poolDialer
|
||||||
|
pool, err := NewPool(context.Background(), &PoolConfig{
|
||||||
|
InboxBufferSize: 256,
|
||||||
|
EventsBufferSize: 10,
|
||||||
|
ConnectionConfig: cc,
|
||||||
|
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||||
|
}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = pool.Connect("wss://test", WithDialer(perCallDialer))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-pool.events:
|
||||||
|
return e.ID == "wss://test" && e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected connected event")
|
||||||
|
|
||||||
|
assert.True(t, perCallUsed, "per-call dialer was not used")
|
||||||
|
pool.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestPoolClose(t *testing.T) {
|
func TestPoolClose(t *testing.T) {
|
||||||
t.Run("channels close after pool close", func(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()
|
pool.Close()
|
||||||
_, ok := <-pool.Inbox()
|
_, ok := <-pool.Inbox()
|
||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
@@ -99,7 +146,7 @@ func TestPoolClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("connect after close returns error", func(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()
|
pool.Close()
|
||||||
err := pool.Connect("wss://test")
|
err := pool.Connect("wss://test")
|
||||||
assert.ErrorIs(t, err, ErrPoolClosed)
|
assert.ErrorIs(t, err, ErrPoolClosed)
|
||||||
@@ -157,9 +204,15 @@ func TestPoolSend(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pool, err := NewPool(context.Background(), "pool-1", nil, nil)
|
cc := *transport.GetDefaultConnectionConfig()
|
||||||
|
cc.Dialer = mockDialer
|
||||||
|
pool, err := NewPool(context.Background(), &PoolConfig{
|
||||||
|
InboxBufferSize: 256,
|
||||||
|
EventsBufferSize: 10,
|
||||||
|
ConnectionConfig: cc,
|
||||||
|
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||||
|
}, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
pool.dialer = mockDialer
|
|
||||||
|
|
||||||
err = pool.Connect("wss://test")
|
err = pool.Connect("wss://test")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
-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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+31
-42
@@ -1,11 +1,17 @@
|
|||||||
package transport
|
package transport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Connection Config
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
type CloseHandler func(code int, text string) error
|
type CloseHandler func(code int, text string) error
|
||||||
|
|
||||||
type ConnectionConfig struct {
|
type ConnectionConfig struct {
|
||||||
@@ -15,12 +21,12 @@ type ConnectionConfig struct {
|
|||||||
PingInterval time.Duration
|
PingInterval time.Duration
|
||||||
IncomingBufferSize int
|
IncomingBufferSize int
|
||||||
ErrorsBufferSize int
|
ErrorsBufferSize int
|
||||||
LoggingEnabled bool
|
Retry RetryConfig
|
||||||
LogLevel *slog.Level
|
Dialer types.Dialer
|
||||||
Retry *RetryConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RetryConfig struct {
|
type RetryConfig struct {
|
||||||
|
Disabled bool
|
||||||
MaxRetries int
|
MaxRetries int
|
||||||
InitialDelay time.Duration
|
InitialDelay time.Duration
|
||||||
MaxDelay time.Duration
|
MaxDelay time.Duration
|
||||||
@@ -29,6 +35,8 @@ type RetryConfig struct {
|
|||||||
|
|
||||||
type ConnectionOption func(*ConnectionConfig) error
|
type ConnectionOption func(*ConnectionConfig) error
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
|
||||||
func NewConnectionConfig(options ...ConnectionOption) (*ConnectionConfig, error) {
|
func NewConnectionConfig(options ...ConnectionOption) (*ConnectionConfig, error) {
|
||||||
conf := GetDefaultConnectionConfig()
|
conf := GetDefaultConnectionConfig()
|
||||||
if err := applyConnectionOptions(conf, options...); err != nil {
|
if err := applyConnectionOptions(conf, options...); err != nil {
|
||||||
@@ -50,19 +58,20 @@ func GetDefaultConnectionConfig() *ConnectionConfig {
|
|||||||
PingInterval: 20 * time.Second,
|
PingInterval: 20 * time.Second,
|
||||||
IncomingBufferSize: 100,
|
IncomingBufferSize: 100,
|
||||||
ErrorsBufferSize: 10,
|
ErrorsBufferSize: 10,
|
||||||
LoggingEnabled: true,
|
Retry: RetryConfig{
|
||||||
LogLevel: nil,
|
MaxRetries: 0, // Infinite retries
|
||||||
Retry: GetDefaultRetryConfig(),
|
InitialDelay: 1 * time.Second,
|
||||||
|
MaxDelay: 60 * time.Second,
|
||||||
|
JitterFactor: 0.2,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDefaultRetryConfig() *RetryConfig {
|
func (c ConnectionConfig) Clone() ConnectionConfig {
|
||||||
return &RetryConfig{
|
if c.RequestHeader != nil {
|
||||||
MaxRetries: 0, // Infinite retries
|
c.RequestHeader = c.RequestHeader.Clone()
|
||||||
InitialDelay: 1 * time.Second,
|
|
||||||
MaxDelay: 60 * time.Second,
|
|
||||||
JitterFactor: 0.2,
|
|
||||||
}
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyConnectionOptions(config *ConnectionConfig, options ...ConnectionOption) error {
|
func applyConnectionOptions(config *ConnectionConfig, options ...ConnectionOption) error {
|
||||||
@@ -74,13 +83,15 @@ func applyConnectionOptions(config *ConnectionConfig, options ...ConnectionOptio
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
|
||||||
func ValidateConnectionConfig(config *ConnectionConfig) error {
|
func ValidateConnectionConfig(config *ConnectionConfig) error {
|
||||||
err := validateWriteTimeout(config.WriteTimeout)
|
err := validateWriteTimeout(config.WriteTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Retry != nil {
|
if !config.Retry.Disabled {
|
||||||
err = validateMaxRetries(config.Retry.MaxRetries)
|
err = validateMaxRetries(config.Retry.MaxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -158,6 +169,8 @@ func validateJitterFactor(value float64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options
|
||||||
|
|
||||||
func WithCloseHandler(handler CloseHandler) ConnectionOption {
|
func WithCloseHandler(handler CloseHandler) ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
c.CloseHandler = handler
|
c.CloseHandler = handler
|
||||||
@@ -216,34 +229,22 @@ func WithErrorsBufferSize(value int) ConnectionOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithLoggingEnabled(value bool) ConnectionOption {
|
func WithConnectionDialer(d types.Dialer) ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
c.LoggingEnabled = value
|
c.Dialer = d
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithLogLevel(level slog.Level) ConnectionOption {
|
func WithRetryDisabled() ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
l := level
|
c.Retry.Disabled = true
|
||||||
c.LogLevel = &l
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithoutRetry() ConnectionOption {
|
|
||||||
return func(c *ConnectionConfig) error {
|
|
||||||
c.Retry = nil
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithRetryMaxRetries(value int) ConnectionOption {
|
func WithRetryMaxRetries(value int) ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
if c.Retry == nil {
|
|
||||||
c.Retry = GetDefaultRetryConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
err := validateMaxRetries(value)
|
err := validateMaxRetries(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -256,10 +257,6 @@ func WithRetryMaxRetries(value int) ConnectionOption {
|
|||||||
|
|
||||||
func WithRetryInitialDelay(value time.Duration) ConnectionOption {
|
func WithRetryInitialDelay(value time.Duration) ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
if c.Retry == nil {
|
|
||||||
c.Retry = GetDefaultRetryConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
err := validateInitialDelay(value)
|
err := validateInitialDelay(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -272,10 +269,6 @@ func WithRetryInitialDelay(value time.Duration) ConnectionOption {
|
|||||||
|
|
||||||
func WithRetryMaxDelay(value time.Duration) ConnectionOption {
|
func WithRetryMaxDelay(value time.Duration) ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
if c.Retry == nil {
|
|
||||||
c.Retry = GetDefaultRetryConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
err := validateMaxDelay(value)
|
err := validateMaxDelay(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -288,10 +281,6 @@ func WithRetryMaxDelay(value time.Duration) ConnectionOption {
|
|||||||
|
|
||||||
func WithRetryJitterFactor(value float64) ConnectionOption {
|
func WithRetryJitterFactor(value float64) ConnectionOption {
|
||||||
return func(c *ConnectionConfig) error {
|
return func(c *ConnectionConfig) error {
|
||||||
if c.Retry == nil {
|
|
||||||
c.Retry = GetDefaultRetryConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
err := validateJitterFactor(value)
|
err := validateJitterFactor(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+39
-24
@@ -1,8 +1,8 @@
|
|||||||
package transport
|
package transport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,20 +36,12 @@ func TestDefaultConnectionConfig(t *testing.T) {
|
|||||||
PingInterval: 20 * time.Second,
|
PingInterval: 20 * time.Second,
|
||||||
IncomingBufferSize: 100,
|
IncomingBufferSize: 100,
|
||||||
ErrorsBufferSize: 10,
|
ErrorsBufferSize: 10,
|
||||||
LoggingEnabled: true,
|
Retry: RetryConfig{
|
||||||
LogLevel: nil,
|
MaxRetries: 0,
|
||||||
Retry: GetDefaultRetryConfig(),
|
InitialDelay: 1 * time.Second,
|
||||||
})
|
MaxDelay: 60 * time.Second,
|
||||||
}
|
JitterFactor: 0.2,
|
||||||
|
},
|
||||||
func TestDefaultRetryConnectionConfig(t *testing.T) {
|
|
||||||
conf := GetDefaultRetryConfig()
|
|
||||||
|
|
||||||
assert.Equal(t, conf, &RetryConfig{
|
|
||||||
MaxRetries: 0,
|
|
||||||
InitialDelay: 1 * time.Second,
|
|
||||||
MaxDelay: 60 * time.Second,
|
|
||||||
JitterFactor: 0.2,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +53,6 @@ func TestApplyConnectionOptions(t *testing.T) {
|
|||||||
conf,
|
conf,
|
||||||
WithIncomingBufferSize(256),
|
WithIncomingBufferSize(256),
|
||||||
WithErrorsBufferSize(100),
|
WithErrorsBufferSize(100),
|
||||||
WithLoggingEnabled(false),
|
|
||||||
WithLogLevel(slog.LevelError),
|
|
||||||
WithRetryMaxRetries(0),
|
WithRetryMaxRetries(0),
|
||||||
WithRetryInitialDelay(3*time.Second),
|
WithRetryInitialDelay(3*time.Second),
|
||||||
WithRetryJitterFactor(0.5),
|
WithRetryJitterFactor(0.5),
|
||||||
@@ -71,8 +61,6 @@ func TestApplyConnectionOptions(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 256, conf.IncomingBufferSize)
|
assert.Equal(t, 256, conf.IncomingBufferSize)
|
||||||
assert.Equal(t, 100, conf.ErrorsBufferSize)
|
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, 0, conf.Retry.MaxRetries)
|
||||||
assert.Equal(t, 3*time.Second, conf.Retry.InitialDelay)
|
assert.Equal(t, 3*time.Second, conf.Retry.InitialDelay)
|
||||||
assert.Equal(t, 0.5, conf.Retry.JitterFactor)
|
assert.Equal(t, 0.5, conf.Retry.JitterFactor)
|
||||||
@@ -121,10 +109,10 @@ func TestWithWriteTimeout(t *testing.T) {
|
|||||||
func TestWithRetry(t *testing.T) {
|
func TestWithRetry(t *testing.T) {
|
||||||
t.Run("without retry", func(t *testing.T) {
|
t.Run("without retry", func(t *testing.T) {
|
||||||
conf := GetDefaultConnectionConfig()
|
conf := GetDefaultConnectionConfig()
|
||||||
opt := WithoutRetry()
|
opt := WithRetryDisabled()
|
||||||
err := applyConnectionOptions(conf, opt)
|
err := applyConnectionOptions(conf, opt)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Nil(t, conf.Retry)
|
assert.True(t, conf.Retry.Disabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("with attempts", func(t *testing.T) {
|
t.Run("with attempts", func(t *testing.T) {
|
||||||
@@ -216,7 +204,7 @@ func TestValidateConnectionConfig(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid empty",
|
name: "valid empty",
|
||||||
conf: *&ConnectionConfig{},
|
conf: ConnectionConfig{Retry: RetryConfig{Disabled: true}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid defaults",
|
name: "valid defaults",
|
||||||
@@ -227,7 +215,7 @@ func TestValidateConnectionConfig(t *testing.T) {
|
|||||||
conf: ConnectionConfig{
|
conf: ConnectionConfig{
|
||||||
CloseHandler: (func(code int, text string) error { return nil }),
|
CloseHandler: (func(code int, text string) error { return nil }),
|
||||||
WriteTimeout: time.Duration(30),
|
WriteTimeout: time.Duration(30),
|
||||||
Retry: &RetryConfig{
|
Retry: RetryConfig{
|
||||||
MaxRetries: 0,
|
MaxRetries: 0,
|
||||||
InitialDelay: 2 * time.Second,
|
InitialDelay: 2 * time.Second,
|
||||||
MaxDelay: 10 * time.Second,
|
MaxDelay: 10 * time.Second,
|
||||||
@@ -238,7 +226,7 @@ func TestValidateConnectionConfig(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "invalid - initial delay > max delay",
|
name: "invalid - initial delay > max delay",
|
||||||
conf: ConnectionConfig{
|
conf: ConnectionConfig{
|
||||||
Retry: &RetryConfig{
|
Retry: RetryConfig{
|
||||||
InitialDelay: 10 * time.Second,
|
InitialDelay: 10 * time.Second,
|
||||||
MaxDelay: 1 * time.Second,
|
MaxDelay: 1 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -266,3 +254,30 @@ func TestValidateConnectionConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConnectionConfigClone(t *testing.T) {
|
||||||
|
header := http.Header{}
|
||||||
|
header.Set("X-Test", "val")
|
||||||
|
orig := ConnectionConfig{
|
||||||
|
RequestHeader: header,
|
||||||
|
WriteTimeout: 5 * time.Second,
|
||||||
|
Retry: RetryConfig{Disabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := orig.Clone()
|
||||||
|
|
||||||
|
// values match
|
||||||
|
assert.Equal(t, orig.WriteTimeout, cloned.WriteTimeout)
|
||||||
|
assert.Equal(t, "val", cloned.RequestHeader.Get("X-Test"))
|
||||||
|
|
||||||
|
// header is a distinct copy
|
||||||
|
cloned.RequestHeader.Set("X-Test", "mutated")
|
||||||
|
assert.Equal(t, "val", orig.RequestHeader.Get("X-Test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithConnectionDialer(t *testing.T) {
|
||||||
|
mock := &honeybeetest.MockDialer{}
|
||||||
|
conf, err := NewConnectionConfig(WithConnectionDialer(mock))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, mock, conf.Dialer)
|
||||||
|
}
|
||||||
|
|||||||
+295
-236
@@ -12,9 +12,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wisehodl.dev/jay/go-honeybee/types"
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
|
"git.wisehodl.dev/jay/go-mana-component"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
type ConnectionState int
|
type ConnectionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -48,11 +53,19 @@ type ConnectionStats struct {
|
|||||||
TotalHeartbeats uint64
|
TotalHeartbeats uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Connection
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Constructors
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
type Connection struct {
|
type Connection struct {
|
||||||
url *url.URL
|
url *url.URL
|
||||||
dialer types.Dialer
|
dialer types.Dialer
|
||||||
socket types.Socket
|
socket types.Socket
|
||||||
config *ConnectionConfig
|
config ConnectionConfig
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
incoming chan []byte
|
incoming chan []byte
|
||||||
@@ -74,7 +87,7 @@ type Connection struct {
|
|||||||
cleanupOnce sync.Once
|
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 {
|
if config == nil {
|
||||||
config = GetDefaultConnectionConfig()
|
config = GetDefaultConnectionConfig()
|
||||||
}
|
}
|
||||||
@@ -88,15 +101,26 @@ func NewConnection(urlStr string, config *ConnectionConfig, logger *slog.Logger)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if component.FromContext(ctx) == nil {
|
||||||
|
ctx = component.MustNew(ctx, "honeybee", "connection")
|
||||||
|
} else {
|
||||||
|
ctx = component.MustExtend(ctx, "connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone config to ensure full ownership of all fields.
|
||||||
|
cc := config.Clone()
|
||||||
|
if cc.Dialer == nil {
|
||||||
|
cc.Dialer = NewDialer()
|
||||||
|
}
|
||||||
|
|
||||||
conn := &Connection{
|
conn := &Connection{
|
||||||
url: url,
|
url: url,
|
||||||
dialer: NewDialer(),
|
dialer: cc.Dialer,
|
||||||
socket: nil,
|
socket: nil,
|
||||||
config: config,
|
config: cc,
|
||||||
logger: logger,
|
incoming: make(chan []byte, cc.IncomingBufferSize),
|
||||||
incoming: make(chan []byte, config.IncomingBufferSize),
|
|
||||||
heartbeat: make(chan struct{}, 1),
|
heartbeat: make(chan struct{}, 1),
|
||||||
errors: make(chan error, config.ErrorsBufferSize),
|
errors: make(chan error, cc.ErrorsBufferSize),
|
||||||
incomingCount: &atomic.Uint64{},
|
incomingCount: &atomic.Uint64{},
|
||||||
outgoingCount: &atomic.Uint64{},
|
outgoingCount: &atomic.Uint64{},
|
||||||
heartbeatCount: &atomic.Uint64{},
|
heartbeatCount: &atomic.Uint64{},
|
||||||
@@ -104,11 +128,16 @@ func NewConnection(urlStr string, config *ConnectionConfig, logger *slog.Logger)
|
|||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if handler != nil {
|
||||||
|
comp := component.FromContext(ctx)
|
||||||
|
conn.logger = slog.New(handler).With(slog.Any("component", comp))
|
||||||
|
}
|
||||||
|
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnectionFromSocket(
|
func NewConnectionFromSocket(
|
||||||
socket types.Socket, config *ConnectionConfig, logger *slog.Logger,
|
ctx context.Context, socket types.Socket, config *ConnectionConfig, handler slog.Handler,
|
||||||
) (*Connection, error) {
|
) (*Connection, error) {
|
||||||
if socket == nil {
|
if socket == nil {
|
||||||
return nil, NewConnectionError(ErrNilSocket)
|
return nil, NewConnectionError(ErrNilSocket)
|
||||||
@@ -122,15 +151,23 @@ func NewConnectionFromSocket(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if component.FromContext(ctx) == nil {
|
||||||
|
ctx = component.MustNew(ctx, "honeybee", "connection")
|
||||||
|
} else {
|
||||||
|
ctx = component.MustExtend(ctx, "connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone config to ensure full ownership of all fields.
|
||||||
|
cc := config.Clone()
|
||||||
|
|
||||||
conn := &Connection{
|
conn := &Connection{
|
||||||
url: nil,
|
url: nil,
|
||||||
dialer: nil,
|
dialer: nil,
|
||||||
socket: socket,
|
socket: socket,
|
||||||
config: config,
|
config: cc,
|
||||||
logger: logger,
|
incoming: make(chan []byte, cc.IncomingBufferSize),
|
||||||
incoming: make(chan []byte, config.IncomingBufferSize),
|
|
||||||
heartbeat: make(chan struct{}, 1),
|
heartbeat: make(chan struct{}, 1),
|
||||||
errors: make(chan error, config.ErrorsBufferSize),
|
errors: make(chan error, cc.ErrorsBufferSize),
|
||||||
incomingCount: &atomic.Uint64{},
|
incomingCount: &atomic.Uint64{},
|
||||||
outgoingCount: &atomic.Uint64{},
|
outgoingCount: &atomic.Uint64{},
|
||||||
heartbeatCount: &atomic.Uint64{},
|
heartbeatCount: &atomic.Uint64{},
|
||||||
@@ -138,17 +175,31 @@ func NewConnectionFromSocket(
|
|||||||
done: make(chan struct{}),
|
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 {
|
if config.CloseHandler != nil {
|
||||||
socket.SetCloseHandler(config.CloseHandler)
|
socket.SetCloseHandler(config.CloseHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.setupPongHandler()
|
conn.setupPongHandler()
|
||||||
conn.startPinger()
|
|
||||||
conn.startReader()
|
if conn.config.PingInterval > 0 {
|
||||||
|
conn.wg.Go(conn.startPinger)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.wg.Go(conn.startReader)
|
||||||
|
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Methods
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
func (c *Connection) Connect(ctx context.Context) error {
|
func (c *Connection) Connect(ctx context.Context) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -161,253 +212,53 @@ func (c *Connection) Connect(ctx context.Context) error {
|
|||||||
return NewConnectionError(ErrConnectionClosed)
|
return NewConnectionError(ErrConnectionClosed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// begin connecting
|
||||||
if c.logger != nil {
|
if c.logger != nil {
|
||||||
c.logger.Debug("connecting")
|
c.logger.Debug("connecting")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.state = StateConnecting
|
c.state = StateConnecting
|
||||||
|
|
||||||
|
// obtain socket
|
||||||
retryMgr := NewRetryManager(c.config.Retry)
|
retryMgr := NewRetryManager(c.config.Retry)
|
||||||
socket, _, err := AcquireSocket(
|
socket, _, err := AcquireSocket(
|
||||||
ctx, retryMgr, c.dialer, c.url.String(), c.config.RequestHeader, c.logger)
|
ctx, retryMgr, c.dialer, c.url.String(), c.config.RequestHeader, c.logger)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// socket acquisition failed
|
||||||
c.state = StateDisconnected
|
c.state = StateDisconnected
|
||||||
if c.logger != nil {
|
if c.logger != nil {
|
||||||
c.logger.Error("connection failed", "error", err)
|
c.logger.Warn("connection failed", "error", err)
|
||||||
}
|
}
|
||||||
return NewConnectionError(err)
|
return NewConnectionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// got socket
|
||||||
c.socket = socket
|
c.socket = socket
|
||||||
c.state = StateConnected
|
|
||||||
|
|
||||||
|
// initialize
|
||||||
if c.config.CloseHandler != nil {
|
if c.config.CloseHandler != nil {
|
||||||
c.socket.SetCloseHandler(c.config.CloseHandler)
|
c.socket.SetCloseHandler(c.config.CloseHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.logger != nil {
|
|
||||||
c.logger.Info("connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setupPongHandler()
|
c.setupPongHandler()
|
||||||
c.startPinger()
|
|
||||||
c.startReader()
|
if c.config.PingInterval > 0 {
|
||||||
|
c.wg.Go(c.startPinger)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.wg.Go(c.startReader)
|
||||||
|
|
||||||
|
// connected
|
||||||
|
c.state = StateConnected
|
||||||
|
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Debug("connected")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
func (c *Connection) Send(data []byte) error {
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
defer c.writeMu.Unlock()
|
defer c.writeMu.Unlock()
|
||||||
@@ -416,6 +267,7 @@ func (c *Connection) Send(data []byte) error {
|
|||||||
return NewConnectionError(ErrConnectionClosed)
|
return NewConnectionError(ErrConnectionClosed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setup
|
||||||
if c.config.WriteTimeout > 0 {
|
if c.config.WriteTimeout > 0 {
|
||||||
if err := c.socket.SetWriteDeadline(time.Now().Add(c.config.WriteTimeout)); err != nil {
|
if err := c.socket.SetWriteDeadline(time.Now().Add(c.config.WriteTimeout)); err != nil {
|
||||||
if c.logger != nil {
|
if c.logger != nil {
|
||||||
@@ -425,7 +277,10 @@ func (c *Connection) Send(data []byte) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.socket.WriteMessage(websocket.TextMessage, data); err != nil {
|
// send
|
||||||
|
err := c.socket.WriteMessage(websocket.TextMessage, data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
if c.logger != nil {
|
if c.logger != nil {
|
||||||
c.logger.Error("write error", "error", err)
|
c.logger.Error("write error", "error", err)
|
||||||
}
|
}
|
||||||
@@ -465,6 +320,210 @@ func (c *Connection) Stats() ConnectionStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Connection) SetDialer(d types.Dialer) {
|
// ---------------------------/
|
||||||
c.dialer = d
|
// Reader loop
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
func (c *Connection) startReader() {
|
||||||
|
defer c.shutdownInternal()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
messageType, data, err := c.socket.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
case c.errors <- c.classifyCloseError(err):
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageType == websocket.TextMessage ||
|
||||||
|
messageType == websocket.BinaryMessage {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
case c.incoming <- data:
|
||||||
|
c.incomingCount.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) classifyCloseError(err error) error {
|
||||||
|
var classifiedError error
|
||||||
|
var closeErr *websocket.CloseError
|
||||||
|
|
||||||
|
if errors.As(err, &closeErr) {
|
||||||
|
switch closeErr.Code {
|
||||||
|
case websocket.CloseNormalClosure, websocket.CloseGoingAway:
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Debug("connection closed by peer",
|
||||||
|
"code", closeErr.Code,
|
||||||
|
"text", closeErr.Text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
classifiedError = fmt.Errorf("%w: %w", ErrPeerClosedClean, err)
|
||||||
|
|
||||||
|
default:
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Warn("unexpected close",
|
||||||
|
"code", closeErr.Code,
|
||||||
|
"text", closeErr.Text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
classifiedError = fmt.Errorf("%w: %w", ErrPeerClosedUnexpected, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
isLocalClose := false
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
isLocalClose = true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.logger != nil {
|
||||||
|
if isLocalClose {
|
||||||
|
c.logger.Debug("read loop terminated", "error", err)
|
||||||
|
} else {
|
||||||
|
c.logger.Error("read error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
classifiedError = fmt.Errorf("%w: %w", ErrReadError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return classifiedError
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Heartbeat Handling
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
func (c *Connection) setupPongHandler() {
|
||||||
|
c.socket.SetPongHandler(func(appData string) error {
|
||||||
|
select {
|
||||||
|
case c.heartbeat <- struct{}{}:
|
||||||
|
c.heartbeatCount.Add(1)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) startPinger() {
|
||||||
|
defer c.shutdownInternal()
|
||||||
|
|
||||||
|
// Calculate 10% jitter window
|
||||||
|
jitter := c.config.PingInterval / 10
|
||||||
|
|
||||||
|
for {
|
||||||
|
offset := time.Duration(rand.Int63n(int64(jitter*2))) - jitter
|
||||||
|
next := c.config.PingInterval + offset
|
||||||
|
timer := time.NewTimer(next)
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
deadline := time.Now().Add(c.config.WriteTimeout)
|
||||||
|
err := c.socket.WriteControl(websocket.PingMessage, nil, deadline)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Shutdown
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
func (c *Connection) Close() {
|
||||||
|
c.shutdownExternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) shutdownExternal() {
|
||||||
|
// set closed
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closed {
|
||||||
|
// idempotent shutdown
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.closed = true
|
||||||
|
c.state = StateClosed
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// perform shutdown
|
||||||
|
c.shutdownInner()
|
||||||
|
c.shutdownCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownInternal defers final cleanup to allow it to return.
|
||||||
|
// Otherwise, a deadlock occurs where startReader triggers a shutdown and
|
||||||
|
// must wait for itself to exit.
|
||||||
|
func (c *Connection) shutdownInternal() {
|
||||||
|
// set closed
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closed {
|
||||||
|
// idempotent shutdown
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.closed = true
|
||||||
|
c.state = StateClosed
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// perform shutdown
|
||||||
|
c.shutdownInner()
|
||||||
|
|
||||||
|
// defer cleanup to avoid deadlock
|
||||||
|
go func() {
|
||||||
|
c.shutdownCleanup()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) shutdownInner() {
|
||||||
|
c.doneOnce.Do(func() {
|
||||||
|
close(c.done)
|
||||||
|
})
|
||||||
|
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Debug("closing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.socket != nil {
|
||||||
|
// force unblock of any network operations immediately
|
||||||
|
expired := time.Now().Add(-1 * time.Minute)
|
||||||
|
c.socket.SetReadDeadline(expired)
|
||||||
|
c.socket.SetWriteDeadline(expired)
|
||||||
|
|
||||||
|
// close socket
|
||||||
|
err := c.socket.Close()
|
||||||
|
|
||||||
|
if err != nil && c.logger != nil {
|
||||||
|
c.logger.Error("socket close failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) shutdownCleanup() {
|
||||||
|
c.cleanupOnce.Do(func() {
|
||||||
|
c.wg.Wait()
|
||||||
|
|
||||||
|
close(c.incoming)
|
||||||
|
close(c.errors)
|
||||||
|
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Debug("closed")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package transport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -11,7 +12,7 @@ import (
|
|||||||
|
|
||||||
func TestDisconnectedConnectionClose(t *testing.T) {
|
func TestDisconnectedConnectionClose(t *testing.T) {
|
||||||
t.Run("close succeeds on disconnected connection", func(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.NoError(t, err)
|
||||||
assert.Equal(t, StateDisconnected, conn.State())
|
assert.Equal(t, StateDisconnected, conn.State())
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("close is idempotent", func(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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -29,7 +30,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("close with nil socket", func(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.NoError(t, err)
|
||||||
assert.Nil(t, conn.socket)
|
assert.Nil(t, conn.socket)
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
|
|||||||
return expectedErr
|
return expectedErr
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnection("ws://test", nil, nil)
|
conn, err := NewConnection(context.Background(), "ws://test", nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
conn.socket = mockSocket
|
conn.socket = mockSocket
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("channels close after close", func(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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -66,7 +67,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("send fails after close", func(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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package transport
|
package transport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -66,7 +67,7 @@ func TestStartReader(t *testing.T) {
|
|||||||
return 0, nil, io.EOF
|
return 0, nil, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
honeybeetest.Eventually(t, func() bool {
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package transport
|
package transport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -62,12 +63,12 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for j := 0; j < 10; j++ {
|
for j := range 10 {
|
||||||
data := []byte(fmt.Sprintf("msg-%d-%d", id, j))
|
data := fmt.Appendf(nil, "msg-%d-%d", id, j)
|
||||||
for {
|
for {
|
||||||
// send and retry until success
|
// send and retry until success
|
||||||
err := conn.Send(data)
|
err := conn.Send(data)
|
||||||
@@ -101,7 +102,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("write timeout disabled when zero", func(t *testing.T) {
|
t.Run("write timeout disabled when zero", func(t *testing.T) {
|
||||||
config := &ConnectionConfig{WriteTimeout: 0}
|
config := &ConnectionConfig{WriteTimeout: 0, Retry: RetryConfig{Disabled: true}}
|
||||||
|
|
||||||
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
@@ -129,7 +130,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, config, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("write timeout sets deadline when positive", func(t *testing.T) {
|
t.Run("write timeout sets deadline when positive", func(t *testing.T) {
|
||||||
config := &ConnectionConfig{WriteTimeout: 30 * time.Millisecond}
|
config := &ConnectionConfig{WriteTimeout: 30 * time.Millisecond, Retry: RetryConfig{Disabled: true}}
|
||||||
|
|
||||||
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
@@ -175,7 +176,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, config, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("send fails on deadline error", func(t *testing.T) {
|
t.Run("send fails on deadline error", func(t *testing.T) {
|
||||||
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond}
|
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond, Retry: RetryConfig{Disabled: true}}
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
return fmt.Errorf("test error")
|
return fmt.Errorf("test error")
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, config, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ func TestConnectionSend(t *testing.T) {
|
|||||||
return writeErr
|
return writeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ func TestConnectionStateString(t *testing.T) {
|
|||||||
|
|
||||||
func TestConnectionState(t *testing.T) {
|
func TestConnectionState(t *testing.T) {
|
||||||
// Test initial state
|
// Test initial state
|
||||||
conn, _ := NewConnection("ws://test", nil, nil)
|
conn, _ := NewConnection(context.Background(), "ws://test", nil, nil)
|
||||||
assert.Equal(t, StateDisconnected, conn.State())
|
assert.Equal(t, StateDisconnected, conn.State())
|
||||||
|
|
||||||
// Test state after FromSocket (should be Connected)
|
// 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())
|
assert.Equal(t, StateConnected, conn2.State())
|
||||||
|
|
||||||
// Test state after close
|
// Test state after close
|
||||||
@@ -69,7 +69,7 @@ func TestNewConnection(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid url, valid config",
|
name: "valid url, valid config",
|
||||||
url: "wss://relay.example.com:8080/path",
|
url: "wss://relay.example.com:8080/path",
|
||||||
config: &ConnectionConfig{WriteTimeout: 30 * time.Second},
|
config: &ConnectionConfig{WriteTimeout: 30 * time.Second, Retry: RetryConfig{Disabled: true}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid url",
|
name: "invalid url",
|
||||||
@@ -82,7 +82,7 @@ func TestNewConnection(t *testing.T) {
|
|||||||
name: "invalid config",
|
name: "invalid config",
|
||||||
url: "ws://example.com",
|
url: "ws://example.com",
|
||||||
config: &ConnectionConfig{
|
config: &ConnectionConfig{
|
||||||
Retry: &RetryConfig{
|
Retry: RetryConfig{
|
||||||
InitialDelay: 10 * time.Second,
|
InitialDelay: 10 * time.Second,
|
||||||
MaxDelay: 1 * time.Second,
|
MaxDelay: 1 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -94,7 +94,7 @@ func TestNewConnection(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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 {
|
if tc.wantErr {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -121,9 +121,13 @@ func TestNewConnection(t *testing.T) {
|
|||||||
|
|
||||||
// Verify default config is used if nil is passed
|
// Verify default config is used if nil is passed
|
||||||
if tc.config == nil {
|
if tc.config == nil {
|
||||||
assert.Equal(t, GetDefaultConnectionConfig(), conn.config)
|
expected := *GetDefaultConnectionConfig()
|
||||||
|
expected.Dialer = conn.config.Dialer // dialer resolved at construction
|
||||||
|
assert.Equal(t, expected, conn.config)
|
||||||
} else {
|
} else {
|
||||||
assert.Equal(t, tc.config, conn.config)
|
expected := *tc.config
|
||||||
|
expected.Dialer = conn.config.Dialer
|
||||||
|
assert.Equal(t, expected, conn.config)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -152,13 +156,13 @@ func TestNewConnectionFromSocket(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid socket with valid config",
|
name: "valid socket with valid config",
|
||||||
socket: honeybeetest.NewMockSocket(),
|
socket: honeybeetest.NewMockSocket(),
|
||||||
config: &ConnectionConfig{WriteTimeout: 30 * time.Second},
|
config: &ConnectionConfig{WriteTimeout: 30 * time.Second, Retry: RetryConfig{Disabled: true}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid config",
|
name: "invalid config",
|
||||||
socket: honeybeetest.NewMockSocket(),
|
socket: honeybeetest.NewMockSocket(),
|
||||||
config: &ConnectionConfig{
|
config: &ConnectionConfig{
|
||||||
Retry: &RetryConfig{
|
Retry: RetryConfig{
|
||||||
InitialDelay: 10 * time.Second,
|
InitialDelay: 10 * time.Second,
|
||||||
MaxDelay: 1 * time.Second,
|
MaxDelay: 1 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -173,6 +177,7 @@ func TestNewConnectionFromSocket(t *testing.T) {
|
|||||||
CloseHandler: func(code int, text string) error {
|
CloseHandler: func(code int, text string) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
Retry: RetryConfig{Disabled: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -194,7 +199,7 @@ func TestNewConnectionFromSocket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(tc.socket, tc.config, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), tc.socket, tc.config, nil)
|
||||||
|
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -219,11 +224,19 @@ func TestNewConnectionFromSocket(t *testing.T) {
|
|||||||
assert.Equal(t, StateConnected, conn.state)
|
assert.Equal(t, StateConnected, conn.state)
|
||||||
assert.False(t, conn.closed)
|
assert.False(t, conn.closed)
|
||||||
|
|
||||||
// Verify default config is used if nil is passed
|
// Verify default config is used if nil is passed.
|
||||||
|
// CloseHandler is a func; exclude it from the struct comparison
|
||||||
|
// (identity is verified separately via closeHandlerSet).
|
||||||
|
gotCfg := conn.config
|
||||||
|
gotCfg.CloseHandler = nil
|
||||||
if tc.config == nil {
|
if tc.config == nil {
|
||||||
assert.Equal(t, GetDefaultConnectionConfig(), conn.config)
|
expected := *GetDefaultConnectionConfig()
|
||||||
|
expected.CloseHandler = nil
|
||||||
|
assert.Equal(t, expected, gotCfg)
|
||||||
} else {
|
} else {
|
||||||
assert.Equal(t, tc.config, conn.config)
|
expected := *tc.config
|
||||||
|
expected.CloseHandler = nil
|
||||||
|
assert.Equal(t, expected, gotCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify close handler was set if provided
|
// Verify close handler was set if provided
|
||||||
@@ -236,7 +249,7 @@ func TestNewConnectionFromSocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestConnect(t *testing.T) {
|
func TestConnect(t *testing.T) {
|
||||||
t.Run("connect fails when socket already present", func(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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.socket = honeybeetest.NewMockSocket()
|
conn.socket = honeybeetest.NewMockSocket()
|
||||||
@@ -248,7 +261,7 @@ func TestConnect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("connect fails when connection closed", func(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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -260,9 +273,6 @@ func TestConnect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("connect succeeds and starts goroutines", func(t *testing.T) {
|
t.Run("connect succeeds and starts goroutines", func(t *testing.T) {
|
||||||
conn, err := NewConnection("ws://test", nil, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
outgoingData := make(chan honeybeetest.MockOutgoingData, 10)
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
@@ -276,7 +286,9 @@ func TestConnect(t *testing.T) {
|
|||||||
return mockSocket, nil, nil
|
return mockSocket, nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
conn, err := NewConnection(context.Background(), "ws://test",
|
||||||
|
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -298,17 +310,6 @@ func TestConnect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("connect retries on dial failure", func(t *testing.T) {
|
t.Run("connect retries on dial failure", func(t *testing.T) {
|
||||||
config := &ConnectionConfig{
|
|
||||||
Retry: &RetryConfig{
|
|
||||||
MaxRetries: 2,
|
|
||||||
InitialDelay: 1 * time.Millisecond,
|
|
||||||
MaxDelay: 5 * time.Millisecond,
|
|
||||||
JitterFactor: 0.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
conn, err := NewConnection("ws://test", config, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
attemptCount := 0
|
attemptCount := 0
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
@@ -319,7 +320,17 @@ func TestConnect(t *testing.T) {
|
|||||||
return honeybeetest.NewMockSocket(), nil, nil
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
config := &ConnectionConfig{
|
||||||
|
Retry: RetryConfig{
|
||||||
|
MaxRetries: 2,
|
||||||
|
InitialDelay: 1 * time.Millisecond,
|
||||||
|
MaxDelay: 5 * time.Millisecond,
|
||||||
|
JitterFactor: 0.0,
|
||||||
|
},
|
||||||
|
Dialer: mockDialer,
|
||||||
|
}
|
||||||
|
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -330,23 +341,22 @@ func TestConnect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("connect fails after max retries", func(t *testing.T) {
|
t.Run("connect fails after max retries", func(t *testing.T) {
|
||||||
config := &ConnectionConfig{
|
|
||||||
Retry: &RetryConfig{
|
|
||||||
MaxRetries: 2,
|
|
||||||
InitialDelay: 1 * time.Millisecond,
|
|
||||||
MaxDelay: 5 * time.Millisecond,
|
|
||||||
JitterFactor: 0.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
conn, err := NewConnection("ws://test", config, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
return nil, nil, fmt.Errorf("dial failed")
|
return nil, nil, fmt.Errorf("dial failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
config := &ConnectionConfig{
|
||||||
|
Retry: RetryConfig{
|
||||||
|
MaxRetries: 2,
|
||||||
|
InitialDelay: 1 * time.Millisecond,
|
||||||
|
MaxDelay: 5 * time.Millisecond,
|
||||||
|
JitterFactor: 0.0,
|
||||||
|
},
|
||||||
|
Dialer: mockDialer,
|
||||||
|
}
|
||||||
|
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -355,18 +365,20 @@ func TestConnect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("state transitions during connect", func(t *testing.T) {
|
t.Run("state transitions during connect", func(t *testing.T) {
|
||||||
conn, err := NewConnection("ws://test", nil, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, StateDisconnected, conn.State())
|
|
||||||
|
|
||||||
stateDuringDial := StateDisconnected
|
stateDuringDial := StateDisconnected
|
||||||
|
// conn captured after construction; closure safe because dialer runs during Connect
|
||||||
|
var conn *Connection
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
stateDuringDial = conn.state
|
stateDuringDial = conn.state
|
||||||
return honeybeetest.NewMockSocket(), nil, nil
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
var err error
|
||||||
|
conn, err = NewConnection(context.Background(), "ws://test",
|
||||||
|
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, StateDisconnected, conn.State())
|
||||||
|
|
||||||
conn.Connect(context.Background())
|
conn.Connect(context.Background())
|
||||||
|
|
||||||
@@ -378,25 +390,24 @@ func TestConnect(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("close handler configured when provided", func(t *testing.T) {
|
t.Run("close handler configured when provided", func(t *testing.T) {
|
||||||
handlerSet := false
|
handlerSet := false
|
||||||
config := &ConnectionConfig{
|
|
||||||
CloseHandler: func(code int, text string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
conn, err := NewConnection("ws://test", config, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
mockSocket.SetCloseHandlerFunc = func(h func(int, string) error) {
|
mockSocket.SetCloseHandlerFunc = func(h func(int, string) error) {
|
||||||
handlerSet = true
|
handlerSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
return mockSocket, nil, nil
|
return mockSocket, nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
config := &ConnectionConfig{
|
||||||
|
CloseHandler: func(code int, text string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Retry: RetryConfig{Disabled: true},
|
||||||
|
Dialer: mockDialer,
|
||||||
|
}
|
||||||
|
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Connect(context.Background())
|
conn.Connect(context.Background())
|
||||||
|
|
||||||
@@ -407,17 +418,16 @@ func TestConnect(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("passes headers when configured", func(t *testing.T) {
|
t.Run("passes headers when configured", func(t *testing.T) {
|
||||||
header := http.Header{"X-Custom": []string{"val"}}
|
header := http.Header{"X-Custom": []string{"val"}}
|
||||||
conf, _ := NewConnectionConfig(WithRequestHeader(header))
|
|
||||||
conn, _ := NewConnection("ws://test", conf, nil)
|
|
||||||
|
|
||||||
dialCalled := false
|
dialCalled := false
|
||||||
conn.dialer = &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(ctx context.Context, url string, h http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(ctx context.Context, url string, h http.Header) (types.Socket, *http.Response, error) {
|
||||||
assert.Equal(t, "val", h.Get("X-Custom"))
|
assert.Equal(t, "val", h.Get("X-Custom"))
|
||||||
dialCalled = true
|
dialCalled = true
|
||||||
return honeybeetest.NewMockSocket(), nil, nil
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
conf, _ := NewConnectionConfig(WithRequestHeader(header), WithConnectionDialer(mockDialer))
|
||||||
|
conn, _ := NewConnection(context.Background(), "ws://test", conf, nil)
|
||||||
|
|
||||||
err := conn.Connect(context.Background())
|
err := conn.Connect(context.Background())
|
||||||
|
|
||||||
@@ -429,25 +439,25 @@ func TestConnect(t *testing.T) {
|
|||||||
func TestConnectContextCancellation(t *testing.T) {
|
func TestConnectContextCancellation(t *testing.T) {
|
||||||
t.Run("context cancelled during connect returns before retries exhaust", func(t *testing.T) {
|
t.Run("context cancelled during connect returns before retries exhaust", func(t *testing.T) {
|
||||||
config := &ConnectionConfig{
|
config := &ConnectionConfig{
|
||||||
Retry: &RetryConfig{
|
Retry: RetryConfig{
|
||||||
MaxRetries: 100,
|
MaxRetries: 100,
|
||||||
InitialDelay: 500 * time.Millisecond,
|
InitialDelay: 500 * time.Millisecond,
|
||||||
MaxDelay: 1 * time.Second,
|
MaxDelay: 1 * time.Second,
|
||||||
JitterFactor: 0.0,
|
JitterFactor: 0.0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn, err := NewConnection("ws://test", config, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
dialCount := atomic.Int32{}
|
dialCount := atomic.Int32{}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
|
|
||||||
conn.dialer = &honeybeetest.MockDialer{
|
|
||||||
DialContextFunc: func(ctx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(ctx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||||
dialCount.Add(1)
|
dialCount.Add(1)
|
||||||
return nil, nil, fmt.Errorf("dial failed")
|
return nil, nil, fmt.Errorf("dial failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
config.Dialer = mockDialer
|
||||||
|
conn, err := NewConnection(context.Background(), "ws://test", config, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -475,7 +485,7 @@ func TestConnectContextCancellation(t *testing.T) {
|
|||||||
// Connection method tests
|
// Connection method tests
|
||||||
|
|
||||||
func TestConnectionIncoming(t *testing.T) {
|
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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
incoming := conn.Incoming()
|
incoming := conn.Incoming()
|
||||||
@@ -498,7 +508,7 @@ func TestConnectionErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -521,7 +531,7 @@ func TestConnectionErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -541,7 +551,7 @@ func TestConnectionErrors(t *testing.T) {
|
|||||||
return 0, nil, io.EOF
|
return 0, nil, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -573,7 +583,7 @@ func TestConnectionHeartbeat(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn, _ := NewConnectionFromSocket(socket, conf, nil)
|
conn, _ := NewConnectionFromSocket(context.Background(), socket, conf, nil)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
honeybeetest.Eventually(t,
|
honeybeetest.Eventually(t,
|
||||||
@@ -586,7 +596,7 @@ func TestConnectionHeartbeat(t *testing.T) {
|
|||||||
socket, _, _ := honeybeetest.SetupTestSocket(t)
|
socket, _, _ := honeybeetest.SetupTestSocket(t)
|
||||||
socket.SetPongHandlerFunc = func(h func(string) error) { handler = h }
|
socket.SetPongHandlerFunc = func(h func(string) error) { handler = h }
|
||||||
|
|
||||||
conn, _ := NewConnectionFromSocket(socket, nil, nil)
|
conn, _ := NewConnectionFromSocket(context.Background(), socket, nil, nil)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
honeybeetest.Eventually(t, func() bool {
|
honeybeetest.Eventually(t, func() bool {
|
||||||
@@ -620,7 +630,7 @@ func setupTestConnection(t *testing.T) (
|
|||||||
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
|
socket, incoming, outgoing = honeybeetest.SetupTestSocket(t)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
conn, err = NewConnectionFromSocket(socket, nil, nil)
|
conn, err = NewConnectionFromSocket(context.Background(), socket, nil, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-64
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
// slog used for ExpectedLog level constants
|
||||||
|
|
||||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
"git.wisehodl.dev/jay/go-honeybee/types"
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
@@ -26,10 +27,6 @@ func log(level slog.Level, msg string, attrs map[string]any) honeybeetest.Expect
|
|||||||
func TestConnectLogging(t *testing.T) {
|
func TestConnectLogging(t *testing.T) {
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
conn, err := NewConnection("ws://test", nil, logger)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
@@ -37,7 +34,9 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
return mockSocket, nil, nil
|
return mockSocket, nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
conn, err := NewConnection(context.Background(), "ws://test",
|
||||||
|
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, mockHandler)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -49,7 +48,7 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
log(slog.LevelDebug, "connecting", map[string]any{}),
|
log(slog.LevelDebug, "connecting", map[string]any{}),
|
||||||
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 1}),
|
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 1}),
|
||||||
log(slog.LevelDebug, "dial successful", map[string]any{"attempt": 1}),
|
log(slog.LevelDebug, "dial successful", map[string]any{"attempt": 1}),
|
||||||
log(slog.LevelInfo, "connected", map[string]any{}),
|
log(slog.LevelDebug, "connected", map[string]any{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
honeybeetest.AssertLogSequence(t, records, expected)
|
honeybeetest.AssertLogSequence(t, records, expected)
|
||||||
@@ -57,19 +56,6 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("max retries failure", func(t *testing.T) {
|
t.Run("max retries failure", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
config := &ConnectionConfig{
|
|
||||||
Retry: &RetryConfig{
|
|
||||||
MaxRetries: 2,
|
|
||||||
InitialDelay: 1 * time.Millisecond,
|
|
||||||
MaxDelay: 5 * time.Millisecond,
|
|
||||||
JitterFactor: 0.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := NewConnection("ws://test", config, logger)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
dialErr := fmt.Errorf("dial error")
|
dialErr := fmt.Errorf("dial error")
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
@@ -77,7 +63,18 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
return nil, nil, dialErr
|
return nil, nil, dialErr
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
config := &ConnectionConfig{
|
||||||
|
Retry: RetryConfig{
|
||||||
|
MaxRetries: 2,
|
||||||
|
InitialDelay: 1 * time.Millisecond,
|
||||||
|
MaxDelay: 5 * time.Millisecond,
|
||||||
|
JitterFactor: 0.0,
|
||||||
|
},
|
||||||
|
Dialer: mockDialer,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnection(context.Background(), "ws://test", config, mockHandler)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -91,8 +88,8 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 2}),
|
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 2}),
|
||||||
log(slog.LevelWarn, "dial failed, retrying", map[string]any{"attempt": 2, "error": dialErr}),
|
log(slog.LevelWarn, "dial failed, retrying", map[string]any{"attempt": 2, "error": dialErr}),
|
||||||
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 3}),
|
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 3}),
|
||||||
log(slog.LevelError, "dial failed, max retries reached", map[string]any{"attempt": 3, "error": dialErr}),
|
log(slog.LevelDebug, "dial failed, max retries reached", map[string]any{"attempt": 3, "error": dialErr}),
|
||||||
log(slog.LevelError, "connection failed", map[string]any{"error": dialErr}),
|
log(slog.LevelWarn, "connection failed", map[string]any{"error": dialErr}),
|
||||||
}
|
}
|
||||||
|
|
||||||
honeybeetest.AssertLogSequence(t, records, expected)
|
honeybeetest.AssertLogSequence(t, records, expected)
|
||||||
@@ -100,19 +97,6 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("success after retry", func(t *testing.T) {
|
t.Run("success after retry", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
config := &ConnectionConfig{
|
|
||||||
Retry: &RetryConfig{
|
|
||||||
MaxRetries: 3,
|
|
||||||
InitialDelay: 1 * time.Millisecond,
|
|
||||||
MaxDelay: 5 * time.Millisecond,
|
|
||||||
JitterFactor: 0.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := NewConnection("ws://test", config, logger)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
attemptCount := 0
|
attemptCount := 0
|
||||||
dialErr := fmt.Errorf("dial error")
|
dialErr := fmt.Errorf("dial error")
|
||||||
@@ -125,7 +109,18 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
return honeybeetest.NewMockSocket(), nil, nil
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
config := &ConnectionConfig{
|
||||||
|
Retry: RetryConfig{
|
||||||
|
MaxRetries: 3,
|
||||||
|
InitialDelay: 1 * time.Millisecond,
|
||||||
|
MaxDelay: 5 * time.Millisecond,
|
||||||
|
JitterFactor: 0.0,
|
||||||
|
},
|
||||||
|
Dialer: mockDialer,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnection(context.Background(), "ws://test", config, mockHandler)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -141,7 +136,7 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
log(slog.LevelWarn, "dial failed, retrying", map[string]any{"attempt": 2, "error": dialErr}),
|
log(slog.LevelWarn, "dial failed, retrying", map[string]any{"attempt": 2, "error": dialErr}),
|
||||||
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 3}),
|
log(slog.LevelDebug, "dialing", map[string]any{"attempt": 3}),
|
||||||
log(slog.LevelDebug, "dial successful", map[string]any{"attempt": 3}),
|
log(slog.LevelDebug, "dial successful", map[string]any{"attempt": 3}),
|
||||||
log(slog.LevelInfo, "connected", map[string]any{}),
|
log(slog.LevelDebug, "connected", map[string]any{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
honeybeetest.AssertLogSequence(t, records, expected)
|
honeybeetest.AssertLogSequence(t, records, expected)
|
||||||
@@ -151,24 +146,23 @@ func TestConnectLogging(t *testing.T) {
|
|||||||
func TestCloseLogging(t *testing.T) {
|
func TestCloseLogging(t *testing.T) {
|
||||||
t.Run("normal close", func(t *testing.T) {
|
t.Run("normal close", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|
||||||
honeybeetest.Eventually(t, func() bool {
|
honeybeetest.Eventually(t, func() bool {
|
||||||
return honeybeetest.FindLogRecord(
|
return honeybeetest.FindLogRecord(
|
||||||
mockHandler.GetRecords(), slog.LevelInfo, "closed") != nil
|
mockHandler.GetRecords(), slog.LevelDebug, "closed") != nil
|
||||||
}, "expected log")
|
}, "expected log")
|
||||||
|
|
||||||
records := mockHandler.GetRecords()
|
records := mockHandler.GetRecords()
|
||||||
|
|
||||||
expected := []honeybeetest.ExpectedLog{
|
expected := []honeybeetest.ExpectedLog{
|
||||||
log(slog.LevelInfo, "closing", map[string]any{}),
|
log(slog.LevelDebug, "closing", map[string]any{}),
|
||||||
log(slog.LevelInfo, "closed", map[string]any{}),
|
log(slog.LevelDebug, "closed", map[string]any{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
honeybeetest.AssertLogSequence(t, records, expected)
|
honeybeetest.AssertLogSequence(t, records, expected)
|
||||||
@@ -176,7 +170,6 @@ func TestCloseLogging(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("close with socket error", func(t *testing.T) {
|
t.Run("close with socket error", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
closeErr := fmt.Errorf("close error")
|
closeErr := fmt.Errorf("close error")
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
@@ -184,7 +177,7 @@ func TestCloseLogging(t *testing.T) {
|
|||||||
return closeErr
|
return closeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -197,7 +190,7 @@ func TestCloseLogging(t *testing.T) {
|
|||||||
records := mockHandler.GetRecords()
|
records := mockHandler.GetRecords()
|
||||||
|
|
||||||
expected := []honeybeetest.ExpectedLog{
|
expected := []honeybeetest.ExpectedLog{
|
||||||
log(slog.LevelInfo, "closing", map[string]any{}),
|
log(slog.LevelDebug, "closing", map[string]any{}),
|
||||||
log(slog.LevelError, "socket close failed", map[string]any{"error": closeErr}),
|
log(slog.LevelError, "socket close failed", map[string]any{"error": closeErr}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +201,6 @@ func TestCloseLogging(t *testing.T) {
|
|||||||
func TestReaderLogging(t *testing.T) {
|
func TestReaderLogging(t *testing.T) {
|
||||||
t.Run("clean close by peer", func(t *testing.T) {
|
t.Run("clean close by peer", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
||||||
@@ -218,16 +210,16 @@ func TestReaderLogging(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
honeybeetest.Eventually(t, func() bool {
|
honeybeetest.Eventually(t, func() bool {
|
||||||
return honeybeetest.FindLogRecord(
|
return honeybeetest.FindLogRecord(
|
||||||
mockHandler.GetRecords(), slog.LevelInfo, "connection closed by peer") != nil
|
mockHandler.GetRecords(), slog.LevelDebug, "connection closed by peer") != nil
|
||||||
}, "expected log")
|
}, "expected log")
|
||||||
|
|
||||||
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelInfo, "connection closed by peer")
|
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelDebug, "connection closed by peer")
|
||||||
assert.NotNil(t, record)
|
assert.NotNil(t, record)
|
||||||
honeybeetest.AssertAttributePresent(t, *record, "code", websocket.CloseNormalClosure)
|
honeybeetest.AssertAttributePresent(t, *record, "code", websocket.CloseNormalClosure)
|
||||||
honeybeetest.AssertAttributePresent(t, *record, "text", "goodbye")
|
honeybeetest.AssertAttributePresent(t, *record, "text", "goodbye")
|
||||||
@@ -236,7 +228,6 @@ func TestReaderLogging(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("unexpected close", func(t *testing.T) {
|
t.Run("unexpected close", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
||||||
@@ -246,16 +237,16 @@ func TestReaderLogging(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
honeybeetest.Eventually(t, func() bool {
|
honeybeetest.Eventually(t, func() bool {
|
||||||
return honeybeetest.FindLogRecord(
|
return honeybeetest.FindLogRecord(
|
||||||
mockHandler.GetRecords(), slog.LevelError, "unexpected close") != nil
|
mockHandler.GetRecords(), slog.LevelWarn, "unexpected close") != nil
|
||||||
}, "expected log")
|
}, "expected log")
|
||||||
|
|
||||||
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelError, "unexpected close")
|
record := honeybeetest.FindLogRecord(mockHandler.GetRecords(), slog.LevelWarn, "unexpected close")
|
||||||
assert.NotNil(t, record)
|
assert.NotNil(t, record)
|
||||||
honeybeetest.AssertAttributePresent(t, *record, "code", websocket.CloseProtocolError)
|
honeybeetest.AssertAttributePresent(t, *record, "code", websocket.CloseProtocolError)
|
||||||
honeybeetest.AssertAttributePresent(t, *record, "text", "bad protocol")
|
honeybeetest.AssertAttributePresent(t, *record, "text", "bad protocol")
|
||||||
@@ -264,14 +255,13 @@ func TestReaderLogging(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("read error", func(t *testing.T) {
|
t.Run("read error", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
||||||
return 0, nil, io.EOF
|
return 0, nil, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -285,9 +275,8 @@ func TestReaderLogging(t *testing.T) {
|
|||||||
func TestWriterLogging(t *testing.T) {
|
func TestWriterLogging(t *testing.T) {
|
||||||
t.Run("write deadline error", func(t *testing.T) {
|
t.Run("write deadline error", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond}
|
config := &ConnectionConfig{WriteTimeout: 1 * time.Millisecond, Retry: RetryConfig{Disabled: true}}
|
||||||
|
|
||||||
deadlineErr := fmt.Errorf("deadline error")
|
deadlineErr := fmt.Errorf("deadline error")
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
@@ -295,7 +284,7 @@ func TestWriterLogging(t *testing.T) {
|
|||||||
return deadlineErr
|
return deadlineErr
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, config, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, config, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Send([]byte("test"))
|
err = conn.Send([]byte("test"))
|
||||||
@@ -317,7 +306,6 @@ func TestWriterLogging(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("write message error", func(t *testing.T) {
|
t.Run("write message error", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
logger := slog.New(mockHandler)
|
|
||||||
|
|
||||||
writeErr := fmt.Errorf("write error")
|
writeErr := fmt.Errorf("write error")
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
@@ -325,7 +313,7 @@ func TestWriterLogging(t *testing.T) {
|
|||||||
return writeErr
|
return writeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := NewConnectionFromSocket(mockSocket, nil, logger)
|
conn, err := NewConnectionFromSocket(context.Background(), mockSocket, nil, mockHandler)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Send([]byte("test"))
|
err = conn.Send([]byte("test"))
|
||||||
@@ -350,16 +338,15 @@ func TestLoggingDisabled(t *testing.T) {
|
|||||||
t.Run("nil logger produces no logs", func(t *testing.T) {
|
t.Run("nil logger produces no logs", func(t *testing.T) {
|
||||||
mockHandler := honeybeetest.NewMockSlogHandler()
|
mockHandler := honeybeetest.NewMockSlogHandler()
|
||||||
|
|
||||||
conn, err := NewConnection("ws://test", nil, nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
mockSocket := honeybeetest.NewMockSocket()
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
mockDialer := &honeybeetest.MockDialer{
|
mockDialer := &honeybeetest.MockDialer{
|
||||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
return mockSocket, nil, nil
|
return mockSocket, nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
conn.dialer = mockDialer
|
conn, err := NewConnection(context.Background(), "ws://test",
|
||||||
|
&ConnectionConfig{Retry: RetryConfig{Disabled: true}, Dialer: mockDialer}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = conn.Connect(context.Background())
|
err = conn.Connect(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
+10
-16
@@ -7,16 +7,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RetryManager struct {
|
type RetryManager struct {
|
||||||
config *RetryConfig
|
config RetryConfig
|
||||||
retryCount int
|
retryCount int
|
||||||
saturation int
|
saturation int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRetryManager(config *RetryConfig) *RetryManager {
|
func NewRetryManager(config RetryConfig) *RetryManager {
|
||||||
// saturationCount: retry count at which base delay meets or exceeds MaxDelay.
|
// saturationCount: retry count at which base delay meets or exceeds MaxDelay.
|
||||||
// Conservative by two to preserve jitter variance near the boundary.
|
// Conservative by two to preserve jitter variance near the boundary.
|
||||||
saturation := 0
|
saturation := 0
|
||||||
if config != nil &&
|
if !config.Disabled &&
|
||||||
config.InitialDelay > 0 &&
|
config.InitialDelay > 0 &&
|
||||||
config.InitialDelay <= config.MaxDelay {
|
config.InitialDelay <= config.MaxDelay {
|
||||||
ratio := float64(config.MaxDelay) / float64(config.InitialDelay)
|
ratio := float64(config.MaxDelay) / float64(config.InitialDelay)
|
||||||
@@ -31,7 +31,7 @@ func NewRetryManager(config *RetryConfig) *RetryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *RetryManager) ShouldRetry() bool {
|
func (r *RetryManager) ShouldRetry() bool {
|
||||||
if r.config == nil {
|
if r.config.Disabled {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ func (r *RetryManager) ShouldRetry() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *RetryManager) CalculateDelay() time.Duration {
|
func (r *RetryManager) CalculateDelay() time.Duration {
|
||||||
if r.config == nil {
|
if r.config.Disabled {
|
||||||
return time.Second
|
return time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,27 +54,21 @@ func (r *RetryManager) CalculateDelay() time.Duration {
|
|||||||
|
|
||||||
// if saturation is reached, calculated backoff will always be higher than
|
// if saturation is reached, calculated backoff will always be higher than
|
||||||
// the maximum delay
|
// the maximum delay
|
||||||
if r.config != nil && r.retryCount >= r.saturation {
|
if r.retryCount >= r.saturation {
|
||||||
return r.config.MaxDelay
|
return r.config.MaxDelay
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exponential backoff: InitialDelay * 2^(attempts-1)
|
// Exponential backoff: InitialDelay * 2^(attempts-1)
|
||||||
shift := r.retryCount - 1
|
shift := min(r.retryCount-1, 62) // prevent overflow
|
||||||
if shift > 62 {
|
|
||||||
shift = 62
|
|
||||||
} // prevent overflow
|
|
||||||
backoffMultiplier := float64(int64(1) << shift)
|
backoffMultiplier := float64(int64(1) << shift)
|
||||||
baseDelay := float64(r.config.InitialDelay) * backoffMultiplier
|
baseDelay := float64(r.config.InitialDelay) * backoffMultiplier
|
||||||
|
|
||||||
// Apply jitter: delay * (1 + jitterFactor * (random - 0.5))
|
// Apply jitter: delay * (1 + jitterFactor * (random - 0.5))
|
||||||
random := rand.Float64()
|
random := rand.Float64()
|
||||||
jitterMultiplier := 1 + r.config.JitterFactor*(random-0.5)
|
jitterMultiplier := 1 + r.config.JitterFactor*(random-0.5)
|
||||||
delay := time.Duration(baseDelay * jitterMultiplier)
|
delay := min(
|
||||||
|
// Cap at MaxDelay
|
||||||
// Cap at MaxDelay
|
time.Duration(baseDelay*jitterMultiplier), r.config.MaxDelay)
|
||||||
if delay > r.config.MaxDelay {
|
|
||||||
delay = r.config.MaxDelay
|
|
||||||
}
|
|
||||||
|
|
||||||
return delay
|
return delay
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRetryManager(t *testing.T) {
|
func TestNewRetryManager(t *testing.T) {
|
||||||
config := &RetryConfig{
|
config := RetryConfig{
|
||||||
MaxRetries: 0,
|
MaxRetries: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,14 +16,14 @@ func TestNewRetryManager(t *testing.T) {
|
|||||||
assert.Equal(t, config, mgr.config)
|
assert.Equal(t, config, mgr.config)
|
||||||
assert.Equal(t, 0, mgr.retryCount)
|
assert.Equal(t, 0, mgr.retryCount)
|
||||||
|
|
||||||
// Should accept nil config
|
// Should accept a disabled config
|
||||||
mgr = NewRetryManager(nil)
|
mgr = NewRetryManager(RetryConfig{Disabled: true})
|
||||||
assert.Nil(t, mgr.config)
|
assert.True(t, mgr.config.Disabled)
|
||||||
assert.Equal(t, 0, mgr.retryCount)
|
assert.Equal(t, 0, mgr.retryCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordRetry(t *testing.T) {
|
func TestRecordRetry(t *testing.T) {
|
||||||
mgr := NewRetryManager(nil)
|
mgr := NewRetryManager(RetryConfig{Disabled: true})
|
||||||
assert.Equal(t, mgr.retryCount, 0)
|
assert.Equal(t, mgr.retryCount, 0)
|
||||||
|
|
||||||
mgr.RecordRetry()
|
mgr.RecordRetry()
|
||||||
@@ -34,13 +34,13 @@ func TestRecordRetry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldRetry(t *testing.T) {
|
func TestShouldRetry(t *testing.T) {
|
||||||
// never retry if config is nil
|
// never retry if config is disabled
|
||||||
mgr := NewRetryManager(nil)
|
mgr := NewRetryManager(RetryConfig{Disabled: true})
|
||||||
assert.False(t, mgr.ShouldRetry())
|
assert.False(t, mgr.ShouldRetry())
|
||||||
|
|
||||||
// always retry if max attempt count is zero
|
// always retry if max attempt count is zero
|
||||||
mgr = &RetryManager{
|
mgr = &RetryManager{
|
||||||
config: &RetryConfig{
|
config: RetryConfig{
|
||||||
MaxRetries: 0,
|
MaxRetries: 0,
|
||||||
},
|
},
|
||||||
retryCount: 1000,
|
retryCount: 1000,
|
||||||
@@ -49,7 +49,7 @@ func TestShouldRetry(t *testing.T) {
|
|||||||
|
|
||||||
// retry if below max attempt count
|
// retry if below max attempt count
|
||||||
mgr = &RetryManager{
|
mgr = &RetryManager{
|
||||||
config: &RetryConfig{
|
config: RetryConfig{
|
||||||
MaxRetries: 10,
|
MaxRetries: 10,
|
||||||
},
|
},
|
||||||
retryCount: 5,
|
retryCount: 5,
|
||||||
@@ -58,7 +58,7 @@ func TestShouldRetry(t *testing.T) {
|
|||||||
|
|
||||||
// do not retry if above max attempt count
|
// do not retry if above max attempt count
|
||||||
mgr = &RetryManager{
|
mgr = &RetryManager{
|
||||||
config: &RetryConfig{
|
config: RetryConfig{
|
||||||
MaxRetries: 10,
|
MaxRetries: 10,
|
||||||
},
|
},
|
||||||
retryCount: 11,
|
retryCount: 11,
|
||||||
@@ -68,12 +68,12 @@ func TestShouldRetry(t *testing.T) {
|
|||||||
|
|
||||||
func TestCalculateDelayDisabled(t *testing.T) {
|
func TestCalculateDelayDisabled(t *testing.T) {
|
||||||
// default delay if retry is disabled
|
// default delay if retry is disabled
|
||||||
mgr := NewRetryManager(nil)
|
mgr := NewRetryManager(RetryConfig{Disabled: true})
|
||||||
assert.Equal(t, time.Second, mgr.CalculateDelay())
|
assert.Equal(t, time.Second, mgr.CalculateDelay())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateDelayWithoutJitter(t *testing.T) {
|
func TestCalculateDelayWithoutJitter(t *testing.T) {
|
||||||
mgr := NewRetryManager(&RetryConfig{
|
mgr := NewRetryManager(RetryConfig{
|
||||||
MaxRetries: 0,
|
MaxRetries: 0,
|
||||||
InitialDelay: 1 * time.Second,
|
InitialDelay: 1 * time.Second,
|
||||||
MaxDelay: 5 * time.Second,
|
MaxDelay: 5 * time.Second,
|
||||||
@@ -105,7 +105,7 @@ func TestCalculateDelayWithoutJitter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateDelayWithJitter(t *testing.T) {
|
func TestCalculateDelayWithJitter(t *testing.T) {
|
||||||
mgr := NewRetryManager(&RetryConfig{
|
mgr := NewRetryManager(RetryConfig{
|
||||||
MaxRetries: 0,
|
MaxRetries: 0,
|
||||||
InitialDelay: 1 * time.Second,
|
InitialDelay: 1 * time.Second,
|
||||||
MaxDelay: 5 * time.Second,
|
MaxDelay: 5 * time.Second,
|
||||||
|
|||||||
+5
-1
@@ -69,6 +69,7 @@ func AcquireSocket(
|
|||||||
logger.Debug("dialing", "attempt", retryMgr.RetryCount()+1)
|
logger.Debug("dialing", "attempt", retryMgr.RetryCount()+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dial
|
||||||
socket, resp, err := dialer.DialContext(ctx, url, header)
|
socket, resp, err := dialer.DialContext(ctx, url, header)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
@@ -77,9 +78,11 @@ func AcquireSocket(
|
|||||||
return socket, resp, nil
|
return socket, resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dial failed, retry
|
||||||
if !retryMgr.ShouldRetry() {
|
if !retryMgr.ShouldRetry() {
|
||||||
|
// retry policy expired
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Error("dial failed, max retries reached",
|
logger.Debug("dial failed, max retries reached",
|
||||||
"error", err,
|
"error", err,
|
||||||
"attempt", retryMgr.RetryCount()+1)
|
"attempt", retryMgr.RetryCount()+1)
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,7 @@ func AcquireSocket(
|
|||||||
"next_delay", delay)
|
"next_delay", delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// context cancellable backoff
|
||||||
select {
|
select {
|
||||||
case <-time.After(delay):
|
case <-time.After(delay):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestAcquireSocket(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
retryMgr := NewRetryManager(&RetryConfig{
|
retryMgr := NewRetryManager(RetryConfig{
|
||||||
MaxRetries: tc.maxRetries,
|
MaxRetries: tc.maxRetries,
|
||||||
InitialDelay: 1 * time.Millisecond,
|
InitialDelay: 1 * time.Millisecond,
|
||||||
MaxDelay: 5 * time.Millisecond,
|
MaxDelay: 5 * time.Millisecond,
|
||||||
@@ -106,7 +106,8 @@ func TestAcquireSocketGuards(t *testing.T) {
|
|||||||
return honeybeetest.NewMockSocket(), nil, nil
|
return honeybeetest.NewMockSocket(), nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
validRetryMgr := NewRetryManager(GetDefaultRetryConfig())
|
validRetryConfig := GetDefaultConnectionConfig().Retry
|
||||||
|
validRetryMgr := NewRetryManager(validRetryConfig)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -167,7 +168,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
|||||||
// cancel before acquiring socket
|
// cancel before acquiring socket
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
retryMgr := NewRetryManager(GetDefaultRetryConfig())
|
retryMgr := NewRetryManager(GetDefaultConnectionConfig().Retry)
|
||||||
_, _, err := AcquireSocket(ctx, retryMgr, mockDialer, "ws://test", nil, nil)
|
_, _, err := AcquireSocket(ctx, retryMgr, mockDialer, "ws://test", nil, nil)
|
||||||
|
|
||||||
assert.ErrorIs(t, err, context.Canceled)
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
@@ -186,7 +187,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
retryMgr := NewRetryManager(&RetryConfig{
|
retryMgr := NewRetryManager(RetryConfig{
|
||||||
MaxRetries: 10,
|
MaxRetries: 10,
|
||||||
InitialDelay: 1 * time.Second,
|
InitialDelay: 1 * time.Second,
|
||||||
MaxDelay: 1 * time.Second,
|
MaxDelay: 1 * time.Second,
|
||||||
@@ -230,7 +231,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
retryMgr := NewRetryManager(GetDefaultRetryConfig())
|
retryMgr := NewRetryManager(GetDefaultConnectionConfig().Retry)
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, _, err := AcquireSocket(ctx, retryMgr, mockDialer, "ws://test", nil, nil)
|
_, _, err := AcquireSocket(ctx, retryMgr, mockDialer, "ws://test", nil, nil)
|
||||||
@@ -263,7 +264,7 @@ func TestAcquireSocketPassesHeaders(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
retryMgr := NewRetryManager(&RetryConfig{MaxRetries: 0})
|
retryMgr := NewRetryManager(RetryConfig{MaxRetries: 0, InitialDelay: 1 * time.Millisecond, MaxDelay: 5 * time.Millisecond})
|
||||||
_, _, err := AcquireSocket(context.Background(), retryMgr, mockDialer, "ws://test", header, nil)
|
_, _, err := AcquireSocket(context.Background(), retryMgr, mockDialer, "ws://test", header, nil)
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,38 +9,39 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunWatchdog(t *testing.T) {
|
func TestIdleWatchdog(t *testing.T) {
|
||||||
t.Run("heartbeat resets timer, onInactive not called", func(t *testing.T) {
|
t.Run("heartbeat resets timer, onTimeout not called", func(t *testing.T) {
|
||||||
heartbeat := make(chan struct{})
|
activity := make(chan struct{})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx := t.Context()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
called := atomic.Bool{}
|
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)
|
time.Sleep(20 * time.Millisecond)
|
||||||
heartbeat <- struct{}{}
|
activity <- struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
honeybeetest.Never(t, func() bool {
|
honeybeetest.Never(t, func() bool {
|
||||||
return called.Load()
|
return called.Load()
|
||||||
}, "unexpected onInactive call")
|
}, "unexpected onTimeout call")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("timeout fires onInactive exactly once", func(t *testing.T) {
|
t.Run("timeout fires onTimeout exactly once", func(t *testing.T) {
|
||||||
heartbeat := make(chan struct{})
|
activity := make(chan struct{})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx := t.Context()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var gotKind WorkerExitKind
|
|
||||||
count := atomic.Int32{}
|
count := atomic.Int32{}
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go RunWatchdog(ctx, func(kind WorkerExitKind) {
|
go IdleWatchdog(
|
||||||
count.Add(1)
|
ctx, activity, 20*time.Millisecond, func() {
|
||||||
gotKind = kind
|
// will panic on second close
|
||||||
close(done)
|
count.Add(1)
|
||||||
}, heartbeat, 20*time.Millisecond, nil)
|
close(done)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
honeybeetest.Eventually(t, func() bool {
|
honeybeetest.Eventually(t, func() bool {
|
||||||
select {
|
select {
|
||||||
@@ -49,20 +50,21 @@ func TestRunWatchdog(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}, "expected onInactive")
|
}, "expected onTimeout")
|
||||||
|
|
||||||
assert.Equal(t, int32(1), count.Load())
|
assert.Equal(t, 1, int(count.Load()))
|
||||||
assert.Equal(t, ExitPolicy, gotKind)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ctx.Done exits without calling onInactive", func(t *testing.T) {
|
t.Run("ctx.Done exits without calling onTimeout", func(t *testing.T) {
|
||||||
heartbeat := make(chan struct{})
|
activity := make(chan struct{})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
called := atomic.Bool{}
|
called := atomic.Bool{}
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
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)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -80,14 +82,16 @@ func TestRunWatchdog(t *testing.T) {
|
|||||||
assert.False(t, called.Load())
|
assert.False(t, called.Load())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("zero timeout exits on ctx.Done without firing onInactive", func(t *testing.T) {
|
t.Run("zero timeout exits on ctx.Done without firing onTimeout", func(t *testing.T) {
|
||||||
heartbeat := make(chan struct{})
|
activity := make(chan struct{})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
called := atomic.Bool{}
|
called := atomic.Bool{}
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
RunWatchdog(ctx, func(WorkerExitKind) { called.Store(true) }, heartbeat, 0, nil)
|
IdleWatchdog(
|
||||||
|
ctx, activity, 0, func() { called.Store(true) },
|
||||||
|
)
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -105,20 +109,20 @@ func TestRunWatchdog(t *testing.T) {
|
|||||||
assert.False(t, called.Load())
|
assert.False(t, called.Load())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("disabled keepalive drains heartbeats without blocking", func(t *testing.T) {
|
t.Run("zero timeout drains activity without blocking", func(t *testing.T) {
|
||||||
heartbeat := make(chan struct{})
|
activity := make(chan struct{})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
RunWatchdog(ctx, func(WorkerExitKind) {}, heartbeat, 0, nil)
|
IdleWatchdog(ctx, activity, 0, func() {})
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// these must not block
|
// these must not block
|
||||||
for i := 0; i < 5; i++ {
|
for range 5 {
|
||||||
heartbeat <- struct{}{}
|
activity <- struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package honeybee
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
|
"git.wisehodl.dev/jay/go-mana-component"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Worker
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Types
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
type WorkerFactory func(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
handler slog.Handler,
|
||||||
|
) (Worker, error)
|
||||||
|
|
||||||
|
type Worker interface {
|
||||||
|
Start(pool PoolPlugin)
|
||||||
|
Stop()
|
||||||
|
Send(data []byte) error
|
||||||
|
Stats() WorkerStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerStats struct {
|
||||||
|
IncomingAvailable bool
|
||||||
|
ChanIncoming int
|
||||||
|
|
||||||
|
ConnectionAvailable bool
|
||||||
|
Connection transport.ConnectionStats
|
||||||
|
|
||||||
|
TotalProcessed uint64
|
||||||
|
TotalSent uint64
|
||||||
|
TotalRestarts uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultWorker struct {
|
||||||
|
id string
|
||||||
|
conn atomic.Pointer[transport.Connection]
|
||||||
|
|
||||||
|
sendHeartbeat chan struct{}
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
config *WorkerConfig
|
||||||
|
handler slog.Handler
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
processedCount *atomic.Uint64
|
||||||
|
outgoingCount *atomic.Uint64
|
||||||
|
restartCount *atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Constructor
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
func NewWorker(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
config *WorkerConfig,
|
||||||
|
handler slog.Handler,
|
||||||
|
) (*DefaultWorker, error) {
|
||||||
|
if config == nil {
|
||||||
|
config = GetDefaultWorkerConfig()
|
||||||
|
}
|
||||||
|
if err := ValidateWorkerConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if component.FromContext(ctx) == nil {
|
||||||
|
ctx = component.MustNew(ctx, "honeybee", "worker")
|
||||||
|
} else {
|
||||||
|
ctx = component.MustExtend(ctx, "worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
w := &DefaultWorker{
|
||||||
|
id: id,
|
||||||
|
|
||||||
|
sendHeartbeat: make(chan struct{}),
|
||||||
|
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
config: config,
|
||||||
|
|
||||||
|
processedCount: &atomic.Uint64{},
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
restartCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler != nil {
|
||||||
|
comp := component.FromContext(ctx)
|
||||||
|
w.handler = handler.WithAttrs([]slog.Attr{slog.String("peer", id)})
|
||||||
|
w.logger = slog.New(w.handler).With(slog.Any("component", comp))
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Session
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
func (w *DefaultWorker) Start(pool PoolPlugin) {
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("starting")
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.runSession(w.ctx, pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("started")
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
|
||||||
|
// setup dialer
|
||||||
|
var dialCancel context.CancelFunc
|
||||||
|
newConn := make(chan *transport.Connection, 1)
|
||||||
|
spawnDialer := func() { dialCancel = w.spawnDialer(ctx, dialCancel, newConn, pool) }
|
||||||
|
|
||||||
|
// setup heartbeat
|
||||||
|
timer, inactive, heartbeat := w.setupHeartbeat()
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
// main loop
|
||||||
|
for {
|
||||||
|
// spawn initial dial for this reconnect cycle
|
||||||
|
spawnDialer()
|
||||||
|
|
||||||
|
// obtain new connection
|
||||||
|
var conn *transport.Connection
|
||||||
|
preConn:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if dialCancel != nil {
|
||||||
|
dialCancel()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
case conn = <-newConn:
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Info("connected")
|
||||||
|
}
|
||||||
|
break preConn
|
||||||
|
|
||||||
|
case <-w.sendHeartbeat:
|
||||||
|
heartbeat()
|
||||||
|
|
||||||
|
case <-inactive():
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Warn("keepalive: no activity observed")
|
||||||
|
}
|
||||||
|
timer.Reset(w.config.KeepaliveTimeout)
|
||||||
|
spawnDialer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup new connection
|
||||||
|
w.conn.Store(conn)
|
||||||
|
pool.Events <- PoolEvent{ID: w.id, Kind: EventConnected, At: time.Now()}
|
||||||
|
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("session: started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// run session loop
|
||||||
|
conn_loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break conn_loop
|
||||||
|
|
||||||
|
case data, ok := <-conn.Incoming():
|
||||||
|
if !ok {
|
||||||
|
var reason error
|
||||||
|
select {
|
||||||
|
case reason = <-conn.Errors():
|
||||||
|
default:
|
||||||
|
reason = fmt.Errorf("unknown")
|
||||||
|
}
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Info("websocket: closed", "reason", reason)
|
||||||
|
}
|
||||||
|
break conn_loop
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.Inbox <- types.InboxMessage{
|
||||||
|
ID: w.id, Data: data, ReceivedAt: time.Now()}
|
||||||
|
|
||||||
|
pool.InboxCounter.Add(1)
|
||||||
|
w.processedCount.Add(1)
|
||||||
|
|
||||||
|
heartbeat()
|
||||||
|
|
||||||
|
case <-conn.Heartbeat():
|
||||||
|
heartbeat()
|
||||||
|
|
||||||
|
case <-w.sendHeartbeat:
|
||||||
|
heartbeat()
|
||||||
|
|
||||||
|
case <-inactive():
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Warn("keepalive: no activity observed")
|
||||||
|
}
|
||||||
|
timer.Reset(w.config.KeepaliveTimeout)
|
||||||
|
break conn_loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// session ended
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Info("disconnected")
|
||||||
|
}
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("session: ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tear down connection
|
||||||
|
w.conn.Store(nil)
|
||||||
|
pool.Events <- PoolEvent{ID: w.id, Kind: EventDisconnected, At: time.Now()}
|
||||||
|
|
||||||
|
// exit if worker is shutting down
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh session
|
||||||
|
time.Sleep(w.config.ReconnectDelay)
|
||||||
|
w.restartCount.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DefaultWorker) setupHeartbeat() (
|
||||||
|
timer *time.Timer, inactive func() <-chan time.Time, heartbeat func(),
|
||||||
|
) {
|
||||||
|
if w.config.KeepaliveTimeout > 0 {
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("keepalive: enabled", "timeout", w.config.KeepaliveTimeout)
|
||||||
|
}
|
||||||
|
timer = time.NewTimer(w.config.KeepaliveTimeout)
|
||||||
|
} else {
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("keepalive: disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat = func() {
|
||||||
|
if timer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(w.config.KeepaliveTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
inactive = func() <-chan time.Time {
|
||||||
|
if timer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return timer.C
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DefaultWorker) spawnDialer(
|
||||||
|
ctx context.Context,
|
||||||
|
dialCancel context.CancelFunc,
|
||||||
|
newConn chan<- *transport.Connection,
|
||||||
|
pool PoolPlugin,
|
||||||
|
) context.CancelFunc {
|
||||||
|
if dialCancel != nil {
|
||||||
|
dialCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, dialCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Debug("session: dialing")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := connect(w.id, dialCtx, pool, w.handler)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case newConn <- conn:
|
||||||
|
case <-dialCtx.Done():
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return dialCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(
|
||||||
|
id string,
|
||||||
|
ctx context.Context,
|
||||||
|
pool PoolPlugin,
|
||||||
|
handler slog.Handler,
|
||||||
|
) (*transport.Connection, error) {
|
||||||
|
cc := pool.ConnectionConfig
|
||||||
|
conn, err := transport.NewConnection(ctx, id, &cc, handler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, conn.Connect(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------/
|
||||||
|
// Methods
|
||||||
|
// -------------------------/
|
||||||
|
|
||||||
|
func (w *DefaultWorker) Stop() {
|
||||||
|
if w.logger != nil {
|
||||||
|
w.logger.Info("shutting down")
|
||||||
|
}
|
||||||
|
w.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DefaultWorker) Send(data []byte) error {
|
||||||
|
conn := w.conn.Load()
|
||||||
|
if conn == nil {
|
||||||
|
// connection not established by session
|
||||||
|
return NewWorkerError(w.id, ErrConnectionUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := conn.Send(data)
|
||||||
|
if err != nil {
|
||||||
|
return NewWorkerError(w.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case w.sendHeartbeat <- struct{}{}:
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
w.outgoingCount.Add(1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DefaultWorker) Stats() WorkerStats {
|
||||||
|
connectionAvailable := false
|
||||||
|
incomingLen := 0
|
||||||
|
connStats := transport.ConnectionStats{}
|
||||||
|
|
||||||
|
conn := w.conn.Load()
|
||||||
|
if conn != nil {
|
||||||
|
connectionAvailable = true
|
||||||
|
incomingLen = len(conn.Incoming())
|
||||||
|
connStats = conn.Stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkerStats{
|
||||||
|
IncomingAvailable: connectionAvailable,
|
||||||
|
ChanIncoming: incomingLen,
|
||||||
|
|
||||||
|
ConnectionAvailable: connectionAvailable,
|
||||||
|
Connection: connStats,
|
||||||
|
|
||||||
|
TotalProcessed: w.processedCount.Load(),
|
||||||
|
TotalRestarts: w.restartCount.Load(),
|
||||||
|
TotalSent: w.outgoingCount.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
+749
@@ -0,0 +1,749 @@
|
|||||||
|
package honeybee
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||||
|
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeWorkerContext(t *testing.T) (
|
||||||
|
inbox chan types.InboxMessage,
|
||||||
|
events chan PoolEvent,
|
||||||
|
pool PoolPlugin,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
inbox = make(chan types.InboxMessage, 256)
|
||||||
|
events = make(chan PoolEvent, 10)
|
||||||
|
pool = PoolPlugin{
|
||||||
|
Inbox: inbox,
|
||||||
|
Events: events,
|
||||||
|
InboxCounter: &atomic.Uint64{},
|
||||||
|
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *DefaultWorker {
|
||||||
|
t.Helper()
|
||||||
|
config, _ := NewWorkerConfig(
|
||||||
|
WithReconnectDelay(0 * time.Second),
|
||||||
|
)
|
||||||
|
return &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
config: config,
|
||||||
|
sendHeartbeat: make(chan struct{}),
|
||||||
|
processedCount: &atomic.Uint64{},
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
restartCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockDialer(socket *honeybeetest.MockSocket) *honeybeetest.MockDialer {
|
||||||
|
return &honeybeetest.MockDialer{
|
||||||
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
|
return socket, nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerSession(t *testing.T) {
|
||||||
|
t.Run("EventConnected emitted after dial succeeds", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.Start(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.ID == w.id && e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dial failure exhausted - session stays alive, no events emitted", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
|
||||||
|
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||||
|
pool.ConnectionConfig = *cc
|
||||||
|
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||||
|
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||||
|
return nil, nil, errors.New("connection refused")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
honeybeetest.Never(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-events:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected no events when dial fails")
|
||||||
|
|
||||||
|
// worker goroutine is still running
|
||||||
|
assert.False(t, func() bool {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}(), "expected worker to still be running after dial failure")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keepalive fires before connection - dial is cancelled and replaced", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
config, _ := NewWorkerConfig(
|
||||||
|
WithReconnectDelay(0),
|
||||||
|
WithKeepaliveTimeout(20*time.Millisecond),
|
||||||
|
)
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
config: config,
|
||||||
|
sendHeartbeat: make(chan struct{}),
|
||||||
|
processedCount: &atomic.Uint64{},
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
restartCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
_, _, pool := makeWorkerContext(t)
|
||||||
|
|
||||||
|
var dialCount atomic.Uint64
|
||||||
|
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||||
|
pool.ConnectionConfig = *cc
|
||||||
|
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||||
|
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||||
|
dialCount.Add(1)
|
||||||
|
<-dialCtx.Done()
|
||||||
|
return nil, nil, dialCtx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
// keepalive fires after 20ms; a second dial goroutine must be spawned
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
return dialCount.Load() >= 2
|
||||||
|
}, "expected at least two dial attempts after keepalive fired")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Stop before connection established - exits cleanly, no events", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
|
||||||
|
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||||
|
pool.ConnectionConfig = *cc
|
||||||
|
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||||
|
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||||
|
<-dialCtx.Done()
|
||||||
|
return nil, nil, dialCtx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
w.Stop()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(done) }()
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected Start to return after Stop")
|
||||||
|
|
||||||
|
assert.Empty(t, events, "expected no events when stopped before connection")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Send delivers data to socket", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
_, mockSocket, _, outgoingData := setupTestConnection(t)
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.Start(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
err := w.Send([]byte("hello"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case msg := <-outgoingData:
|
||||||
|
return string(msg.Data) == "hello"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected data on socket")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("socket data arrives on Inbox", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
inbox, events, pool := makeWorkerContext(t)
|
||||||
|
|
||||||
|
incomingData := make(chan honeybeetest.MockIncomingData, 10)
|
||||||
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
|
|
||||||
|
mockSocket.CloseFunc = func() error {
|
||||||
|
mockSocket.Once.Do(func() { close(mockSocket.Closed) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
||||||
|
select {
|
||||||
|
case data := <-incomingData:
|
||||||
|
return data.MsgType, data.Data, data.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.Start(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
incomingData <- honeybeetest.MockIncomingData{
|
||||||
|
MsgType: websocket.TextMessage,
|
||||||
|
Data: []byte("hello"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var received types.InboxMessage
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case msg := <-inbox:
|
||||||
|
received = msg
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected message on Inbox")
|
||||||
|
assert.Equal(t, w.id, received.ID)
|
||||||
|
assert.Equal(t, []byte("hello"), received.Data)
|
||||||
|
assert.False(t, received.ReceivedAt.IsZero(), "expected non-zero ReceivedAt")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sustained incoming messages reset keepalive - no disconnect", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
config, _ := NewWorkerConfig(
|
||||||
|
WithReconnectDelay(0),
|
||||||
|
WithKeepaliveTimeout(60*time.Millisecond),
|
||||||
|
)
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
config: config,
|
||||||
|
sendHeartbeat: make(chan struct{}),
|
||||||
|
processedCount: &atomic.Uint64{},
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
restartCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
// send messages every 20ms for 100ms — well within the 60ms timeout each cycle
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
select {
|
||||||
|
case incomingData <- honeybeetest.MockIncomingData{MsgType: websocket.TextMessage, Data: []byte("ping")}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
honeybeetest.Never(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventDisconnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected no EventDisconnected while messages are arriving")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pong heartbeat resets keepalive - no disconnect", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
config, _ := NewWorkerConfig(
|
||||||
|
WithReconnectDelay(0),
|
||||||
|
WithKeepaliveTimeout(60*time.Millisecond),
|
||||||
|
)
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
config: config,
|
||||||
|
sendHeartbeat: make(chan struct{}),
|
||||||
|
processedCount: &atomic.Uint64{},
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
restartCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
|
||||||
|
// socket whose pong handler fires every 20ms; no incoming messages
|
||||||
|
var pongHandler func(string) error
|
||||||
|
mockSocket, incomingData, _ := honeybeetest.SetupTestSocket(t)
|
||||||
|
mockSocket.SetPongHandlerFunc = func(h func(string) error) { pongHandler = h }
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
// fire pong every 20ms — well within the 60ms keepalive window
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if pongHandler != nil {
|
||||||
|
_ = pongHandler("")
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
honeybeetest.Never(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventDisconnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected no EventDisconnected while pongs are arriving")
|
||||||
|
|
||||||
|
_ = incomingData // kept open to prevent reader EOF
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keepalive fires while connected - EventDisconnected emitted and redial begins", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
config, _ := NewWorkerConfig(
|
||||||
|
WithReconnectDelay(0),
|
||||||
|
WithKeepaliveTimeout(30*time.Millisecond),
|
||||||
|
)
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
config: config,
|
||||||
|
sendHeartbeat: make(chan struct{}),
|
||||||
|
processedCount: &atomic.Uint64{},
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
restartCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
_, mockSocket, _, _ := setupTestConnection(t)
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
// no activity — keepalive fires after 30ms
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventDisconnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventDisconnected after keepalive timeout")
|
||||||
|
|
||||||
|
// session must redial — a second EventConnected follows
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected after redial")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("socket close produces EventDisconnected then EventConnected", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.Start(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
close(incomingData)
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventDisconnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventDisconnected")
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected second EventConnected")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("connection pointer is nil between disconnect and reconnect", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { w.Start(pool) })
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
close(incomingData)
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventDisconnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventDisconnected")
|
||||||
|
|
||||||
|
// conn.Store(nil) happens before EventDisconnected is sent
|
||||||
|
assert.Nil(t, w.conn.Load(), "expected connection pointer to be nil after disconnect")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Stop produces EventDisconnected and wg drains", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := makeWorker(t, ctx, cancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.Start(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
w.Stop()
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventDisconnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventDisconnected")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(done) }()
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected wg to drain")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parent context cancel exits cleanly and wg drains", func(t *testing.T) {
|
||||||
|
parentCtx, parentCancel := context.WithCancel(context.Background())
|
||||||
|
workerCtx, workerCancel := context.WithCancel(parentCtx)
|
||||||
|
|
||||||
|
w := makeWorker(t, workerCtx, workerCancel)
|
||||||
|
_, events, pool := makeWorkerContext(t)
|
||||||
|
mockSocket := honeybeetest.NewMockSocket()
|
||||||
|
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
w.Start(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
return e.Kind == EventConnected
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected EventConnected")
|
||||||
|
|
||||||
|
// drain events after parent cancel — we don't assert what they are,
|
||||||
|
// only that the worker exits
|
||||||
|
parentCancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(done) }()
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected wg to drain after parent cancel")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerSend(t *testing.T) {
|
||||||
|
t.Run("data sent to mock socket", func(t *testing.T) {
|
||||||
|
conn, _, _, outgoingData := setupTestConnection(t)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
heartbeat := make(chan struct{})
|
||||||
|
heartbeatCount := atomic.Int32{}
|
||||||
|
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
sendHeartbeat: heartbeat,
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
w.conn.Store(conn)
|
||||||
|
defer w.cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for range heartbeat {
|
||||||
|
heartbeatCount.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
testData := []byte("hello")
|
||||||
|
err := w.Send(testData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// at least one heartbeat was sent
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
return heartbeatCount.Load() >= 1
|
||||||
|
}, "expected heartbeats")
|
||||||
|
|
||||||
|
// message was sent by the socket
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case msg := <-outgoingData:
|
||||||
|
return string(msg.Data) == "hello"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, "expected message")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sends one heartbeat per successful send", func(t *testing.T) {
|
||||||
|
conn, _, _, _ := setupTestConnection(t)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
heartbeat := make(chan struct{})
|
||||||
|
heartbeatCount := atomic.Int32{}
|
||||||
|
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
sendHeartbeat: heartbeat,
|
||||||
|
outgoingCount: &atomic.Uint64{},
|
||||||
|
}
|
||||||
|
w.conn.Store(conn)
|
||||||
|
defer w.cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for range heartbeat {
|
||||||
|
heartbeatCount.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
const count = 3
|
||||||
|
for i := range count {
|
||||||
|
err := w.Send(fmt.Appendf(nil, "msg-%d", i))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
honeybeetest.Eventually(t, func() bool {
|
||||||
|
return heartbeatCount.Load() == count
|
||||||
|
}, "expected heartbeats")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error if connection is unavailable", func(t *testing.T) {
|
||||||
|
// no connection available to worker
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
heartbeat := make(chan struct{})
|
||||||
|
|
||||||
|
w := &DefaultWorker{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: "wss://test",
|
||||||
|
sendHeartbeat: heartbeat,
|
||||||
|
}
|
||||||
|
defer w.cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for range heartbeat {
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := w.Send([]byte("hello"))
|
||||||
|
assert.ErrorIs(t, err, ErrConnectionUnavailable)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user