Skip to content

fix: re-arm reconnect ladder when a forced socket reopen fails - #7553

Open
Rohit3523 wants to merge 5 commits into
developfrom
fix/reconnect-ladder-survives-forced-reopen
Open

fix: re-arm reconnect ladder when a forced socket reopen fails#7553
Rohit3523 wants to merge 5 commits into
developfrom
fix/reconnect-ladder-survives-forced-reopen

Conversation

@Rohit3523

@Rohit3523 Rohit3523 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Proposed changes

When the app was brought back to the foreground while still offline (e.g. airplane mode), reopenNow() performs the immediate socket attempt but cancels the retry ladder timer first. If that attempt failed, nothing was left scheduled — and a stale timer id disabled the ladder for the rest of the session — so the session stayed disconnected until the next foreground event, even after connectivity returned.

Now a failed forced reopen re-arms the retry ladder and drops the stale id, so the app reconnects on its own once the network is back.

Issue(s)

https://rocketchat.atlassian.net/browse/NATIVE-1461

How to test or reproduce

  1. Open the app, move to a room, enable airplane mode.
  2. Move the app to the background, and from another account send a few messages to that room.
  3. Move the app back to the foreground, with airplane mode still on.
  4. Disable airplane mode.

Expected: within one retry interval the room header stops showing "Waiting for network…" and the messages sent in step 2 load.

Without this fix the header stays on "Waiting for network…" indefinitely — meteor.connected never returns to true — and the messages never arrive, until another background → foreground cycle forces a reconnect. Pull-to-refresh in RoomsListView loads the messages over REST but leaves the socket dead, which is why the room can look partly recovered while the banner persists.

Note on timing: the retry interval is 5s on a release build and 20s in debug (reopen: __DEV__ ? 20000 : 5000 in app/lib/services/sdk.ts), so on a debug build allow ~20s after disabling airplane mode before judging the result.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works

Further comments

Root cause. reopenNow() in the patched DDP driver gets its "immediate" attempt by cancelling the pending ladder timer — but it only called clearTimeout(), never deleting the openTimeout id. reopen() guards on if (this.openTimeout) return, so openTimeout is not merely a timer handle: it is the flag for "a tick is already scheduled". A cleared-but-still-set id therefore silenced the entire ladder for the rest of the session, including the re-arm onClose attempts on every later close. So a single foreground while offline did not just cancel one tick — it permanently removed the app's ability to schedule another.

On top of that, when the forced attempt failed (device offline / server unreachable), the old cleanup path resolved the shared promise and left nothing scheduled, so that one failed reconnect meant the session sat disconnected with no retry path until the next explicit trigger.

Why this fix. The ladder was already the correct place to own retry scheduling; forcing a reopen just needed to not sabotage it. So instead of adding a parallel retry timer in the caller, reopenNow() now (a) deletes the cancelled id so reopen() can schedule again, and (b) re-arms the ladder — this.reopen() — whenever the attempt did not produce a live socket (on create error or the 10s deadline). A successful open deliberately skips the re-arm so no redundant tick is left behind. reopenPromise is deleted before the ladder is re-armed so a ladder tick calling open() cannot short-circuit onto the already-settled reopen promise.

The two parts are complementary. Deleting the id alone would let onClose recover the ladder, but a socket that never opens may never emit close, so the re-arm covers that case. The re-arm alone does nothing at all while the stale id is still present — an earlier revision of this fix had only the re-arm, passed the whole unit suite, and was a complete no-op on device.

Verification. Beyond the unit tests, verified on an Android emulator against a live server: three airplane-mode cycles following the steps above all recovered — banner cleared, meteor.connected back to true, and every message sent during the offline window arrived. A control cycle with no background/foreground step confirms the path that already worked is not regressed.

