Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e148cea
fix: lib-led DDP stream restoration on Zombie Connection
diegolmello Jul 24, 2026
849e846
chore: prettier-format CONTEXT.md glossary table
diegolmello Jul 24, 2026
6d5c208
fix: derive message sync cursor from server timestamps to survive cli…
diegolmello Jul 23, 2026
4fdeba9
fix: advance sync cursor once after full pagination chain succeeds
diegolmello Jul 23, 2026
760962f
test: cover offline coverage-gap in message sync cursor
diegolmello Jul 23, 2026
1a8e3cf
test: add LokiJS in-memory WatermelonDB harness for sync-cursor coverage
diegolmello Jul 23, 2026
3b7a236
test: cover advanceSyncCursor incl. re-read race guard on LokiJS harness
diegolmello Jul 23, 2026
2a23e40
test: integration-cover loadMissedMessages sync + pagination on LokiJS
diegolmello Jul 23, 2026
1e93fdf
test: integration-cover loadMessagesForRoom cursor gate on LokiJS
diegolmello Jul 23, 2026
4f84888
test: close LokiJS driver in teardown so integration suites exit cleanly
diegolmello Jul 23, 2026
449a1bb
test: clear WatermelonDB writer-queue diagnostic timer in contended test
diegolmello Jul 23, 2026
8f7ca0e
test: drop unnecessary jest.mock-before-import ceremony in loki suites
diegolmello Jul 23, 2026
2a0e507
test: prove RoomSubscription.updateMessage writer-lock fix on a real DB
diegolmello Jul 23, 2026
a7cec53
test: integration-cover reconnect/offline sync-cursor cycle on LokiJS
diegolmello Jul 23, 2026
e386cc1
test: retire mock-based sync-cursor tests superseded by LokiJS real-D…
diegolmello Jul 24, 2026
94d3945
fix: sync cursor resolves for rooms never opened on this device
diegolmello Jul 25, 2026
42d1672
chore: make the SDK patch render as text in diffs
diegolmello Jul 25, 2026
c55447d
fix: cap the sync page walk and the RoomView init retry storm
diegolmello Jul 25, 2026
b55738c
fix: carry the DELETED stop-point out of the bounded sync walk
diegolmello Jul 25, 2026
2b11182
fix: log the first failed room init once per broken open
diegolmello Jul 25, 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
12 changes: 7 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,13 @@ 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 |
| **Zombie Connection** | A live socket (`connected`) with no authenticated user and zero live subscriptions | Stranded session, dead connection |
| **Resubscribe** | The SDK re-sending its preserved `subscriptions` registry on every DDP login via `subscribeAll()`, reusing sub ids | Re-bind, registry replay |

## Navigation & Layout

Expand Down
27 changes: 16 additions & 11 deletions app/lib/methods/subscriptions/room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,42 +193,47 @@ describe('RoomSubscription', () => {
expect(mockSubscribeRoom).not.toHaveBeenCalled();
});

