Skip to content

Fix connection and subscription stability bugs - #109

Merged
FZambia merged 1 commit into
masterfrom
conn_stability
May 19, 2026
Merged

Fix connection and subscription stability bugs#109
FZambia merged 1 commit into
masterfrom
conn_stability

Conversation

@FZambia

@FZambia FZambia commented May 19, 2026

Copy link
Copy Markdown
Member

Summary

This PR fixes several bugs that could cause stuck reconnect loops, missed resubscriptions, crashes, and silent event loss under realistic concurrency conditions.


Bugs fixed

1. Client stuck in connecting state after transport closes mid-connect

When the WebSocket closed while a ConnectRequest was in flight, the transport's
_onDone callback ran synchronously (via Completer.sync()), calling
_processDisconnect + _scheduleReconnect before the catch block in
_connectInner resumed. The catch then called _processDisconnect a second
time, which cancelled the already-scheduled reconnect timer without rescheduling
one, leaving the client permanently stuck in connecting.

Fix: Return early from _connectInner's catch when the error is
ClientDisconnectedError; the reconnect timer is already running.


2. Server-initiated temporary unsubscribe did not trigger resubscription

When the server sent an Unsubscribe push with code ≥ 2500 (temporary, e.g. code
2502 for state invalidation), _handleUnsubscribe called moveToSubscribing to
transition the subscription but never attempted a resubscribe. Unlike a full
disconnect, the client remains connected in this case, so waiting for a reconnect
means the subscription never recovers.

Fix: Call resubscribeOnConnect() after moveToSubscribing() for all
temporary server unsubscribes (code ≥ 2500 and the existing code 2502 path).


3. _resubscribe lacked a concurrency guard

_resubscribe() had no mutex, so concurrent calls (e.g. from a pending retry
timer firing at the same moment as resubscribeOnConnect() on reconnect) could
result in two simultaneous SubscribeRequests for the same channel.

Fix: Added _resubscribing boolean flag (set/cleared in try/finally) that
causes any concurrent call to return immediately.


4. State not checked after getToken await in _resubscribe

After await _config.getToken!(event) yielded, a concurrent unsubscribe() or
disconnect could change the subscription state. The code proceeded to build and
send a SubscribeRequest regardless.

Fix: Added if (state != subscribing || client.state != connected) return
immediately after the getToken await.


5. _addUnsubscribe StateError on concurrent close + unsubscribe

When client.close() ran concurrently with subscription.unsubscribe(), the
moveToUnsubscribed future could resume after subscription.close() had already
closed _unsubscribedController. Calling .add() on a closed broadcast
StreamController throws StateError.

Fix: _addUnsubscribe now checks !_closed before adding to the stream.


6. subscription.close() did not clean up state before closing streams

close() closed the stream controllers but did not set _closed = true, cancel
pending timers, error out pending ready() futures, or set state to unsubscribed
first. This left a window where timer callbacks and async continuations could still
try to interact with the subscription after it was destroyed.

Fix: close() now sets _closed = true, cancels _resubscribeTimer and
_refreshTimer, calls _errorReadyFutures, and sets state = unsubscribed before
closing any controllers.


7. subscribe() did not guard against calling on a closed subscription

After client.close(), calling subscription.subscribe() would proceed past the
state check (state was unsubscribed but _closed was unset), producing confusing
errors downstream.

Fix: subscribe() now throws ClientClosedError when _closed is true.


8. Missing UnsubscribedEvent when unsubscribe cleanup send fails

In moveToUnsubscribed, if the cleanup UnsubscribeRequest to the server failed
and prevState was subscribed, the code triggered a reconnect and returned
early — but did so without emitting the UnsubscribedEvent to the caller. The
caller's await unsubscribe() would complete silently with no event on the stream.

Fix: Added _addUnsubscribe(UnsubscribedEvent(code, reason)) before the early
return in that error path.


9. _errorController.add after client.close() in _connectInner (4 sites)

If client.close() ran while _connectInner was awaiting getToken, transport.open,
or getData, the continuation could fire after _errorController was closed,
causing a StateError: Cannot add event after closing.

Fix: Added if (_closed) return guards at all four affected sites:

  • getToken exception catch
  • transport.open onError callback
  • transport.open exception catch
  • getData exception catch

10. Transport decode errors crashed the stream listener

A malformed incoming frame caused _replyDecoder.convert() to throw, which
propagated uncaught through the WebSocket stream onData handler, killing the
listener and silently stopping all further message processing.

Fix: Wrapped _replyDecoder.convert() in a try/catch; decode errors are now
forwarded to the onError callback so the connection handles them gracefully.


11. backoffDelay panicked with minReconnectDelay: Duration.zero

When minReconnectDelay was zero, val.toInt() evaluated to 0 and
Random.nextInt(0) threw a RangeError.

Fix: Return minDelay immediately when val <= 0.


12. _refreshToken missing state check after getToken await

_refreshToken() on the client checked state before calling getToken but not
after. A disconnect during the getToken await could leave the client in a
non-connected state, yet _refreshToken would still proceed to send a
RefreshRequest against the (now-null) transport.

Fix: Added if (state != State.connected) return after the getToken await,
before the RefreshRequest is built.


13. send() used wrong protobuf message type

Client.send() was constructing protocol.Message instead of
protocol.SendRequest, causing a runtime ArgumentError from the transport's
command encoder on every call.

Fix: Changed to protocol.SendRequest.


14. onError callback called transport.close() redundantly

The onError handler inside transport.open(...) called transport.close(), but
by the time a stream error fires the transport is already in an error state and the
onDone path handles cleanup. The extra close caused a double-close and surfaced
as a spurious error event.

Fix: Removed the transport.close() call from the onError handler.


Tests added

  • close() errors pending ready() futures instead of leaking them
  • removeSubscription after close() does not throw StateError
  • unsubscribe() during getToken does not create a server-side subscription (validates _inflight cleanup path)
  • subscribe() on a closed subscription throws SubscriptionUnsubscribedError
  • Zero minReconnectDelay does not crash on retryable disconnect

@FZambia
FZambia merged commit 2c93298 into master May 19, 2026
7 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.

1 participant