Summary by CodeRabbit

  • Bug Fixes
    • Improved connection recovery after interruptions, timeouts, and connection errors.
    • Prevented outdated connections from interfering with active sessions.
    • Added connection health checks and reduced connection timeout delays.
    • Improved automatic restoration of user media signals and media call subscriptions after reconnecting.
    • Reduced redundant reconnect attempts and improved handling of simultaneous connection requests.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c0780c02-a3b6-4323-bbb4-a2464a51f2f4

📥 Commits

Reviewing files that changed from the base of the PR and between 19526fd and a51baa4.

📒 Files selected for processing (1)
  • patches/@rocket.chat+sdk+1.3.3-mobile.patch
🚧 Files skipped from review as they are similar to previous changes (1)
  • patches/@rocket.chat+sdk+1.3.3-mobile.patch
📜 Recent review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: ESLint and Test / run-eslint-and-test

Walkthrough

The DDP socket now supports bounded immediate reconnection, socket replacement, and ping-based liveness checks. DDPDriver exposes these operations and restores media subscriptions. RocketChatClient retains its DDP driver. Tests cover reconnect-ladder recovery.

Changes

DDP recovery flow

Layer / File(s) Summary
Socket replacement and immediate reconnect
patches/@rocket.chat+sdk+1.3.3-mobile.patch, app/lib/services/ddpSocket.test.ts
Socket creation and replacement now detach stale handlers and share reconnect operations. reopenNow() performs bounded reconnection and restores the retry ladder after failures. Tests cover timeout, connection errors, scheduled ticks, and successful opens.
Liveness APIs and driver forwarding
patches/@rocket.chat+sdk+1.3.3-mobile.patch
probe() checks for a recent pong. DDPDriver forwards reconnect and probe calls and exposes ping timestamps and intervals.
Media subscription restoration
patches/@rocket.chat+sdk+1.3.3-mobile.patch
Media signal and media call subscriptions are included. waitForNotifyUserMediaSubs() polls for both subscriptions and resubscribes them with timeout and acknowledgement handling.
DDP driver retention
patches/@rocket.chat+sdk+1.3.3-mobile.patch
RocketChatClient exposes and stores the constructed DDPDriver for DDP connections.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant DDPDriver
  participant Socket
  participant WebSocket
  Client->>DDPDriver: reopenNow()
  DDPDriver->>Socket: reopenNow()
  Socket->>WebSocket: create replacement connection
  WebSocket-->>Socket: open or timeout/error
  Socket-->>DDPDriver: reconnect result
  DDPDriver-->>Client: resolve operation
  Client->>DDPDriver: probe()
  DDPDriver->>Socket: probe()
  Socket->>WebSocket: send ping
  WebSocket-->>Socket: pong
  Socket-->>DDPDriver: liveness result
Loading

Possibly related PRs

Suggested labels: type: bug

Suggested reviewers: otaviostasiak

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: restoring the reconnect ladder after a forced socket reopen fails.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • NATIVE-1461: Request failed with status code 401

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
patches/@rocket.chat+sdk+1.3.3-mobile.patch (1)

75-76: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not resolve open() after a failed forced reconnect.

If reopenNow() fails, it resolves reopenPromise after it re-arms the reconnect ladder. This branch then resolves open() with this.connection, even when that socket is not open. A caller can treat open() as successful and send on a failed or connecting socket.

Reject when this.connected is false, or wait for a subsequent open event before resolving.

