Skip to content

Commit ac12eec

Browse files
SteffenDEsatoren
andauthored
Fix concurrent socket teardown (#6602)
Closes #6600. Closes #6599. In #6110 I added a check to prevent teardown from continuing if there was a new connection attempt in between. While this solved the reported issue (#6103), it also means that in case someone calls connect or disconnect in between the original teardown call and the waitForBufferDone, we would not actually close the original connection, leaving dangling connections. The fix is to always use the original connection that was supposed to be closed in teardown and the subsequent checks and only set this.conn to null when it is still the same object. Co-authored-by: satoren <satoreyo@hotmail.com>
1 parent 0f6a26f commit ac12eec

File tree

2 files changed

+139
-15
lines changed

2 files changed

+139
-15
lines changed

assets/js/phoenix/socket.js

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -496,17 +496,16 @@ export default class Socket {
496496
if(!this.conn){
497497
return callback && callback()
498498
}
499-
let connectClock = this.connectClock
500499

501-
this.waitForBufferDone(() => {
502-
if(connectClock !== this.connectClock){ return }
503-
if(this.conn){
504-
if(code){ this.conn.close(code, reason || "") } else { this.conn.close() }
505-
}
500+
// If someone calls connect before we finish tearing down,
501+
// we create a new connection, but we still want to finish tearing down the old one.
502+
const connToClose = this.conn
506503

507-
this.waitForSocketClosed(() => {
508-
if(connectClock !== this.connectClock){ return }
509-
if(this.conn){
504+
this.waitForBufferDone(connToClose, () => {
505+
if(code){ connToClose.close(code, reason || "") } else { connToClose.close() }
506+
507+
this.waitForSocketClosed(connToClose, () => {
508+
if(this.conn === connToClose){
510509
this.conn.onopen = function (){ } // noop
511510
this.conn.onerror = function (){ } // noop
512511
this.conn.onmessage = function (){ } // noop
@@ -519,25 +518,25 @@ export default class Socket {
519518
})
520519
}
521520

522-
waitForBufferDone(callback, tries = 1){
523-
if(tries === 5 || !this.conn || !this.conn.bufferedAmount){
521+
waitForBufferDone(conn, callback, tries = 1){
522+
if(tries === 5 || !conn.bufferedAmount){
524523
callback()
525524
return
526525
}
527526

528527
setTimeout(() => {
529-
this.waitForBufferDone(callback, tries + 1)
528+
this.waitForBufferDone(conn, callback, tries + 1)
530529
}, 150 * tries)
531530
}
532531

533-
waitForSocketClosed(callback, tries = 1){
534-
if(tries === 5 || !this.conn || this.conn.readyState === SOCKET_STATES.closed){
532+
waitForSocketClosed(conn, callback, tries = 1){
533+
if(tries === 5 || conn.readyState === SOCKET_STATES.closed){
535534
callback()
536535
return
537536
}
538537

539538
setTimeout(() => {
540-
this.waitForSocketClosed(callback, tries + 1)
539+
this.waitForSocketClosed(conn, callback, tries + 1)
541540
}, 150 * tries)
542541
}
543542

assets/test/socket_test.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {jest} from "@jest/globals"
22
import {WebSocket, Server as WebSocketServer} from "mock-socket"
33
import {encode} from "./serializer"
44
import {Socket, LongPoll} from "../js/phoenix"
5+
import {SOCKET_STATES} from "../js/phoenix/constants"
56

67
let socket
78

@@ -285,6 +286,130 @@ describe("with transports", function (){
285286
socket.disconnect()
286287
}).not.toThrow()
287288
})
289+
290+
it("properly tears down old connection when immediately reconnecting", function (){
291+
const connections = []
292+
const mockWebSocket = function StubWebSocketNoAutoClose(_url){
293+
const conn = {
294+
readyState: SOCKET_STATES.open,
295+
get bufferedAmount(){ return 1 },
296+
binaryType: "arraybuffer",
297+
timeout: 20000,
298+
onopen: null,
299+
onerror: null,
300+
onmessage: null,
301+
onclose: null,
302+
close(_code, _reason){
303+
this.readyState = SOCKET_STATES.closing
304+
setTimeout(() => {
305+
this.readyState = SOCKET_STATES.closed
306+
}, 1000)
307+
},
308+
send(){},
309+
}
310+
connections.push(conn)
311+
return conn
312+
}
313+
314+
jest.useFakeTimers()
315+
316+
socket = new Socket("/socket", {
317+
heartbeatIntervalMs: 30000,
318+
heartbeatTimeoutMs: 30000,
319+
reconnectAfterMs: () => 10,
320+
transport: mockWebSocket
321+
})
322+
socket.connect()
323+
const originalConn = socket.conn
324+
325+
// Disconnect triggers teardown, which waits for bufferedAmount to be zero or 2250ms,
326+
// then awaits SOCKET_STATES.closed before calling the callback.
327+
const disconnected = jest.fn()
328+
socket.disconnect(disconnected)
329+
330+
// For now, the conn is still set.
331+
expect(socket.conn).toBeTruthy()
332+
333+
// Advance time by > 2250ms, which means we are waiting for socket to transition to closed
334+
jest.advanceTimersByTime(3000)
335+
336+
// Now we call connect, while the teardown is still running
337+
socket.connect()
338+
// By now, waitForSocketClosed should be done, but now there's a new conn!
339+
jest.advanceTimersByTime(3000)
340+
expect(socket.conn).not.toBe(originalConn)
341+
342+
const openConns = connections.filter(c => c.readyState === SOCKET_STATES.open)
343+
expect(openConns.length).toBe(1)
344+
345+
// Late teardown must not overwrite this.conn with null when it is already connB
346+
expect(socket.conn).not.toBeNull()
347+
348+
// the original disconnected should have been called
349+
expect(disconnected).toHaveBeenCalled()
350+
351+
jest.useRealTimers()
352+
})
353+
354+
it("properly tears down old connection when disconnecting twice", function (){
355+
const connections = []
356+
const mockWebSocket = function StubWebSocketNoAutoClose(_url){
357+
const conn = {
358+
readyState: SOCKET_STATES.open,
359+
get bufferedAmount(){ return 1 },
360+
binaryType: "arraybuffer",
361+
timeout: 20000,
362+
onopen: null,
363+
onerror: null,
364+
onmessage: null,
365+
onclose: null,
366+
close(_code, _reason){
367+
this.readyState = SOCKET_STATES.closing
368+
setTimeout(() => {
369+
this.readyState = SOCKET_STATES.closed
370+
}, 1000)
371+
},
372+
send(){},
373+
}
374+
connections.push(conn)
375+
return conn
376+
}
377+
378+
jest.useFakeTimers()
379+
380+
socket = new Socket("/socket", {
381+
heartbeatIntervalMs: 30000,
382+
heartbeatTimeoutMs: 30000,
383+
reconnectAfterMs: () => 10,
384+
transport: mockWebSocket
385+
})
386+
socket.connect()
387+
388+
const disconnected = jest.fn()
389+
socket.disconnect(disconnected)
390+
391+
// For now, the conn is still set.
392+
expect(socket.conn).toBeTruthy()
393+
394+
// Advance time by > 2250ms, which means we are waiting for socket to transition to closed
395+
jest.advanceTimersByTime(3000)
396+
397+
// Now we call disconnect again, while the teardown is still running
398+
const disconnected2 = jest.fn()
399+
socket.disconnect(disconnected2)
400+
401+
jest.advanceTimersByTime(10000)
402+
403+
const openConns = connections.filter(c => c.readyState === SOCKET_STATES.open)
404+
expect(openConns.length).toBe(0)
405+
expect(socket.conn).toBeNull()
406+
407+
// both disconnected functions should have been called
408+
expect(disconnected).toHaveBeenCalled()
409+
expect(disconnected2).toHaveBeenCalled()
410+
411+
jest.useRealTimers()
412+
})
288413
})
289414

290415
describe("connectionState", function (){

0 commit comments

Comments
 (0)