Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
0163897
Revert "fix: re-subscribe room streams after re-authentication on rec…
diegolmello Jul 26, 2026
6622eed
Revert "fix: re-subscribe room streams on DDP reconnect (#7426)"
diegolmello Jul 26, 2026
38446b4
Revert "fix: re-subscribe rooms stream after forced socket reopen (#7…
diegolmello Jul 26, 2026
d848d35
Revert "fix: keep live socket on deeplink login (prevent orphan WebSo…
diegolmello Jul 26, 2026
87437c9
Revert "fix(voip,connect): probe Meteor Connect on foreground and loc…
diegolmello Jul 26, 2026
5b24172
fix: rewire pendingHangups drain without awaitDdpLoggedIn
diegolmello Jul 26, 2026
c840710
fix: remove 7380 orphan-socket gate tests from deepLinking
diegolmello Jul 26, 2026
8db6b9a
test: cover handleClickCallPush new-server path and pendingHangups lo…
diegolmello Jul 26, 2026
94dceb6
refactor: extract isLoginReady helper in connect.ts
diegolmello Jul 26, 2026
f1e310a
fix: restore orphan-socket guard in SDK patch
diegolmello Jul 27, 2026
2281397
test: restore updateMessage concurrency and subscribe coverage
diegolmello Jul 27, 2026
b84d6e0
refactor: address review nits in connect and MediaSessionInstance
diegolmello Jul 27, 2026
9a638ea
feat(sdk): add reopenNow, liveness probe, and disconnected emit to DD…
diegolmello Jul 28, 2026
383c47a
fix(voip): gate native call accept on socket readiness
diegolmello Jul 28, 2026
bb8220f
fix(sdk): serialize forced reopen against concurrent open, harden probe
diegolmello Jul 28, 2026
0282782
refactor(voip): share socket health classification
diegolmello Jul 28, 2026
efda410
fix(connect): reconnect immediately when foregrounding a stale socket
diegolmello Jul 28, 2026
83c9af6
fix(voip): stop aborted accept gates from terminating the call
diegolmello Jul 28, 2026
79d151d
test(voip): align call-lifecycle integration tests with gated accept
diegolmello Jul 28, 2026
44d451b
fix(voip): await processSignal before native-accept replay
diegolmello Jul 28, 2026
281731c
merge branch 'purring-seahorse' into dry-badger
diegolmello Jul 28, 2026
409c796
test(voip): tidy timer cleanup and duplicate mock in accept tests
diegolmello Jul 28, 2026
4ba1445
fix(voip): terminate native call when readiness sequence throws
diegolmello Jul 28, 2026
f1e7fe1
fix(sdk): require both media subs and reuse sub id when resubscribing
diegolmello Jul 28, 2026
2ee0ef4
test(voip): integration coverage for accept gate ladder and socket probe
diegolmello Jul 28, 2026
10849b1
chore(voip): temporary reconnect-latency trace instrumentation, dev-only
diegolmello Jul 29, 2026
def7a43
fix(voip): classify closed socket as reopen regardless of ping age
diegolmello Jul 29, 2026
0879401
fix(sdk): lower hardcoded ddp ping interval to 10s
diegolmello Jul 29, 2026
3adab1d
fix(sdk): apply sdk patch via pnpm only
diegolmello Jul 29, 2026
0a7d944
fix(sdk): revert to patch-package, keep 10s ping interval
diegolmello Jul 29, 2026
0571fe4
fix(voip): guard AppState access in reconnect trace
diegolmello Jul 29, 2026
fde4947
test(voip): match integration ping interval to 10s sdk patch
diegolmello Jul 29, 2026
111d20b
fix(voip): always confirm socket health with a round trip
diegolmello Jul 30, 2026
0c8157c
remove reconnectMark
diegolmello Jul 30, 2026
6ce7bd9
refactor(connection): extract socket health module with unit suite
diegolmello Jul 30, 2026
352ccf0
test(connection): add socket health integration suite
diegolmello Jul 30, 2026
5a58112
refactor(connection): move foreground socket recovery onto socket hea…
diegolmello Jul 30, 2026
841614f
refactor(voip): gate call accept on socket health module
diegolmello Jul 30, 2026
e1d91ab
test(connection): type the socket health integration scaffolding
diegolmello Jul 30, 2026
7f40909
refactor(connection): consolidate onAbort into shared helper
diegolmello Jul 30, 2026
0260f74
refactor(connection): fold executeRecovery into shareRecovery
diegolmello Jul 30, 2026
f07798a
Revert "fix: re-subscribe room streams after re-authentication on rec…
diegolmello Jul 26, 2026
5efd229
Revert "fix: re-subscribe room streams on DDP reconnect (#7426)"
diegolmello Jul 26, 2026
fa3278a
Revert "fix: re-subscribe rooms stream after forced socket reopen (#7…
diegolmello Jul 26, 2026
635b153
Revert "fix: keep live socket on deeplink login (prevent orphan WebSo…
diegolmello Jul 26, 2026
187acf2
Revert "fix(voip,connect): probe Meteor Connect on foreground and loc…
diegolmello Jul 26, 2026
095b455
fix: rewire pendingHangups drain without awaitDdpLoggedIn
diegolmello Jul 26, 2026
b5d6064
fix: remove 7380 orphan-socket gate tests from deepLinking
diegolmello Jul 26, 2026
1b4c174
test: cover handleClickCallPush new-server path and pendingHangups lo…
diegolmello Jul 26, 2026
8b45556
refactor: extract isLoginReady helper in connect.ts
diegolmello Jul 26, 2026
83e878f
fix: restore orphan-socket guard in SDK patch
diegolmello Jul 27, 2026
13e1afa
test: restore updateMessage concurrency and subscribe coverage
diegolmello Jul 27, 2026
36fb8aa
refactor: address review nits in connect and MediaSessionInstance
diegolmello Jul 27, 2026
8af1086
fix(voip): await processSignal before native-accept replay
diegolmello Jul 28, 2026
5918b64
merge branch 'purring-seahorse' into dry-badger
diegolmello Jul 31, 2026
a0af7a8
merge branch 'develop' into dry-badger
diegolmello Jul 31, 2026
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
11 changes: 6 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,12 @@ A **Message Action** is the active mode on a Message in the Room view. The three