Proposed fix
 if (this.reopenPromise) {
-  return this.reopenPromise.then(() => resolve(this.connection)).catch(reject)
+  return this.reopenPromise
+    .then(() => {
+      if (!this.connected || !this.connection) {
+        throw new Error('Forced reconnect did not establish a socket')
+      }
+      resolve(this.connection)
+    })
+    .catch(reject)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch around lines 75 - 76, Update the
reopenPromise branch in open() so it does not resolve after a failed forced
reconnect: after reopenPromise settles, resolve only when this.connected is
true, otherwise reject (or wait for a subsequent open event). Ensure open()
never reports success with a failed or still-connecting this.connection.
🧹 Nitpick comments (2)
patches/@rocket.chat+sdk+1.3.3-mobile.patch (2)

334-334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace any with DDPDriver.

ddp exposes the new DDP-specific API. any disables type checking at this public boundary. Import DDPDriver as a type and declare ddp?: DDPDriver.

As per coding guidelines, TypeScript code must use type safety for object shapes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch at line 334, Replace the any
type on the public ddp property with DDPDriver, importing DDPDriver as a type
from the appropriate module. Preserve the property’s optional nature while
restoring type checking for the DDP-specific API.

Source: Coding guidelines


290-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use async and try/catch for subscription acknowledgements.

resubscribe handles an asynchronous operation with a .then() chain. Make it an async function and use try/catch.

As per coding guidelines, prefer async/await and use explicit try/catch handling for async operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch around lines 290 - 294, Update
the resubscribe function to be async and await the Promise.all subscription
acknowledgements inside a try/catch block. Return true after all subscriptions
succeed and false from the catch path when any subscription fails, preserving
the current behavior without the .then()/.catch() chain.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch:
- Around line 75-76: Update the reopenPromise branch in open() so it does not
resolve after a failed forced reconnect: after reopenPromise settles, resolve
only when this.connected is true, otherwise reject (or wait for a subsequent
open event). Ensure open() never reports success with a failed or
still-connecting this.connection.

---

Nitpick comments:
In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch:
- Line 334: Replace the any type on the public ddp property with DDPDriver,
importing DDPDriver as a type from the appropriate module. Preserve the
property’s optional nature while restoring type checking for the DDP-specific
API.
- Around line 290-294: Update the resubscribe function to be async and await the
Promise.all subscription acknowledgements inside a try/catch block. Return true
after all subscriptions succeed and false from the catch path when any
subscription fails, preserving the current behavior without the .then()/.catch()
chain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1b538ac-44e1-47cd-b356-0ac1bbfe8958

📥 Commits

Reviewing files that changed from the base of the PR and between 140a44e and 19526fd.

📒 Files selected for processing (2)
  • app/lib/services/ddpSocket.test.ts
  • patches/@rocket.chat+sdk+1.3.3-mobile.patch
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: ESLint and Test / run-eslint-and-test
  • GitHub Check: E2E Shard Preflight
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,jsx,tsx}: Use descriptive names for functions, variables, and classes that clearly convey their purpose
Write comments that explain the 'why' behind code decisions, not the 'what'
Keep functions small and focused on a single responsibility
Use const by default, let when reassignment is needed, and avoid var
Prefer async/await over .then() chains for handling asynchronous operations
Use explicit error handling with try/catch blocks for async operations
Avoid deeply nested code; refactor complex logic into helper functions

Files:

  • app/lib/services/ddpSocket.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use TypeScript for type safety; add explicit type annotations to function parameters and return types
Prefer interfaces over type aliases for defining object shapes in TypeScript
Use enums for sets of related constants rather than magic strings or numbers

Files:

  • app/lib/services/ddpSocket.test.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{js,jsx,ts,tsx}: Format JavaScript and TypeScript code with Oxfmt using the repository configuration: tabs, single quotes, 130-character width, no trailing commas, omitted arrow-function parentheses where possible, and same-line brackets.
Follow Oxlint rules configured in .oxlintrc.json, including the import, React, Jest, TypeScript, and React Native plugins.

Files:

  • app/lib/services/ddpSocket.test.ts
🧠 Learnings (3)
📚 Learning: 2026-04-30T17:07:51.020Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7274
File: app/lib/services/voip/MediaCallEvents.ts:0-0
Timestamp: 2026-04-30T17:07:51.020Z
Learning: In this Rocket.Chat React Native codebase, the ESLint rule `no-void: error` is enforced. When you see a promise returned from an async call that is not awaited (a “floating promise”), do not silence it with the `void somePromise()` pattern. Instead, handle the promise explicitly by attaching `.catch(...)` (or otherwise awaiting/handling the error) so unhandled-rejection risks are addressed in a way that satisfies the existing ESLint configuration.

