Skip to content

Make the client's lifecycle observable and its teardown safe - #7

Merged
monorkin merged 4 commits into
mainfrom
worktree-client-lifecycle-fixes
Sep 7, 2026
Merged

monorkin merged 4 commits into
mainfrom
worktree-client-lifecycle-fixes

Conversation

@monorkin

@monorkin monorkin commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

hey-cli ended up working around a handful of things the client kept to itself. This makes the client say them instead.

  • A failed Connect stops the client. Before, a Connect whose 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() and Client.Err(). A stopped client can be noticed and asked why, instead of probing with Connect and reading the tea leaves.
  • WithMaxAttempts(n). Stops with ErrGaveUp after n failures in a row. A welcome resets the count, so it bounds an outage, not the client's lifetime.
  • Messages closes after the last callback returns. The dispatcher used to drain its queue after being stopped, so OnConnected could fire after Messages closed. Now the channel closes from the callback goroutine as its final act.
  • Subscription.Err(). Says why Messages closed: 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.

Copilot AI balanced review requested due to automatic review settings September 7, 2026 15:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 WithMaxAttempts and preserves connection failure details.
  • Closes Messages after callbacks finish and removes Unsubscribe’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.

Comment thread client.go Outdated
Comment thread dispatcher.go Outdated
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.
@monorkin
monorkin force-pushed the worktree-client-lifecycle-fixes branch from 7df15e8 to cfca2ee Compare September 7, 2026 15:23
@monorkin
monorkin requested a balanced review from Copilot September 7, 2026 15:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • Connect can also return ErrAlreadyConnected, 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 Connect returns ErrAlreadyConnected at 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

Comment thread client.go Outdated
Comment thread client_test.go Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.confirm and Subscription.reject close their verdict channel before calling dispatch; the awakened Subscribe path can therefore unsubscribe/forget the subscription and stop this dispatcher before the corresponding OnConnected/OnRejected is queued. In the rejection path Subscribe itself calls forget, so TestSubscribeRejected can race and report that OnRejected was 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 awaitStopped only waits for the client run loop. Subscription dispatchers drain asynchronously after closeSubscriptions; TestMessagesCloseAfterTheLastCallbackReturns explicitly has Close return 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.
@monorkin
monorkin merged commit ea2cdf5 into main Sep 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants