Skip to content

Commit f35628b

Browse files
authored
Address connection races (#6867)
* Scope socket state to the connection it belongs to 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 * Cancel pending LongPoll work when it stops 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. * Pass the channel along with its pushes 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. * fmt * Make socket tests catch the regressions they are meant for 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. * Cover dropping the pending LongPoll batch on retry 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. * Remove detaching the handlers of a torn down connection 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. * Cover cancelling a scheduled reconnect on a normal close 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. * Error channels after socket error callbacks replaced the connection 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. * Remove redundant checks of the fallback attempt 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. * more fixes * longpoll wasClean fix * don't accumulate ping callbacks when connection drops
1 parent 4c1df11 commit f35628b

7 files changed

Lines changed: 2089 additions & 136 deletions

File tree

‎assets/js/phoenix/connection.js‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {SOCKET_STATES} from "./constants"
2+
3+
/**
4+
* @private
5+
*
6+
* A connection of the socket over a single transport instance.
7+
*
8+
* It owns everything that belongs to this connection, so that nothing about it can
9+
* affect a later connection: the transport events are only handled while it is the
10+
* socket's current connection, and its state is gone together with it.
11+
*/
12+
export default class Connection {
13+
/**
14+
* @param {Socket} socket
15+
* @param {Object} transport - The transport instance, for example a WebSocket
16+
*/
17+
constructor(socket, transport){
18+
this.socket = socket
19+
this.transport = transport
20+
// the join ref of each channel whose join was sent over this connection, weakly held
21+
// as a connection can outlive many channels, e.g. when navigating between LiveViews
22+
this.joins = new WeakMap()
23+
// the callbacks of the pings sent over this connection by ref, as their replies can only
24+
// arrive over it, so they are gone together with it instead of accumulating on the socket
25+
this.pings = new Map()
26+
// set when we close the connection, as opposed to the server or the network
27+
this.closing = false
28+
// set when the socket does not want this connection anymore, e.g. on disconnect,
29+
// so that connecting again creates a new one instead of waiting for it
30+
this.ended = false
31+
// set once the channels were errored after the connection stopped being open
32+
this.channelsErrored = false
33+
// set once the close event was handled, as LongPoll can emit it more than once
34+
this.closeHandled = false
35+
transport.onopen = () => { if(this.isCurrent()){ socket.onConnOpen() } }
36+
transport.onerror = error => { if(this.isCurrent()){ socket.onConnError(error) } }
37+
transport.onmessage = event => { if(this.isCurrent()){ socket.onConnMessage(event) } }
38+
transport.onclose = event => {
39+
if(this.isCurrent() && !this.closeHandled){
40+
this.closeHandled = true
41+
socket.onConnClose(event)
42+
}
43+
}
44+
}
45+
46+
/**
47+
* Whether this is the connection the socket currently uses. A replaced connection
48+
* can still emit events while it closes, but they no longer concern the socket.
49+
*/
50+
isCurrent(){ return this.socket.connection === this }
51+
52+
isOpen(){ return this.transport.readyState === SOCKET_STATES.open }
53+
54+
isConnecting(){ return this.transport.readyState === SOCKET_STATES.connecting }
55+
56+
/**
57+
* Whether the channel's current join was sent over this connection.
58+
*/
59+
carried(channel, joinRef = channel.joinRef()){
60+
return this.joins.has(channel) && this.joins.get(channel) === joinRef
61+
}
62+
63+
/**
64+
* Closes the transport. Its close event then only confirms our close.
65+
*/
66+
close(code, reason){
67+
this.closing = true
68+
// closing a transport that is still connecting makes it emit an error, which is
69+
// caused by closing it and must not be handled as a transport error
70+
this.transport.onerror = function (){ } // noop
71+
if(code){ this.transport.close(code, reason || "") } else { this.transport.close() }
72+
}
73+
}

‎assets/js/phoenix/longpoll.js‎

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export default class LongPoll {
2626
this.endPoint = null
2727
this.token = null
2828
this.skipHeartbeat = true
29+
// the pending requests and queued messages, which are cancelled when we stop
2930
this.reqs = new Set()
31+
this.messageTimers = new Set()
3032
this.awaitingBatchAck = false
3133
this.currentBatch = null
3234
this.currentBatchTimer = null
@@ -37,7 +39,8 @@ export default class LongPoll {
3739
this.onclose = function (){ } // noop
3840
this.pollEndpoint = this.normalizeEndpoint(endPoint)
3941
this.readyState = SOCKET_STATES.connecting
40-
// we must wait for the caller to finish setting up our callbacks and timeout properties
42+
// we must wait for the caller to finish setting up our callbacks and timeout properties,
43+
// and it might close us before that, e.g. when disconnecting right after connecting
4144
setTimeout(() => this.poll(), 0)
4245
}
4346

@@ -52,19 +55,26 @@ export default class LongPoll {
5255
return this.pollEndpoint
5356
}
5457

55-
closeAndRetry(code, reason, wasClean){
56-
this.close(code, reason, wasClean)
57-
this.readyState = SOCKET_STATES.connecting
58+
closeAndRetry(error, code, reason, wasClean){
59+
if(!this.isActive()){ return }
60+
// like a failed WebSocket, we are no longer open when we emit the error and then the close.
61+
// We are connecting again before their callbacks run, so that they can still close us for
62+
// good, e.g. when they disconnect the socket
63+
this.stop(SOCKET_STATES.connecting)
64+
this.onerror(error)
65+
if(this.isActive()){ this.emitClose(code, reason, wasClean) }
5866
}
5967

6068
ontimeout(){
61-
this.onerror("timeout")
62-
this.closeAndRetry(1005, "timeout", false)
69+
this.closeAndRetry("timeout", 1005, "timeout", false)
6370
}
6471

6572
isActive(){ return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting }
6673

6774
poll(){
75+
// onopen can close us before the next poll, just as the caller can close us
76+
// before the constructor's deferred first poll
77+
if(!this.isActive()){ return }
6878
const headers = {"Accept": "application/json"}
6979
if(this.authToken){
7080
headers["X-Phoenix-AuthToken"] = this.authToken
@@ -75,8 +85,7 @@ export default class LongPoll {
7585
if(status === 410 && this.token !== null){
7686
// In case we already have a token, this means that our existing session
7787
// is gone. We fail so that the client rejoins its channels.
78-
this.onerror(410)
79-
this.closeAndRetry(3410, "session_gone", false)
88+
this.closeAndRetry(410, 3410, "session_gone", false)
8089
return
8190
}
8291
this.token = token
@@ -105,7 +114,14 @@ export default class LongPoll {
105114
//
106115
// In order to emulate this behaviour, we need to make sure each
107116
// onmessage handler is run within its own macrotask.
108-
setTimeout(() => this.onmessage({data: msg}), 0)
117+
//
118+
// We might stop before that, e.g. when a POST times out or a message
119+
// handler disconnects, which cancels the remaining messages.
120+
const timer = setTimeout(() => {
121+
this.messageTimers.delete(timer)
122+
this.onmessage({data: msg})
123+
}, 0)
124+
this.messageTimers.add(timer)
109125
})
110126
this.poll()
111127
break
@@ -123,8 +139,7 @@ export default class LongPoll {
123139
break
124140
case 0:
125141
case 500:
126-
this.onerror(500)
127-
this.closeAndRetry(1011, "internal server error", 500)
142+
this.closeAndRetry(500, 1011, "internal server error", false)
128143
break
129144
default: throw new Error(`unhandled poll status ${status}`)
130145
}
@@ -156,9 +171,8 @@ export default class LongPoll {
156171
const batch = messages.slice(offset, next)
157172
this.ajax("POST", {"Content-Type": "application/x-ndjson"}, batch.join("\n"), () => this.ontimeout(), resp => {
158173
if(!resp || resp.status !== 200){
159-
this.awaitingBatchAck = false
160-
this.onerror(resp && resp.status)
161-
this.closeAndRetry(1011, "internal server error", false)
174+
// this calls stop which already clears awaitingBatchAck
175+
this.closeAndRetry(resp && resp.status, 1011, "internal server error", false)
162176
} else if(next < messages.length){
163177
this.batchSend(messages, next)
164178
} else if(this.batchBuffer.length > 0){
@@ -171,13 +185,35 @@ export default class LongPoll {
171185
}
172186

173187
close(code, reason, wasClean){
174-
for(let req of this.reqs){ req.abort() }
175-
this.readyState = SOCKET_STATES.closed
176-
let opts = Object.assign({code: 1000, reason: undefined, wasClean: true}, {code, reason, wasClean})
188+
this.stop(SOCKET_STATES.closed)
189+
this.emitClose(code, reason, wasClean)
190+
}
191+
192+
/**
193+
* Cancels the pending requests, queued messages and outgoing batches.
194+
*/
195+
stop(readyState){
196+
this.readyState = readyState
197+
// the requests are no longer ours before we abort them, as aborting can invoke their
198+
// callbacks, synchronously for XHR and asynchronously for fetch
199+
const reqs = this.reqs
200+
this.reqs = new Set()
201+
for(let {req} of reqs){ req && req.abort() }
202+
for(let timer of this.messageTimers){ clearTimeout(timer) }
203+
this.messageTimers.clear()
177204
this.batchBuffer = []
178205
this.awaitingBatchAck = false
179206
clearTimeout(this.currentBatchTimer)
180207
this.currentBatchTimer = null
208+
this.currentBatch = null
209+
}
210+
211+
/**
212+
* Emits the close event. Like a WebSocket closed without a code, it reports 1005 (no status)
213+
* unless one is given, as 1000 would look like the server closing the connection normally.
214+
*/
215+
emitClose(code = 1005, reason, wasClean = true){
216+
let opts = {code, reason, wasClean}
181217
if(typeof(CloseEvent) !== "undefined"){
182218
this.onclose(new CloseEvent("close", opts))
183219
} else {
@@ -186,18 +222,19 @@ export default class LongPoll {
186222
}
187223

188224
ajax(method, headers, body, onCallerTimeout, callback){
189-
let req
225+
// the request is ours until it completes or we stop, and the callbacks of a request
226+
// that is not ours anymore are ignored. It is ours before it starts, as a request can
227+
// complete while it starts.
228+
const request = {req: null}
229+
this.reqs.add(request)
190230
let ontimeout = () => {
191-
this.reqs.delete(req)
192-
onCallerTimeout()
231+
if(this.reqs.delete(request)){ onCallerTimeout() }
193232
}
194233
if(this.token !== null){
195234
headers = Object.assign({}, headers, {"X-Phoenix-Longpoll-Token": this.token})
196235
}
197-
req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
198-
this.reqs.delete(req)
199-
if(this.isActive()){ callback(resp) }
236+
request.req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
237+
if(this.reqs.delete(request)){ callback(resp) }
200238
})
201-
this.reqs.add(req)
202239
}
203240
}

‎assets/js/phoenix/push.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export default class Push {
4040
payload: this.payload(),
4141
ref: this.ref,
4242
join_ref: this.channel.joinRef()
43-
})
43+
}, this.channel)
4444
}
4545

4646
/**

0 commit comments

Comments
 (0)