Applied to files:

  • app/lib/services/ddpSocket.test.ts
📚 Learning: 2026-06-25T18:37:25.526Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7434
File: app/views/ScreenLockConfigView.test.tsx:16-22
Timestamp: 2026-06-25T18:37:25.526Z
Learning: In Rocket.Chat ReactNative tests that mock selectors for `useAppSelector`, don’t require the mocked selector input to be typed as `IApplicationState` when the fixture only includes a partial Redux state slice (e.g., only `server` and `settings`). Requiring the full `IApplicationState` type in that scenario forces unsafe `as IApplicationState` casts and undermines type-safety. For these narrowly scoped selector-mock fixtures, use a less strict type (e.g., `any`) to keep the mock focused on the slice under test.

Applied to files:

  • app/lib/services/ddpSocket.test.ts
📚 Learning: 2026-05-05T21:08:33.177Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7298
File: patches/@rocket.chat+sdk+1.3.3-mobile.patch:79-79
Timestamp: 2026-05-05T21:08:33.177Z
Learning: In the RocketChat/Rocket.Chat.ReactNative repo, for patches under patches/*.patch (especially those touching rocket.chat/sdk), remember that the patching sandbox's node_modules reflects the pre-patch state. Grepping node_modules for symbols (e.g., userDisconnectCloseCode = 4000 in node_modules/rocket.chat/sdk/lib/drivers/ddp.ts) can yield false positives. Review patches by inspecting the diff and applying it to a fresh copy of the SDK or diffing against the SDK source, rather than relying on node_modules. Ensure the patch actually introduces/updates symbols in the SDK source and run the test suite to validate behavior after applying the patch.

Applied to files:

  • patches/@rocket.chat+sdk+1.3.3-mobile.patch
🔇 Additional comments (2)
patches/@rocket.chat+sdk+1.3.3-mobile.patch (1)

9-9: LGTM!

Also applies to: 25-61, 96-103, 118-166, 172-208, 218-218, 227-227, 235-249, 259-262, 273-289, 295-321, 343-346

app/lib/services/ddpSocket.test.ts (1)

209-231: LGTM!

Also applies to: 233-254, 256-275, 277-287

@Rohit3523

Copy link
Copy Markdown
Member Author

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)

patches/@rocket.chat+sdk+1.3.3-mobile.patch (1)> 75-76: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not resolve open() after a failed forced reconnect.
If reopenNow() fails, it resolves reopenPromise after it re-arms the reconnect ladder. This branch then resolves open() with this.connection, even when that socket is not open. A caller can treat open() as successful and send on a failed or connecting socket.
Reject when this.connected is false, or wait for a subsequent open event before resolving.

Proposed fix

 if (this.reopenPromise) {
-  return this.reopenPromise.then(() => resolve(this.connection)).catch(reject)
+  return this.reopenPromise
+    .then(() => {
+      if (!this.connected || !this.connection) {
+        throw new Error('Forced reconnect did not establish a socket')
+      }
+      resolve(this.connection)
+    })
+    .catch(reject)
 }

🤖 Prompt for AI Agents

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch around lines 75 - 76, Update the
reopenPromise branch in open() so it does not resolve after a failed forced
reconnect: after reopenPromise settles, resolve only when this.connected is
true, otherwise reject (or wait for a subsequent open event). Ensure open()
never reports success with a failed or still-connecting this.connection.

🧹 Nitpick comments (2)

patches/@rocket.chat+sdk+1.3.3-mobile.patch (2)> 334-334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace any with DDPDriver.
ddp exposes the new DDP-specific API. any disables type checking at this public boundary. Import DDPDriver as a type and declare ddp?: DDPDriver.
As per coding guidelines, TypeScript code must use type safety for object shapes.

🤖 Prompt for AI Agents

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch at line 334, Replace the any
type on the public ddp property with DDPDriver, importing DDPDriver as a type
from the appropriate module. Preserve the property’s optional nature while
restoring type checking for the DDP-specific API.

Source: Coding guidelines

290-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Use async and try/catch for subscription acknowledgements.
resubscribe handles an asynchronous operation with a .then() chain. Make it an async function and use try/catch.
As per coding guidelines, prefer async/await and use explicit try/catch handling for async operations.

🤖 Prompt for AI Agents

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch around lines 290 - 294, Update
the resubscribe function to be async and await the Promise.all subscription
acknowledgements inside a try/catch block. Return true after all subscriptions
succeed and false from the catch path when any subscription fails, preserving
the current behavior without the .then()/.catch() chain.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch:
- Around line 75-76: Update the reopenPromise branch in open() so it does not
resolve after a failed forced reconnect: after reopenPromise settles, resolve
only when this.connected is true, otherwise reject (or wait for a subsequent
open event). Ensure open() never reports success with a failed or
still-connecting this.connection.

---

Nitpick comments:
In `@patches/`@rocket.chat+sdk+1.3.3-mobile.patch:
- Line 334: Replace the any type on the public ddp property with DDPDriver,
importing DDPDriver as a type from the appropriate module. Preserve the
property’s optional nature while restoring type checking for the DDP-specific
API.
- Around line 290-294: Update the resubscribe function to be async and await the
Promise.all subscription acknowledgements inside a try/catch block. Return true
after all subscriptions succeed and false from the catch path when any
subscription fails, preserving the current behavior without the .then()/.catch()
chain.

ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1b538ac-44e1-47cd-b356-0ac1bbfe8958

📥 Commits
Reviewing files that changed from the base of the PR and between 140a44e and 19526fd.

📒 Files selected for processing (2)

  • app/lib/services/ddpSocket.test.ts
  • patches/@rocket.chat+sdk+1.3.3-mobile.patch

📜 Review details

none are being changed in this PR, with reasoning below.

  1. open() resolving after a failed forced reopen

Correct about the contract, but out of scope here and not currently reachable. This PR only changes reopenNow; open() is pre-existing patch content from the earlier socket-health work.

open() has three callers and only reopen() consumes its resolution — checkAndReopen ignores it, DDPDriver.connect only uses .catch. So a false resolve costs exactly one thing: reopen()'s catch doesn't re-arm the ladder. This PR makes reopenNow's cleanup re-arm the ladder unconditionally whenever the attempt didn't produce a live socket, which covers that window, and once cleanup has run reopenPromise is deleted so open() no longer short-circuits. I couldn't construct a case where the false resolve strands a session after this change.

The suggested fix also needs a companion change: checkAndReopen calls this.open() with no .catch, so making open() reject would trade this wart for an unhandled rejection. I'll fold both into the next change that touches this area.

  1. ddp?: any → DDPDriver

This wouldn't restore any type checking, so I'd rather not add an annotation that implies it did. The package ships index.d.ts containing only declare module '@rocketchat/sdk'; — not even the correct package name, the dot is missing — and package.json#types points at it, so the whole module resolves to any in app code. tsc --noEmit accepts all of the following without error:

sdk.current.thisPropertyDoesNotExist
sdk.current.ddp.alsoNotReal.deeplyFake()
const c: number = sdk.current.ddp
Separately, app/lib/services/sdk.ts declares the client as typeof Rocketchat — the constructor type, not the instance — so .ddp wouldn't resolve even with real declarations. Making this meaningful requires proper declarations plus fixing the instance typing, which will surface a lot of previously unchecked call sites. That deserves its own PR rather than a drive-by in a reconnect bugfix. (It would also need import type { DDPDriver }, since ddp is assigned from a dynamic import().)

  1. resubscribe async/await

Pre-existing code inside waitForNotifyUserMediaSubs, untouched by this PR. Promise.all(...).then(() => true).catch(() => false) is behaviourally identical to the async/await form, so this is purely stylistic — better left to whoever next edits that function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant