Action Cable: make duplicate subscribe idempotent#57504
Action Cable: make duplicate subscribe idempotent#57504samuel-williams-shopify wants to merge 1 commit into
Conversation
When a client sends a `subscribe` command for an identifier it is already subscribed to, re-transmit `confirm_subscription` instead of raising `Subscriptions::AlreadySubscribedError`. The `subscribed` callback is not invoked a second time and no new subscription is registered, so user code with side-effects (e.g. `stream_from`) is not duplicated. The client's subscription guarantor receives a confirmation and resolves rather than spinning on retries. Removes the `Subscriptions::AlreadySubscribedError` class, which was introduced in the as-yet-unreleased server adapterization refactor and has not shipped in any released version of Rails. This addresses long-standing reports going back to 2016: - rails#24875 (2016): "Cable: Should error on double-subscribe" \u2014 the original debate over what to do here; never resolved. - rails#44652 (2022): "ActionCable: Repeated subscription attempts" \u2014 25+ comments documenting Turbo / SPA / reconnect scenarios where the silent no-op left the client retrying every second forever. The issue reporter proposed re-transmitting the confirmation; this commit implements that proposal. - rails#48292 (2023): "ActionCable sporadically fails to confirm subscribe because the subscription 'already exists'" \u2014 Turbo-specific reproduction, closed as stale without a fix. - rails#44653 (2022, open): companion client-side workaround PR. Many of those reporters mention switching to AnyCable as the only practical workaround, but the relevant code paths are now shared via the recent upstream sync, so the bug exists everywhere. PR rails#50979 ("Action Cable server adapterization", merged 2026-05-28) replaced the prior silent no-op with a raise of `AlreadySubscribedError`, addressing rails#24875's call for a clearer signal but worsening rails#44652 (clients now see an unhandled exception in addition to never receiving confirmation). This commit picks a third option: be idempotent at the protocol layer by re-sending the confirmation, which is what every real-world client expects when it re-issues a subscribe.
|
@palkan can you please review, thanks! |
|
The only thing I'm not really sure about is whether subscribe and unsubscribe are supposed to be matched for a given connection... or whether multiple subscribes followed by a single unsubscribe is sufficient to actually unsubscribe - e.g. multiple components subscribing, a single component unsubscribes, then no updates are received, etc. |
Ha, my next suggested move was to introduce a proper IMO, a client sending double You've already raised one concern regarding re-sending confirmations:
It should be sufficient; both the client and the server are assumed to have only one subscription for a given identifier (but there could be multiple Subscription objects (=event handlers) attached to a given subscription). However, think about race conditions (typical in Hotwire apps): Here is an example:
Without the re-transmission it would attempt to re-subscribe again and restore the state (though I think there could still be a room for race condition here, too).
What about the client-side semantics for I chose raising an exception in the first place (in #50979) to highlight that such things should never happen if the client behaves correctly. This is an exception, not an error (semantically). The real problem is the client, not the server.
To handle all of these, the client must be careful about managing state transitions and keeping track of the intents. We can't fix that server-side; we can just hack around some issues (though likely at the cost of introducing new ones). |
|
(Also I'll close this PR since we don't want to change this behaviour). |
Make Action Cable's handling of duplicate
subscribecommands idempotent: when a client subscribes to a channel it is already subscribed to, the server re-transmitsconfirm_subscriptionand returns. Thesubscribedcallback is not invoked again, no new subscription is registered, and the client's subscription guarantor receives a confirmation and resolves.This replaces the current behaviour of raising
ActionCable::Connection::Subscriptions::AlreadySubscribedError(introduced in the unreleased adapterization refactor in #50979 and not yet present in any tagged Rails release).Subscriptions::AlreadySubscribedErroris removed; the other error classes (MalformedCommandError,ChannelNotFound,UnknownCommandError,UnknownSubscription) are unaffected.Motivation
The right behaviour for duplicate
subscribehas been an open question in Action Cable for nearly a decade. Multiple long-running issues describe the same underlying problem:confirm_subscriptionand retries every second forever. The reporter (@sj26) explicitly proposed "send a subscription confirmation from the actioncable server when the client asks for a subscription which is already in place, instead of swallowing it silently." That proposal is what this PR implements.A common theme across these reports is users either carrying a custom client-side patch or switching to AnyCable to escape the bug. The relevant code paths are now shared via the recent upstream sync, so the symptom now exists in stock Rails as well.
PR #50979 ("Action Cable server adapterization", merged 2026-05-28) changed the long-standing silent
returntoraise AlreadySubscribedError. That addresses #24875's request for an explicit signal, but it makes #44652 strictly worse: clients still don't receive the confirmation they need, and now there's also an unhandled exception propagating up the connection.Why idempotent re-transmit is the right answer
Realistic clients re-issue
subscribecommands as a matter of normal operation, not as a programming error:<turbo-cable-stream-source>is re-inserted into the DOM on morph/refresh cycles, which triggers a re-subscribe.From the client's perspective, the meaningful response to "please subscribe me to X" when it is already subscribed is "you are subscribed to X" — exactly the
confirm_subscriptionframe the client is waiting for. That's what this PR sends. It preserves the at-most-once semantics of#subscribedcallbacks (no side-effect duplication) while resolving the client's state machine.Why removing
AlreadySubscribedErroris safeThe class was introduced by commit
7a8f26dcff("Improve exceptions in Action Cable subscriptions") inside PR #50979, which merged tomainon 2026-05-28. The most recent tagged release is v8.1.3 (2026-03-24), which predates the merge. ThereforeAlreadySubscribedErrorhas never appeared in a released Rails. No user code outsiderails/railsmain can be depending on its name today.Behavioural diff
main)subscriptionsHash unchanged#subscribedruns only onceAlreadySubscribedError)Test
actioncable/test/connection/subscriptions_test.rbnow asserts that resubscribing:#subscribeda second time.confirm_subscriptionto the client.subscriptions.identifiers.sizeat 1.Full ActionCable test suite passes (238 runs, 0 failures, 0 errors).
Notes for reviewers
Subscriptions::Errorbase class and the remaining four error subclasses are unaffected.MalformedCommandErrorcontinues to be raised whenidentifieris missing (genuine protocol violation).@rails/actioncable, ports, third parties) need no change. Existingsubscription.receivedcallbacks fire on the re-confirmation if registered, matching their behaviour for the initial confirmation.