## Server & Connection

| Term | Definition | Aliases to avoid |
| ------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| **Server** | A Rocket.Chat server instance the app connects to, with version, settings, and enterprise modules | Workspace (used by web but not consistently in mobile), instance |
| **Server History** | List of previously connected Servers for quick reconnection | Recent servers |
| **Meteor Connect** | The WebSocket connection to the Server's DDP (Distributed Data Protocol) endpoint | Socket, connection |
| Term | Definition | Aliases to avoid |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| **Server** | A Rocket.Chat server instance the app connects to, with version, settings, and enterprise modules | Workspace (used by web but not consistently in mobile), instance |
| **Server History** | List of previously connected Servers for quick reconnection | Recent servers |
| **Meteor Connect** | The WebSocket connection to the Server's DDP (Distributed Data Protocol) endpoint | Socket, connection |
| **Socket Health** | Whether the Meteor Connect socket is genuinely alive — confirmed by a round trip when in doubt, reopened when known dead | Staleness (stale/gray/fresh), socket probe |

## Navigation & Layout

Expand Down
17 changes: 17 additions & 0 deletions app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Navigation from '../../lib/navigation/appNavigation';
import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore';
import { useCallStore } from '../../lib/services/voip/useCallStore';
import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance';
import { acceptNativeCallWithReadiness } from '../../lib/services/voip/acceptNativeCall';
import { mockedStore } from '../../reducers/mockedStore';
import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions';
import type { InsideStackParamList } from '../../stacks/types';
Expand Down Expand Up @@ -157,6 +158,15 @@ jest.mock('../../lib/services/voip/getPeerAutocompleteOptions', () => ({
jest.mock('../../lib/services/voip/navigateToCallRoom', () => ({
navigateToCallRoom: jest.fn().mockResolvedValue(undefined)
}));
// Gate boundary mock: the DDP listener now routes accepted signals through
// acceptNativeCallWithReadiness rather than calling answerCall directly. The
// gate's own unit tests cover readiness orchestration; this file asserts the
// lifecycle/navigation contract, so the gate is short-circuited to answerCall.
jest.mock('../../lib/services/voip/acceptNativeCall', () => ({
acceptNativeCallWithReadiness: jest.fn(async (_callId: string, mediaSession: any) => {
await mediaSession.answerCall(_callId);
})
}));
// playCallEndedSound → expo-av → Audio.Sound constructor not present in this test boundary.
jest.mock('../../lib/services/voip/playCallEndedSound', () => ({
playCallEndedSound: jest.fn()
Expand Down Expand Up @@ -541,6 +551,9 @@ describe('VoIP call lifecycle (integration)', () => {
});

// ── MediaSessionInstance contract: answerCall ────────────────────────────
// Incoming accepted signals now flow through acceptNativeCallWithReadiness
// (readiness gate). The gate module is mocked here to delegate straight to
// answerCall; gate readiness is covered by acceptNativeCall.test.ts.

describe('MediaSessionInstance contract: answerCall', () => {
it('A1: DDP accepted signal with native pre-accept → answerCall navigates to CallView', async () => {
Expand All @@ -566,6 +579,9 @@ describe('VoIP call lifecycle (integration)', () => {
expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('incoming-1');
expect(Navigation.navigate).toHaveBeenCalledWith('CallView');
expect(useCallStore.getState().call?.callId).toBe('incoming-1');
// The DDP listener now funnels accepted signals through the readiness
// gate instead of invoking answerCall directly.
expect(acceptNativeCallWithReadiness).toHaveBeenCalledWith('incoming-1', mediaSessionInstance);
});

it('A2: accepted signal but call not found → RNCallKeep.endCall, no navigate', async () => {
Expand All @@ -590,6 +606,7 @@ describe('VoIP call lifecycle (integration)', () => {
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
expect(Navigation.navigate).not.toHaveBeenCalled();
expect(useCallStore.getState().call).toBeNull();
expect(acceptNativeCallWithReadiness).toHaveBeenCalledWith('missing-1', mediaSessionInstance);
// Tighten: confirm the known-noise allowlist entry was actually triggered.
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.objectContaining({ message: '[VoIP] Call not found after accept: missing-1' })
Expand Down
1 change: 1 addition & 0 deletions app/lib/methods/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './isReadOnly';
export * from './media';
export * from './normalizeDeepLinkingServerHost';
export * from './normalizeStatusExpiresAt';
export * from './onAbort';
export * from './room';
export * from './server';
export * from './isSsl';
Expand Down
10 changes: 10 additions & 0 deletions app/lib/methods/helpers/onAbort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function onAbort(signal: AbortSignal | undefined, callback: () => void): void {
if (!signal) {
return;
}
if (signal.aborted) {
callback();
return;
}
signal.addEventListener('abort', callback, { once: true });
}
259 changes: 259 additions & 0 deletions app/lib/services/__tests__/socketHealth.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import sdk from '../sdk';
import { recoverSocket } from '../socketHealth';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DDPDriver } = require('@rocket.chat/sdk/lib/drivers/ddp') as {
DDPDriver: new (options: { host: string; logger: unknown }) => PatchedDriver;
};

interface MockConnection {
send: jest.Mock;
close: jest.Mock;
readyState: number;
onopen: () => void;
onmessage: (event: { data: string }) => void;
onerror: () => void;
onclose: () => void;
}

interface WireFrame {
msg: string;
id?: string;
name?: string;
params?: string[];
}

interface PatchedDriver {
userId: string;
pingInterval: number;
reopenNow(): Promise<void>;
waitForNotifyUserMediaSubs(timeoutMs?: number): Promise<boolean>;
ddp: {
lastPing: number;
pingTimeout?: ReturnType<typeof setTimeout>;
openTimeout?: ReturnType<typeof setTimeout>;
open(): Promise<void>;
send(message: Record<string, unknown>): Promise<unknown>;
subscriptions: Record<string, { id: string; name: string; params: string[]; unsubscribe: jest.Mock }>;
};
}

const mockConnections: MockConnection[] = [];

jest.mock('universal-websocket-client', () =>
jest.fn().mockImplementation(() => {
const connection = {
send: jest.fn((data: string) => {
const message = JSON.parse(data) as { msg: string; id?: string };
if (message.msg === 'connect') {
setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) }));
} else if (message.msg === 'ping') {
setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) }));
} else if (message.msg === 'sub') {
setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) }));
}
}),
close: jest.fn(),
readyState: 1,
onopen: jest.fn(),
onmessage: jest.fn(),
onerror: jest.fn(),
onclose: jest.fn()
};
mockConnections.push(connection);
return connection;
})
);

jest.mock('../sdk', () => ({
__esModule: true,
default: { current: undefined }
}));

const USER_ID = 'user-id';
const PING_INTERVAL = 10000;

const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() };

