Skip to content

Action Cable: make duplicate subscribe idempotent#57504

Closed
samuel-williams-shopify wants to merge 1 commit into
rails:mainfrom
samuel-williams-shopify:idempotent-duplicate-subscribe
Closed

Action Cable: make duplicate subscribe idempotent#57504
samuel-williams-shopify wants to merge 1 commit into
rails:mainfrom
samuel-williams-shopify:idempotent-duplicate-subscribe

Conversation

@samuel-williams-shopify
Copy link
Copy Markdown

@samuel-williams-shopify samuel-williams-shopify commented May 29, 2026

Make Action Cable's handling of duplicate subscribe commands idempotent: when a client subscribes to a channel it is already subscribed to, the server re-transmits confirm_subscription and returns. The subscribed callback 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::AlreadySubscribedError is removed; the other error classes (MalformedCommandError, ChannelNotFound, UnknownCommandError, UnknownSubscription) are unaffected.

Motivation

The right behaviour for duplicate subscribe has been an open question in Action Cable for nearly a decade. Multiple long-running issues describe the same underlying problem:

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 return to raise 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 subscribe commands as a matter of normal operation, not as a programming error:

  • Turbo's <turbo-cable-stream-source> is re-inserted into the DOM on morph/refresh cycles, which triggers a re-subscribe.
  • React / Vue / Svelte components subscribe in lifecycle hooks; remounting re-subscribes.
  • After a transient network issue, a client may resubscribe before the server has noticed the previous connection went away.
  • The original race condition addressed by @palkan's Avoid race condition on subscription confirmation #26547 in 2016 (which introduced the silent return in the first place): a deferred confirmation hadn't yet been sent when the client issued a follow-up command, so the client retried.

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_subscription frame the client is waiting for. That's what this PR sends. It preserves the at-most-once semantics of #subscribed callbacks (no side-effect duplication) while resolving the client's state machine.

Why removing AlreadySubscribedError is safe

The class was introduced by commit 7a8f26dcff ("Improve exceptions in Action Cable subscriptions") inside PR #50979, which merged to main on 2026-05-28. The most recent tagged release is v8.1.3 (2026-03-24), which predates the merge. Therefore AlreadySubscribedError has never appeared in a released Rails. No user code outside rails/rails main can be depending on its name today.

Behavioural diff

Before #50979 After #50979 (current main) This PR
subscriptions Hash unchanged
#subscribed runs only once
Client receives a confirmation ❌ (#44652) ❌ + exception
Exception propagates up ✅ (AlreadySubscribedError)

Test

actioncable/test/connection/subscriptions_test.rb now asserts that resubscribing:

  1. Does not invoke #subscribed a second time.
  2. Transmits a fresh confirm_subscription to the client.
  3. Leaves subscriptions.identifiers.size at 1.

Full ActionCable test suite passes (238 runs, 0 failures, 0 errors).

Notes for reviewers

  • The Subscriptions::Error base class and the remaining four error subclasses are unaffected.
  • MalformedCommandError continues to be raised when identifier is missing (genuine protocol violation).
  • The fix is purely server-side; clients (@rails/actioncable, ports, third parties) need no change. Existing subscription.received callbacks fire on the re-confirmation if registered, matching their behaviour for the initial confirmation.

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.
@samuel-williams-shopify
Copy link
Copy Markdown
Author

@palkan can you please review, thanks!

@samuel-williams-shopify
Copy link
Copy Markdown
Author

samuel-williams-shopify commented May 29, 2026

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.

@palkan
Copy link
Copy Markdown
Contributor

palkan commented May 29, 2026

This replaces the current behaviour of raising ActionCable::Connection::Subscriptions::AlreadySubscribedError

Ha, my next suggested move was to introduce a proper "type":"error" message to communicate the problem to the client 😁

IMO, a client sending double subscribe commands, or sending subscribe after receiving a confirmation, is a misbehaving client. We should fix it, not the server (that's what we did in anycable-client). And that's why I think introducing an error response is the right move.

You've already raised one concern regarding re-sending confirmations:

whether multiple subscribes followed by a single unsubscribe is sufficient to actually unsubscribe

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): subscribe-s and unsubscribe-s could be fired in any order, how to handle that? (I'm linearizing them per identifier in anycable-client).

Here is an example:

  • A client has a confirmed subscription
  • The client sends unsubscribe and subscribe one immediately after each other
  • The server process them in separate threads
  • The #subscribed callback finishes first—it's fast, it's just re-sending the confirmation
  • The #unsubscribed callback took a bit longer (some state cleanup) and it finishes last—efficiently removing the server-side subscription
  • The client receives the confirmation—all looks good! But in fact it's unsubscribed.

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).

It preserves the at-most-once semantics of #subscribed callbacks (no side-effect duplication) while resolving the client's state machine.

What about the client-side semantics for connected() callbacks? Receiving multiple confirmations would result in double-execution of the connected callbacks (currently). They should also be at-most-once, too. There is a simple workaround: to add a hint to the confirmation message that this is a re-transmission (and handle it in the client). But I'd hesitate to extend the protocol in such a way. Or we can track the subscription state on the client side (I don't see us doing that) and ignore subsequent confirmations.


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.


Turbo's is re-inserted into the DOM on morph/refresh cycles, which triggers a re-subscribe.
React / Vue / Svelte components subscribe in lifecycle hooks; remounting re-subscribes.
After a transient network issue, a client may resubscribe before the server has noticed the previous connection went away.

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).

@ioquatix
Copy link
Copy Markdown
Contributor

ioquatix commented May 30, 2026

@palkan, that makes sense to me. If it's a client side issue, raising makes total sense.

In consideration of that, is something like #44653 the right way forward?

@samuel-williams-shopify
Copy link
Copy Markdown
Author

(Also I'll close this PR since we don't want to change this behaviour).

@samuel-williams-shopify samuel-williams-shopify deleted the idempotent-duplicate-subscribe branch May 30, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants