Make the client's lifecycle observable and its teardown safe - #7
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate concurrency races can violate connection and callback-ordering guarantees.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Makes client lifecycle and subscription teardown observable while adding bounded retries and safer shutdown behavior.
Changes:
- Adds
Client.Done(),Client.Err(),Subscription.Err(), and lifecycle errors. - Adds
WithMaxAttemptsand preserves connection failure details. - Closes
Messagesafter callbacks finish and removesUnsubscribe’s context parameter.
File summaries
| File | Review |
|---|---|
subscription.go |
Tracks subscription termination reasons and updates unsubscribe behavior. |
README.md |
Documents lifecycle, retry, teardown, and breaking API changes. |
options.go |
Adds maximum-attempt configuration. |
errors.go |
Defines new lifecycle and subscription errors. |
dispatcher.go |
Adds callback draining, but has a moderate enqueue-after-stop race; also contains a grammatical nit. |
client.go |
Implements lifecycle and retry handling, but has a moderate race between welcome and shutdown. |
client_test.go |
Tests lifecycle, retries, callback ordering, and unsubscribe behavior. |
Review details
Suppressed comments (1)
dispatcher.go:14
- This sentence is grammatically incomplete; use “then stops.”
// Once stopped it runs what it still holds, then stopped. That is how a
// subscription closes Messages only after its last callback has returned.
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Callers had to work around several things the client kept to itself: - A Connect that ran out of time left its retry loop running with no one to stop it. Connect now stops the client when it gives up waiting, so a failed Connect leaves nothing behind. The error wraps whatever the last attempt failed on, so a header that couldn't be built shows up instead of hiding behind a deadline. - There was no way to ask a client whether it had stopped, or why, short of calling Connect and reading the tea leaves. Done and Err report it. - Retrying could not be capped. WithMaxAttempts stops the client with ErrGaveUp after that many failures in a row; a welcome resets the count. - A subscription's callbacks could still run after Messages had closed, since the dispatcher drained its queue after being stopped. Messages now closes from the callback goroutine, after the last callback returns. - A closed Messages channel didn't say why. Subscription.Err reports ErrUnsubscribed, ErrRejected, or what stopped the client. - Unsubscribe needed a live context at exactly the moment callers were tearing down under a dead one. The command now goes out on the client's own connection context, and Unsubscribe takes no context.
A Connect that ran out of time checked for a welcome, then stopped the client, as two steps. A welcome landing between them was torn down and reported as a timeout. The check and the stop are now one decision under the client's lock. A callback handed to a dispatcher after its final drain was never run, and Messages closed with it still queued. Stopping and dispatching now share the dispatcher's lock: whatever was queued before the stop runs, and whatever comes after is turned away.
7df15e8 to
cfca2ee
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The terminal disconnect callback behavior and race-prone retry test require correction before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
README.md:91
Connectcan also returnErrAlreadyConnected, which leaves the existing client running rather than stopped. This advice should call out that exception so users do not throw away a healthy client after a duplicate call.
A `Connect` that returns an error leaves the client stopped, with nothing running
behind it. Throw it away and make a new one.
client.go:149
- This guarantee has an exception: a second
ConnectreturnsErrAlreadyConnectedat line 159 while the original client remains healthy and running. As written, callers may discard a live client based on the documentation; explicitly exclude that error from the teardown guarantee.
// ctx bounds the wait, not a connection that got through: that lives until Close.
// A Connect that returns an error leaves the client stopped, with nothing running
// behind it, so a client that failed to connect is one to throw away.
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
The attempt that exhausted WithMaxAttempts was counted after the session returned, by which time its deferred disconnect had already told every subscription the client would reconnect. The count, and the stop it can lead to, now happen inside the session ahead of that, so OnDisconnected reports false on the terminal attempt. Also queues the refused dial before dropping the connection in the attempt-reset test, so the redial can't beat it, and notes that ErrAlreadyConnected is the one Connect error that leaves the client running.
There was a problem hiding this comment.
🔵 Needs a closer look
The verdict callback race can cause observable callbacks to be lost.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
dispatcher.go:45
- This can discard a callback for an event that already happened.
Subscription.confirmandSubscription.rejectclose their verdict channel before callingdispatch; the awakenedSubscribepath can therefore unsubscribe/forget the subscription and stop this dispatcher before the correspondingOnConnected/OnRejectedis queued. In the rejection pathSubscribeitself callsforget, soTestSubscribeRejectedcan race and report thatOnRejectedwas never called. Queue each verdict callback before publishing the verdict, or make publishing and dispatch atomic with stopping.
client.go:330 - This says shutdown waits until nothing is running, but
awaitStoppedonly waits for the client run loop. Subscription dispatchers drain asynchronously aftercloseSubscriptions;TestMessagesCloseAfterTheLastCallbackReturnsexplicitly hasClosereturn while a callback is still blocked. Please narrow this comment so callers do not assume callback teardown is complete.
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
confirm and reject closed their channel first and dispatched the callback second. A Subscribe woken by the channel could unsubscribe, or forget a rejected subscription, and stop the dispatcher before the callback for the very event that woke it was queued. The dispatcher now turns away anything queued after it stops, so that callback was simply lost. Dispatching first means the callback is in the queue before anyone can learn there is something to react to. Also narrows shutdown's comment: it waits for the connection goroutine, not for the subscriptions' callbacks, which drain on their own.
hey-cli ended up working around a handful of things the client kept to itself. This makes the client say them instead.
Connectstops the client. Before, aConnectwhose context ran out left the retry loop running with nothing to stop it. Now it shuts the client down, and the error wraps whatever the last attempt failed on, so a header that couldn't be built shows up rather than hiding behind a deadline. Retry semantics for header errors are unchanged.Client.Done()andClient.Err(). A stopped client can be noticed and asked why, instead of probing withConnectand reading the tea leaves.WithMaxAttempts(n). Stops withErrGaveUpafter n failures in a row. A welcome resets the count, so it bounds an outage, not the client's lifetime.Messagescloses after the last callback returns. The dispatcher used to drain its queue after being stopped, soOnConnectedcould fire afterMessagesclosed. Now the channel closes from the callback goroutine as its final act.Subscription.Err(). Says whyMessagesclosed:ErrUnsubscribed,ErrRejected, or whatever stopped the client. That includes a rejection after a reconnect, which was only observable through the callback.Unsubscribe()takes no context. The command goes out on the client's own connection context, so it works during a teardown whose context has already ended. This is the one breaking change.Independent of #6, which fixes the duplicate-subscribe hang.