Repository navigation
Conversation
The socket kept the state of its current transport in its own fields, so asynchronous events, timers and user callbacks belonging to one connection could act on the next one, which caused a series of races. Wrap each transport instance in a Connection, which owns the state of that connection: its events are only handled while it is the socket's current connection, and it records whether we closed it, whether the socket gave up on it, whether its channels were errored, and which channel joins it carried. Code continuing after user callbacks or asynchronous waits checks whether the socket still uses its connection, as callbacks can disconnect or connect at any time. This fixes that: - channels stayed joined when the close event arrived late or after connect() replaced the connection, resulting in "unmatched topic" - events of a replaced connection acted on its replacement - heartbeats kept running after disconnecting, or were restarted by a late reply, and then reconnected the socket - teardowns, heartbeat timeouts and close or error handling reconnected after callbacks had disconnected or connected - disconnecting while connecting fell back to LongPoll, fallback callbacks accumulated and acted on later attempts, and a normal close by the server did not cancel the fallback health check - connecting twice while disconnecting leaked a connection - a stale buffered join was sent in addition to the rejoin - a heartbeat timeout errored channels while still connected - resume and visibilitychange tore down the connection they created - callbacks removed while dispatching a message were skipped - a normal close by the server was reconnected on visibility change, while connect() did nothing - channels joined by error callbacks were errored again - asynchronous encoders and decoders acted on replaced or closed connections
LongPoll aborted its requests when closing, but aborting can invoke the request callbacks, synchronously for XHR and asynchronously for fetch, and those still acted on the closed transport: a spurious error and a retry, or making it active again after a disconnect. Messages queued for delivery were still delivered after the close, a retry overrode a close callback that closed it for good, and an open callback closing it did not stop the next poll. A request now belongs to the transport until it completes or the transport stops, and callbacks of a request that does not belong to it anymore are ignored. Stopping also cancels the queued messages and the pending outgoing batch, a retry becomes active again before the close callbacks run, and polling stops once the transport is closed.
Socket.push only received the message, so finding out whether a push was outdated meant searching all channels for the one with its topic and join ref, up to three times per push: when it was buffered and flushed, when it was sent, and when its encoding finished. Push now passes its channel to socket.push, which turns these checks into comparing the push's join ref with the channel's current one and checking whether the channel left.
Breaking the code each test guards showed that some tests no longer failed for their regression: - "does not throw when encoding finishes after disconnect completed" could not fail anymore, as its join push is dropped as outdated before it is sent. It now checks that a push is not sent once its connection was replaced while it was encoded. - "does not connect twice" only checked after the scheduled reconnect, when an extra connection was still being torn down. It now also checks right after connect(). - "ignores errors and messages of a connection that was replaced" closed the old connection, which already detached its error handler. The old connection now still drains its buffer. - "does not error channels when the connection never opened" now checks that the channel is not errored at all. Also cover that replacing the transport while a teardown waits prevents its reconnect, like disconnecting does.
When LongPoll stops, it now also drops a batch that was still collecting sends, as its timer is cancelled. Otherwise, sends after a retry were appended to that batch and never sent. Also remove the test for a timeout after close, as the tests for abort callbacks after close and for timeouts after a retry cover it.
A connection only handles the events of its transport while it is the socket's current connection, so detaching the handlers of a replaced or torn down connection made no difference.
A normal close by the server cancels a scheduled reconnect, which would otherwise tear down a connection created by calling connect() again before the reconnect was due.
onConnError skipped erroring the channels when its callbacks replaced the connection. However, when a callback replaced the transport of a connection that already failed (and was therefore no longer open), replaceTransport did not error the channels either, so they stayed joined and did not rejoin on the next connection. Always error the channels for the connection afterwards, which only errors the joins it carried once it was replaced.
fallback() checked whether its attempt was still current, but the fallback timer is cleared whenever the attempt ends, and the error and open callbacks only call it while the attempt is current. The ping of the health check checked it as well, but its reply can only arrive on the connection that sent it, and it shows that the primary transport works even after a disconnect.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #6856.
I did multiple passes with Codex and Claude, since I was sure there are other cases. Turns out there indeed were quite a bunch of race conditions when handling connection lifecycle events (#6856 addresses one of them):
onOpen,onError, or channel error callbacks) did not stop it from connecting over LongPollresumeandvisibilitychangeeventsLongpoll specific:
Other notable changes:
"disconnect"were adjusted in May to send 1001 instead, which does reconnect immediately, which should maybe be revisited (see Make websocket disconnect codes explicit #6678). The docs never said if a reconnection on the client is expected or not, they just say it "terminates all active sockets and channels for a given user". LiveView intentionally does a full page reload on 1000, but uses the jittered reload, so that's what a user complained about in PR #576 (1.10.4) interacts adversely with Phoenix LiveView's "log out everywhere" flow — close code 1000 makes phoenix.js skip reconnect → ~4-5 s UX freeze mtrudel/bandit#582. Since it is usually used for user specific disconnects, the jitter does seem unnecessary for "disconnect". It may be useful to have"disconnect"and"reconnect"events.socket.conn(undocumented)Assisted-by: Codex GPT-6.1-Sol, Claude Code Opus 5.5
Most of the added lines are regression tests.