/** Real patched DDPDriver over a mocked WebSocket, connected and logged in. */
async function buildConnectedDriver() {
const driver = new DDPDriver({ host: 'localhost:3000', logger });
driver.userId = USER_ID;
const openPromise = driver.ddp.open();
mockConnections[0].onopen();
await jest.advanceTimersByTimeAsync(0);
await openPromise;
return driver;
}

function addMediaSubs(driver: PatchedDriver) {
['media-signal', 'media-calls'].forEach((name, index) => {
const id = `sub-${index}`;
driver.ddp.subscriptions[id] = {
id,
name: 'stream-notify-user',
params: [`${USER_ID}/${name}`],
unsubscribe: jest.fn()
};
});
}

function backdateLastPing(driver: PatchedDriver, ageMs: number) {
driver.ddp.lastPing = Date.now() - ageMs;
}

/** Frames of a given `msg` sent over the wire on one connection. */
function framesOn(connection: MockConnection, msg: string) {
return connection.send.mock.calls
.map(([data]: [string]) => JSON.parse(data) as WireFrame)
.filter(message => message.msg === msg);
}

describe('recoverSocket against the real patched socket', () => {
let driver: PatchedDriver;

beforeEach(async () => {
jest.clearAllMocks();
jest.useFakeTimers();
mockConnections.length = 0;
driver = await buildConnectedDriver();
(sdk as unknown as { current: { ddp: PatchedDriver } }).current = { ddp: driver };
});

afterEach(() => {
if (driver.ddp.pingTimeout) clearTimeout(driver.ddp.pingTimeout);
if (driver.ddp.openTimeout) clearTimeout(driver.ddp.openTimeout);
jest.useRealTimers();
});

it('exposes the ping interval the health classification depends on', () => {
expect(driver.pingInterval).toBe(PING_INTERVAL);
});

it('keeps a doubtful socket when the round trip gets a pong', async () => {
backdateLastPing(driver, PING_INTERVAL + 5000);

const recovery = recoverSocket();
await jest.advanceTimersByTimeAsync(0);

await expect(recovery).resolves.toBe('confirmed-alive');
// The round trip pinged the existing socket and the pong kept it alive.
expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0);
expect(mockConnections).toHaveLength(1);
});

it('reopens a doubtful socket when the round trip gets no pong', async () => {
backdateLastPing(driver, PING_INTERVAL + 5000);
// A zombie socket: still `readyState: 1`, but the server never answers.
mockConnections[0].send.mockImplementation(() => undefined);

const recovery = recoverSocket();
await jest.advanceTimersByTimeAsync(2000);

// The round trip was actually attempted on the dead socket before reopening.
expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0);
expect(mockConnections).toHaveLength(2);
mockConnections[1].onopen();
await jest.advanceTimersByTimeAsync(0);

await expect(recovery).resolves.toBe('reopened');
});

