Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,6 @@ export default class CallsClient extends EventEmitter {
case WebSocketErrorType.Native:
break;
case WebSocketErrorType.ReconnectTimeout:
this.ws = null;
this.disconnect(err);
break;
case WebSocketErrorType.Join:
Expand Down Expand Up @@ -523,6 +522,9 @@ export default class CallsClient extends EventEmitter {
});

ws.on('join', async () => {
if (this.closed) {
return;
}
logDebug('join ack received, initializing connection');

const peer = new RTCPeer({
Expand Down Expand Up @@ -866,8 +868,7 @@ export default class CallsClient extends EventEmitter {
this.cleanup();

if (this.ws) {
this.ws.send('leave');
this.ws.close();
this.ws.sendLeaveAndClose();
this.ws = null;
}

Expand Down
93 changes: 93 additions & 0 deletions webapp/src/websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,99 @@ describe('WebSocketClient', () => {
});
});

describe('sendLeaveAndClose', () => {
it('should send leave and close when WS is OPEN', () => {
mockWebSocket.readyState = WebSocket.OPEN;

// Establish connection state
mockWebSocket.onmessage!(new MessageEvent('message', {
data: JSON.stringify({event: 'hello', data: {connection_id: 'conn-1'}, seq: 1}),
}));

const sendSpy = jest.spyOn(mockWebSocket, 'send');
const closeSpy = jest.spyOn(mockWebSocket, 'close');

client.sendLeaveAndClose();

expect(sendSpy).toHaveBeenCalledWith(
expect.stringContaining('"action":"custom_com.mattermost.calls_leave"'),
);
expect(closeSpy).toHaveBeenCalled();
expect((client as any).closed).toBe(true);
});

it('should queue leave for when WS opens if CONNECTING, then close', () => {
expect(mockWebSocket.readyState).toBe(WebSocket.CONNECTING);

const sendSpy = jest.spyOn(mockWebSocket, 'send');

client.sendLeaveAndClose();

// closed flag set immediately to prevent further reconnects
expect((client as any).closed).toBe(true);

// leave not yet sent (WS still connecting)
expect(sendSpy).not.toHaveBeenCalled();

// Simulate WS opening
mockWebSocket.readyState = WebSocket.OPEN;

// Simulate hello message to trigger the 'open' event
mockWebSocket.onmessage!(new MessageEvent('message', {
data: JSON.stringify({event: 'hello', data: {connection_id: 'conn-2'}, seq: 1}),
}));

// leave should now have been sent
expect(sendSpy).toHaveBeenCalledWith(
expect.stringContaining('"action":"custom_com.mattermost.calls_leave"'),
);

// client should be fully closed
expect((client as any).ws).toBeNull();
});

it('should not start a zombie ping interval when closing from CONNECTING state', () => {
expect(mockWebSocket.readyState).toBe(WebSocket.CONNECTING);

client.sendLeaveAndClose();

// Simulate hello arriving (open event fires, once handler runs, close() is called)
mockWebSocket.readyState = WebSocket.OPEN;
mockWebSocket.onmessage!(new MessageEvent('message', {
data: JSON.stringify({event: 'hello', data: {connection_id: 'conn-3'}, seq: 1}),
}));

// After close(), pingInterval must not be running
expect((client as any).pingInterval).toBeNull();
});

it('should call close without sending leave when WS is already CLOSED', () => {
mockWebSocket.readyState = WebSocket.CLOSED;

// Prevent the close() → ws.close() → onclose loop from causing issues
mockWebSocket.onclose = null;

const sendSpy = jest.spyOn(mockWebSocket, 'send');

client.sendLeaveAndClose();

expect(sendSpy).not.toHaveBeenCalled();
expect((client as any).closed).toBe(true);
});

it('should call close without sending leave when WS is CLOSING', () => {
mockWebSocket.readyState = WebSocket.CLOSING;
mockWebSocket.onclose = null;

const sendSpy = jest.spyOn(mockWebSocket, 'send');

client.sendLeaveAndClose();

expect(sendSpy).not.toHaveBeenCalled();
expect((client as any).closed).toBe(true);
});
});

describe('getOriginalConnID', () => {
it('should return the original connection ID', () => {
// Set up connection
Expand Down
30 changes: 30 additions & 0 deletions webapp/src/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,32 @@ export class WebSocketClient extends EventEmitter {
}
}

// sendLeaveAndClose sends a 'leave' message and closes the connection.
// If the WS is mid-reconnect (CONNECTING), it marks the client as closed
// (preventing further reconnects) and queues the leave for when the
// connection opens, so the server receives it instead of waiting for the
// pong timeout (~87s) to reap the session.
sendLeaveAndClose() {
if (!this.ws || this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED) {
this.close();
return;
}

if (this.ws.readyState === WebSocket.OPEN) {
this.send('leave');
this.close();
return;
}

// WS is CONNECTING (mid-reconnect): mark closed to stop further
// reconnects, then send leave as soon as the connection opens.
this.closed = true;
this.once('open', () => {
this.send('leave');
this.close();
});
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
close() {
this.closed = true;
this.stopPingInterval();
Expand Down Expand Up @@ -231,6 +257,10 @@ export class WebSocketClient extends EventEmitter {
}

private startPingInterval() {
if (this.closed) {
return;
}

if (this.pingInterval) {
this.stopPingInterval();
}
Expand Down
Loading