Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
79 changes: 79 additions & 0 deletions app/lib/services/ddpSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,85 @@ describe('Socket.reopenNow', () => {
await secondPromise;
});

it('re-arms the reconnect ladder when a forced reopen never opens', async () => {
jest.useFakeTimers();
const socket = trackSocket(
new Socket({
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() },
timeout: 10000
})
);

const promise = socket.reopenNow();
expect(mockConnections).toHaveLength(1);

// The forced reopen never opens — let its own deadline expire.
await jest.advanceTimersByTimeAsync(10000);
await promise;

// `reopenNow` cancelled the ladder to attempt an immediate reconnect, and that
// attempt failed. The ladder has to be back, or nothing retries and the session
// waits for the next foreground: one `reopen` interval later it tries again.
await jest.advanceTimersByTimeAsync(10000);

expect(mockConnections).toHaveLength(2);
});

it('re-arms the ladder when a ladder tick was already scheduled before the forced reopen', async () => {
jest.useFakeTimers();
const socket = trackSocket(
new Socket({
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() },
timeout: 10000
})
);

// The real precondition: onClose already armed the ladder before anything forced a
// reopen. reopenNow cancels that tick, so it must not leave the field pointing at it.
socket.reopen();
expect(socket.openTimeout).toBeTruthy();

const promise = socket.reopenNow();
await jest.advanceTimersByTimeAsync(10000);
await promise;

await jest.advanceTimersByTimeAsync(socket.config.reopen);

expect(mockConnections).toHaveLength(2);
});

it('re-arms the ladder as soon as the attempt errors, without waiting for the deadline', async () => {
jest.useFakeTimers();
const socket = trackSocket(
new Socket({
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() },
timeout: 10000
})
);

const promise = socket.reopenNow();
// How a WebSocket reports a failed connect: onerror is createConnection's reject.
mockConnections[0].onerror(new Error('offline'));
await promise;

expect(socket.reopenPromise).toBeUndefined();

await jest.advanceTimersByTimeAsync(10000);

expect(mockConnections).toHaveLength(2);
});

it('schedules no ladder tick when the forced reopen opens', async () => {
const { socket } = buildSocket();

const promise = socket.reopenNow();
mockConnections[0].onopen();
await promise;

// A reopen that produced a live socket must not leave a redundant tick behind.
expect(socket.openTimeout).toBeUndefined();
});

it('forces a reconnect on an already healthy socket', async () => {
const { socket } = buildSocket();
const initialConnection = socket.connection;
Expand Down
38 changes: 29 additions & 9 deletions patches/@rocket.chat+sdk+1.3.3-mobile.patch
Original file line number Diff line number Diff line change
Expand Up @@ -104,42 +104,62 @@ index 19d31ae..068b61e 100644
this.emit('close', e)
try {
if (e?.code !== userDisconnectCloseCode) {
@@ -201,6 +243,85 @@ export class Socket extends EventEmitter {
@@ -201,6 +243,105 @@ export class Socket extends EventEmitter {
}, this.config.reopen);
}

+ /**
+ * Force an immediate reconnect. Shared across concurrent callers so only one
+ * new WebSocket is created. Emits 'disconnected' to unblock in-flight sends,
+ * then creates the connection directly so a concurrent open() cannot tear it
+ * down. Unhandled creation errors are swallowed because cleanup already runs
+ * via the open/timeout paths.
+ * down. A reopen that does not produce an open socket re-arms the retry ladder
+ * it cancelled, so the caller never has to schedule its own retry.
+ */
+ reopenNow = (): Promise<void> => {
+ if (this.reopenPromise) {
+ return this.reopenPromise
+ }
+
+ this.reopenPromise = new Promise<void>(resolve => {
+ this.openTimeout && clearTimeout(this.openTimeout as any)
+ if (this.openTimeout) {
+ clearTimeout(this.openTimeout as any)
+ // Drop the id as well as the timer. `reopen()` reads a set `openTimeout` as "a tick
+ // is already scheduled" and returns early, so a cleared-but-still-set id disables
+ // the ladder for the rest of the session -- including the re-arm below and the one
+ // `onClose` attempts for every later close.
+ delete this.openTimeout
+ }
+ this.lastPing = 0
+ this.emit('disconnected')
+
+ let settled = false
+ const cleanup = () => {
+ let timeout: NodeJS.Timer | number | undefined
+
+ const cleanup = (opened: boolean) => {
+ if (settled) return
+ settled = true
+ this.off('open', cleanup)
+ this.off('open', onOpen)
+ if (timeout) clearTimeout(timeout as any)
+ // Drop the shared promise before re-arming, so a ladder tick calling open()
+ // cannot short-circuit onto a reopen that has already settled.
+ delete this.reopenPromise
+ // Hand control back to the retry ladder cancelled above. Cancelling it buys an
+ // immediate attempt; when that attempt fails — device offline, server
+ // unreachable — nothing is left scheduled and the session stays disconnected
+ // until something else forces a reconnect.
+ if (!opened && !this.connected) {
+ this.reopen()
+ }
+ resolve()
+ }
+
+ this.once('open', cleanup)
+ const onOpen = () => cleanup(true)
+
+ this.once('open', onOpen)
+
+ this.createConnection().catch(() => {})
+ this.createConnection().catch(() => cleanup(false))
+
+ const timeout = setTimeout(() => cleanup(), 10000)
+ timeout = setTimeout(() => cleanup(false), 10000)
+ })
+
+ return this.reopenPromise
Expand Down
Loading