it('reopens a frozen socket whose last ping is still young', async () => {
// A young `lastPing` proves nothing: `onOpen` refreshes it before the handshake
// reply lands, so the timestamp can sit on an unusable session.
mockConnections[0].send.mockImplementation(() => undefined);

const recovery = recoverSocket();
await jest.advanceTimersByTimeAsync(2000);

// The young ping bought a round trip, and the silent socket failed it.
expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0);
expect(mockConnections).toHaveLength(2);
mockConnections[1].onopen();
await jest.advanceTimersByTimeAsync(0);

await expect(recovery).resolves.toBe('reopened');
});

it('reopens a known-dead socket without a round trip', async () => {
backdateLastPing(driver, PING_INTERVAL * 2 + 1000);

const recovery = recoverSocket();
await jest.advanceTimersByTimeAsync(0);

expect(mockConnections).toHaveLength(2);
// No raw round-trip ping was sent on the dead socket.
expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0);

mockConnections[1].onopen();
await jest.advanceTimersByTimeAsync(0);

await expect(recovery).resolves.toBe('reopened');
});

it('shares one reopen with a concurrent direct reopenNow', async () => {
backdateLastPing(driver, PING_INTERVAL * 3);

// The foreground path reopens the dead socket while recovery does the same.
const directReopen = driver.reopenNow();
const recovery = recoverSocket();

await jest.advanceTimersByTimeAsync(0);
expect(mockConnections).toHaveLength(2);
mockConnections[1].onopen();
await jest.advanceTimersByTimeAsync(0);

await directReopen;
await expect(recovery).resolves.toBe('reopened');
expect(mockConnections).toHaveLength(2);

// No queued third open fires later — the reopen really was shared.
await jest.advanceTimersByTimeAsync(60000);
expect(mockConnections).toHaveLength(2);
});