it('tears down stale subscriptions on reconnect and tracks fresh ones for later cleanup', async () => {
it('re-subscribes on reconnect without tearing down the stale subscriptions', async () => {
const staleSub = { unsubscribe: jest.fn(() => Promise.resolve()) };
const freshSub = { unsubscribe: jest.fn(() => Promise.resolve()) };
mockSubscribeRoom.mockResolvedValueOnce([staleSub]).mockResolvedValueOnce([freshSub]);

await sub.subscribe();
await sub.handleLogin();

// A fresh DDP session already killed the server-side subs, so handleLogin must not churn them.
expect(staleSub.unsubscribe).not.toHaveBeenCalled();

await sub.unsubscribe();

expect(staleSub.unsubscribe).toHaveBeenCalledTimes(1);
expect(freshSub.unsubscribe).toHaveBeenCalledTimes(1);
});

it('does not accumulate subscriptions across repeated handleLogin calls (simulates sequential reopen)', async () => {
const first = { unsubscribe: jest.fn(() => Promise.resolve()) };
const second = { unsubscribe: jest.fn(() => Promise.resolve()) };
mockSubscribeRoom.mockResolvedValueOnce([first]).mockResolvedValueOnce([second]);
const third = { unsubscribe: jest.fn(() => Promise.resolve()) };
mockSubscribeRoom.mockResolvedValueOnce([first]).mockResolvedValueOnce([second]).mockResolvedValueOnce([third]);

await sub.subscribe();
expect(mockSubscribeRoom).toHaveBeenCalledTimes(1);

// First reopen → tears down [first], creates [second]
// First reopen → tracks [second]; [first] is left alone (SDK owns the replay/dedup).
await sub.handleLogin();
expect(mockSubscribeRoom).toHaveBeenCalledTimes(2);
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
expect(second.unsubscribe).not.toHaveBeenCalled();
expect(first.unsubscribe).not.toHaveBeenCalled();

// Second reopen → tears down [second], creates []
// Second reopen → tracks [third]; [second] left alone.
await sub.handleLogin();
expect(mockSubscribeRoom).toHaveBeenCalledTimes(3);
expect(second.unsubscribe).toHaveBeenCalledTimes(1);
expect(second.unsubscribe).not.toHaveBeenCalled();

// Final cleanup → empty batch, no more unsubscribes
// The app-side promise list holds one batch (replaced, not grown): final cleanup tears down
// only the current [third]. SDK-side dedup of the replays is proven in ddpSocket.test.ts.
await sub.unsubscribe();
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
expect(second.unsubscribe).toHaveBeenCalledTimes(1);
expect(first.unsubscribe).not.toHaveBeenCalled();
expect(second.unsubscribe).not.toHaveBeenCalled();
expect(third.unsubscribe).toHaveBeenCalledTimes(1);
});

it('does not call onStreamData inside handleLogin (listeners persist across reopen)', async () => {
Expand Down
6 changes: 2 additions & 4 deletions app/lib/methods/subscriptions/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,8 @@ export default class RoomSubscription {
return;
}
try {
if (this.promises) {
const oldSubs = await this.promises;
oldSubs?.forEach(sub => sub?.unsubscribe?.().catch(() => {}));
}
// A fresh DDP session means the prior server-side subs are already dead, so tearing them
// down here is pointless churn; the SDK replays and dedups the re-subscribe on login.
this.promises = sdk.subscribeRoom(this.rid);
await this.promises;
if (!this.isAlive) {
Expand Down
43 changes: 43 additions & 0 deletions app/lib/services/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,4 +602,47 @@ describe('connect — stream-notify-logged updateAvatar', () => {
});
});

describe('connect — connected-guard (Zombie Connection resume)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOnStreamDataStops.length = 0;
});

type ResumeAction = { credentials?: { resume?: string } };
const resumeActions = (): ResumeAction[] =>
mockStoreDispatch.mock.calls.map(([action]) => action as ResumeAction).filter(action => action?.credentials?.resume);

it('re-runs resume-login on a Zombie Connection (connected + !isAuthenticated + token)', async () => {
mockStoreGetState.mockReturnValue({
meteor: { connected: true },
login: { user: { token: 'tok' }, isAuthenticated: false },
settings: {}
});

await connect({ server: 'https://example.com' });
mockStoreDispatch.mockClear();

getHandlersByEvent('connected')[0]();

const resumes = resumeActions();
expect(resumes).toHaveLength(1);
expect(resumes[0].credentials).toEqual({ resume: 'tok' });
});

it('early-returns on a healthy connected + isAuthenticated (no relogin storm)', async () => {
mockStoreGetState.mockReturnValue({
meteor: { connected: true },
login: { user: { token: 'tok' }, isAuthenticated: true },
settings: {}
});

await connect({ server: 'https://example.com' });
mockStoreDispatch.mockClear();

getHandlersByEvent('connected')[0]();

expect(resumeActions()).toHaveLength(0);
});
});

// Note: Apple authentication when isIOS is true is tested in connect.ios.test.ts
6 changes: 4 additions & 2 deletions app/lib/services/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr

connectedListener = sdk.current.onStreamData('connected', () => {
const { connected } = store.getState().meteor;
if (connected) {
const { user, isAuthenticated } = store.getState().login;
// A real handshake on an unauthenticated session (a Zombie Connection: connected +
// !isAuthenticated + token) must re-run resume-login instead of stranding with 0 subs.
if (connected && isAuthenticated) {
return;
}
store.dispatch(connectSuccess());
const { user } = store.getState().login;
if (user?.token) {
store.dispatch(loginRequest({ resume: user.token }, logoutOnError));
}
Expand Down
74 changes: 74 additions & 0 deletions app/lib/services/ddpSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,80 @@ describe('Socket.forceReopen awaitability', () => {
});
});

describe('Socket subscription restoration (lib-led)', () => {
const seed = (socket: any, name: string, params: unknown[], id: string) => {
socket.subscriptions[id] = {
id,
name,
params,
unsubscribe: jest.fn(() => Promise.resolve()),
onEvent: jest.fn()
};
};

const subFrames = (send: jest.Mock) =>
send.mock.calls.map(([raw]) => JSON.parse(raw as string)).filter(frame => frame.msg === 'sub');

it('forceReopen keeps the subscription registry (subtraction 1)', async () => {
const { socket } = buildSocket();
socket.open = jest.fn(() => Promise.resolve());
seed(socket, 'stream-notify-user', ['u1/message'], 'ddp-1');

await socket.forceReopen();

expect(Object.keys(socket.subscriptions)).toEqual(['ddp-1']);
});

it('subscribeAll replays each preserved sub exactly once, reusing its original id', () => {
const { socket, send } = buildSocket();
seed(socket, 'stream-notify-user', ['u1/message'], 'ddp-1');
seed(socket, 'stream-room-messages', ['rid-1'], 'ddp-2');

socket.subscribeAll();

const frames = subFrames(send);
expect(frames).toHaveLength(2);
expect(frames.map(f => f.id).sort()).toEqual(['ddp-1', 'ddp-2']);
});

it('dedups two subscribe calls with identical name+params: one sub, one entry, both callbacks fire', async () => {
const { socket, send } = buildSocket();
const cb1 = jest.fn();
const cb2 = jest.fn();

const first = socket.subscribe('stream-room-messages', ['rid-1'], cb1);
socket.subscribe('stream-room-messages', ['rid-1'], cb2);

const frames = subFrames(send);
expect(frames).toHaveLength(1);

// Resolve the single sub so the first subscriber's callback attaches (confirmed path).
socket.emit('ready', { subs: [frames[0].id] });
await first;

const entries = Object.values(socket.subscriptions).filter((s: any) => s.name === 'stream-room-messages');
expect(entries).toHaveLength(1);

socket.emit('stream-room-messages', { fields: {} });
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
});

it('keeps the registry flat across replay+re-subscribe cycles and per-room params separate', () => {
const { socket } = buildSocket();
seed(socket, 'stream-room-messages', ['rid-1'], 'ddp-1');
seed(socket, 'stream-room-messages', ['rid-2'], 'ddp-2');

for (let cycle = 0; cycle < 3; cycle += 1) {
socket.subscribeAll();
socket.subscribe('stream-room-messages', ['rid-1']);
socket.subscribe('stream-room-messages', ['rid-2']);
}

expect(Object.keys(socket.subscriptions)).toHaveLength(2);
});
});

describe('Socket.checkAndReopen bucket dispatch', () => {
const buildWithSpies = () => {
const { socket } = buildSocket();
Expand Down
73 changes: 73 additions & 0 deletions app/sagas/__tests__/state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
jest.mock('../../lib/methods/helpers/localAuthentication', () => ({
localAuthenticate: jest.fn(() => Promise.resolve()),
saveLastLocalAuthenticationSession: jest.fn(() => Promise.resolve())
}));

jest.mock('../../lib/services/connect', () => ({
checkAndReopen: jest.fn(() => Promise.resolve())
}));

jest.mock('../../lib/services/restApi', () => ({
setUserPresenceOnline: jest.fn(() => Promise.resolve()),
setUserPresenceAway: jest.fn(() => Promise.resolve())
}));

jest.mock('../../lib/notifications', () => ({
checkPendingNotification: jest.fn(() => Promise.resolve())
}));

jest.mock('../../lib/methods/helpers/log', () => ({
__esModule: true,
default: jest.fn()
}));

import { put } from 'redux-saga/effects';

import { RootEnum } from '../../definitions';
import { loginRequest } from '../../actions/login';
import { checkAndReopen } from '../../lib/services/connect';
import { appHasComeBackToForeground } from '../state';

// Manual saga-test style (no redux-saga-test-plan): drive the generator by feeding each
// `select` result and inspecting the yielded effects. Enters at ROOT_INSIDE for every case.
describe('state saga — appHasComeBackToForeground resume gate', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('dispatches loginRequest({ resume }) when inside root, unauthenticated, token present', () => {
const gen = appHasComeBackToForeground();
gen.next(); // yields select(app.root)
gen.next(RootEnum.ROOT_INSIDE); // yields select(login)
const effect = gen.next({ isAuthenticated: false, user: { token: 'tok' } }).value;

expect(effect).toEqual(put(loginRequest({ resume: 'tok' })));
expect(gen.next().done).toBe(true);
});

it('bails without dispatching when unauthenticated and no token', () => {
const gen = appHasComeBackToForeground();
gen.next();
gen.next(RootEnum.ROOT_INSIDE);
const result = gen.next({ isAuthenticated: false, user: null });

expect(result.done).toBe(true);
expect(result.value).toBeUndefined();
});

it('runs the authenticated reopen path with no redundant resume', () => {
const gen = appHasComeBackToForeground();
gen.next();
gen.next(RootEnum.ROOT_INSIDE);

const values: unknown[] = [];
let result = gen.next({ isAuthenticated: true, user: { token: 'tok' } });
while (!result.done) {
values.push(result.value);
result = gen.next(undefined);
}

expect(checkAndReopen).toHaveBeenCalledTimes(1);
expect(values).not.toContainEqual(put(loginRequest({ resume: 'tok' })));
});
});
10 changes: 8 additions & 2 deletions app/sagas/state.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { select, takeLatest } from 'redux-saga/effects';
import { put, select, takeLatest } from 'redux-saga/effects';

import log from '../lib/methods/helpers/log';
import { localAuthenticate, saveLastLocalAuthenticationSession } from '../lib/methods/helpers/localAuthentication';
import { loginRequest } from '../actions/login';
import { APP_STATE } from '../actions/actionsTypes';
import { RootEnum } from '../definitions';
import { checkAndReopen } from '../lib/services/connect';
Expand All @@ -14,13 +15,18 @@ const isAuthAndConnected = function* isAuthAndConnected() {
return login.isAuthenticated && meteor.connected;
};

const appHasComeBackToForeground = function* appHasComeBackToForeground() {
export const appHasComeBackToForeground = function* appHasComeBackToForeground() {
const appRoot = yield select(state => state.app.root);
if (appRoot !== RootEnum.ROOT_INSIDE) {
return;
}
const login = yield select(state => state.login);
if (!login.isAuthenticated) {
// A Zombie Connection (inside root, token present but not authenticated) resumes login
// instead of bailing; the authenticated path below owns the reconnect.
if (login.user?.token) {
yield put(loginRequest({ resume: login.user.token }));
}
return;
}
try {
Expand Down
Binary file modified patches/@rocket.chat+sdk+1.3.3-mobile.patch
Binary file not shown.
Loading