it('rejects an in-flight DDP method call when recovery reopens the socket', async () => {
let rejected = false;
const inFlight = driver.ddp.send({ msg: 'method', method: 'getRoomByTypeAndName', params: [] }).catch(() => {
rejected = true;
});
await jest.advanceTimersByTimeAsync(0);
expect(rejected).toBe(false);

// The socket dies silently after the call went out.
backdateLastPing(driver, PING_INTERVAL * 3);

const recovery = recoverSocket();
await jest.advanceTimersByTimeAsync(0);
await inFlight;
expect(rejected).toBe(true);

mockConnections[1].onopen();
await jest.advanceTimersByTimeAsync(0);

await expect(recovery).resolves.toBe('reopened');
});

it('re-sends the media subscriptions on the new socket reusing their ids', async () => {
backdateLastPing(driver, PING_INTERVAL * 3);
addMediaSubs(driver);

const recovery = recoverSocket();
await jest.advanceTimersByTimeAsync(0);
mockConnections[1].onopen();
await jest.advanceTimersByTimeAsync(0);
await expect(recovery).resolves.toBe('reopened');

const resubscribed = driver.waitForNotifyUserMediaSubs();
await jest.advanceTimersByTimeAsync(200);
await expect(resubscribed).resolves.toBe(true);

// Both media subs went out on the new socket reusing their ids.
expect(framesOn(mockConnections[0], 'sub')).toHaveLength(0);
expect(framesOn(mockConnections[1], 'sub')).toEqual([
expect.objectContaining({ id: 'sub-0', name: 'stream-notify-user', params: [`${USER_ID}/media-signal`] }),
expect.objectContaining({ id: 'sub-1', name: 'stream-notify-user', params: [`${USER_ID}/media-calls`] })
]);
});
});
Loading
Loading