RocketChat / RocketChat/Rocket.Chat.js.SDK
Enforce one active Connection Attempt per Socket
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 146
- Forks
- 99
- Avg merge
- 7h 59m
- Merged PRs (30d)
- 17
Description
Problem Statement
A consuming app can ask one SDK Socket to connect, recover immediately, recover after a delay, or close while earlier connection work is still pending. Today those operations can construct competing Transports, replace work unexpectedly, leave callers without one consistent terminal outcome, or allow recovery to outlive an explicit close. The app cannot safely reason about foreground recovery, VoIP readiness, teardown, or whether a completed call represents a successful DDP handshake.
Solution
Give each Socket one coordinated connection-work lifecycle. At any moment it is Idle, owns one Scheduled Reopen, or owns one active Connection Attempt. Requests either retain a usable Transport, share the current attempt, deliberately replace an ordinary attempt with one forced attempt, or take exclusive close ownership. Every attempt owns one Transport, one absolute Deadline, and one terminal outcome. Lifecycle events report committed transitions, and normal delayed recovery remains separate from the outcome observed by callers of a failed attempt.
User Stories
- As an SDK consumer, I want concurrent Driver
connect()calls to share one Connection Attempt, so that one Socket never creates competing Transports. - As an SDK consumer, I want a successful
connect()to mean that the DDP handshake completed, so that I can use the realtime connection when the call fulfills. - As an SDK consumer, I want
connect()to retain an already usable Transport, so that repeated connection requests do not disrupt working realtime state. - As an SDK consumer, I want every caller attached to one Connection Attempt to observe the same terminal outcome, so that concurrent callers agree about success or failure.
- As an SDK consumer, I want a Connection Attempt to have one absolute Deadline from Transport construction through the DDP handshake, so that neither phase can leave me waiting indefinitely.
- As an SDK consumer, I want joining callers to inherit the active attempt's remaining Deadline, so that a late caller cannot extend stalled work.
- As an SDK consumer, I want Transport-construction and write failures to preserve their useful
Errorreason, so that failures remain diagnosable. - As an SDK consumer, I want reasonless Transport failures to reject with a stable SDK error, so that every failure is an
Errorwith a readable message. - As an SDK consumer, I want a failed one-shot
connect()to finish without silently scheduling recovery, so that retry policy does not change behind my call. - As an SDK consumer, I want an unexpected Transport loss to schedule exactly one delayed Reopen, so that normal recovery continues without duplicate timers.
- As an SDK consumer, I want repeated delayed-recovery requests to share the existing schedule without resetting it, so that recovery is not postponed indefinitely.
- As an SDK consumer, I want an immediate
reopenNow()to consume a pending Scheduled Reopen, so that foreground recovery starts now rather than also firing later. - As an SDK consumer, I want
reopenNow()to replace an ordinary Connection Attempt at most once, so that stronger recovery can advance stalled work without Transport churn. - As an SDK consumer, I want concurrent
reopenNow()calls to share one forced Connection Attempt, so that foreground and VoIP callers cannot repeatedly replace each other. - As a caller of an ordinary attempt superseded by
reopenNow(), I want prompt rejection instead of transfer to the replacement, so that my request reports what happened to its own work. - As a caller of a failed forced attempt, I want its failure immediately even when delayed recovery follows, so that later recovery cannot retroactively report my foreground operation as successful.
- As an SDK consumer, I want a failed recovery attempt to create exactly one new Scheduled Reopen, so that recovery continues at the configured fixed delay until success or close.
- As an SDK consumer, I want a successful handshake to leave the Socket Idle with the established Transport retained, so that later requests see stable ownership.
- As an SDK consumer, I want stale callbacks from detached Transports to have no effect, so that superseded work cannot change current state, emit events, or schedule recovery.
- As an SDK consumer, I want
disconnect()and concurrent close calls to join one close operation, so that teardown has one owner, one Deadline, and one outcome. - As an SDK consumer, I want close ownership to begin synchronously and be unsupersedable, so that fire-and-forget teardown cannot race with later recovery.
- As an SDK consumer, I want close to cancel scheduled and active connection work, so that no Transport is created after teardown has begun.
- As a caller attached to connection work canceled by close, I want the existing close-specific
Error, so that cancellation is distinguishable from supersession or expiry. - As an SDK consumer, I want new Connection, DDP, Login, and Liveness work refused while close owns the Socket, so that no message reaches a Transport being released.
- As an SDK consumer, I want close to settle within the fixed 2000 ms bound even when the Transport refuses or never answers, so that teardown cannot hang.
- As an SDK consumer, I want close to finish with an Idle Socket and no retained Transport, DDP session, Liveness work, recovery intent, or pending close, so that later connection work starts cleanly.
- As an event listener, I want one Socket
connectingevent per successfully attached attempt Transport, so that joins, retained connections, schedules, and construction failures do not look like new attempts. - As an event listener, I want one Socket
openand one Driverconnectedevent per successful DDP handshake, so that joined callers do not multiply lifecycle observations. - As an event listener, I want Socket
closeemitted once for the currently owned Transport, including a synthesized event when explicit close must finish locally, so that close observation is deterministic. - As an SDK consumer, I want the obsolete Socket
disconnectedevent removed from internal settlement, so that ownership transitions settle affected DDP waits directly. - As a Rocket.Chat React Native consumer, I want the retained Driver and Client signatures to compile unchanged, so that adopting the new SDK requires only the intentional contract changes.
- As a Rocket.Chat React Native maintainer, I want foreground and VoIP flows to handle failed forced attempts without reporting success, so that a later Scheduled Reopen cannot revive a failed caller.
- As an SDK maintainer, I want the public bounded liveness check preserved, so that the app can verify a questionable Transport before forcing replacement.
- As an SDK maintainer, I want obsolete
checkAndReopenand unused close configuration removed, so that the public contract reflects behavior the SDK actually supports. - As an SDK maintainer, I want unrelated DDP, subscription, liveness, configuration, teardown, and event behavior to remain pinned, so that the change is limited to the decisions resolved by #399.
Implementation Decisions
- Coordination is per Socket instance. Cross-Socket coordination is not introduced.
- Connection work has exactly three exclusive forms: Idle, one Scheduled Reopen, or one active Connection Attempt. Closing is tracked separately and takes precedence.
- Idle may retain an established Transport. A Socket owns at most one attached, observable Transport at all times.
- A Connection Attempt begins when Transport construction begins and ends only after successful DDP handshake, terminal failure, supersession, or close cancellation. Transport open alone is not success.
- Each Connection Attempt owns an identity, its Transport, one absolute Deadline derived from
timeout, its recovery intent, and one terminal outcome. Only callbacks matching the current attempt and Transport may change state. - Internal
open()retains a usable Transport, consumes a Scheduled Reopen before beginning ordinary work, and shares any active ordinary or forced attempt. Anopen()-only failure returns to Idle without scheduling recovery. - Internal
reopen()owns normal delayed recovery. Repeated requests keep an existing schedule without resetting its deadline. A recovery failure schedules exactly one fresh delayed Reopen. - Public Driver
reopenNow()starts forced work immediately. It cancels a Scheduled Reopen, supersedes an ordinary attempt once, and shares an existing forced attempt without resetting its Deadline. - Supersession terminalizes the ordinary attempt before forced work begins. Attached ordinary callers reject with
[ddp] connection attempt was superseded before it completedand do not transfer to the successor. - A failed forced attempt rejects its callers with that attempt's terminal outcome, then creates one Scheduled Reopen. Later recovery is independent work.
- Internal
open()fulfills withvoid, Driverconnect()fulfills with the Driver, and DriverreopenNow()anddisconnect()fulfill withvoid. Promise and Error reference identity are not contractual. - Deadline expiry rejects attached callers with
[ddp] connection attempt did not complete before the deadline. Joining never resets the Deadline, and a replacement attempt receives a fresh Deadline. - Transport construction or write failures that provide an
Errorpreserve its reason. Server DDP errors continue to use the established conversion. Raw events and non-Error Transport failures become[ddp] transport failed during the connection attempt. - Terminalization commits state before events or caller settlement. A canceled or failed attempt detaches its Transport before successor work begins; late and duplicate callbacks have no authority.
- Close takes synchronous, unsupersedable ownership. It cancels Scheduled Reopen and active attempts before invoking Transport behavior and prevents callbacks from restoring connection work.
- All concurrent Socket close and Driver
disconnect()callers join the first close operation, its absolute fixed 2000 ms Deadline, and itsvoidoutcome. - While close owns the Socket, new
open()andreopenNow()calls and callers already attached to attempts reject with[ddp] connection closed before it opened. Internalreopen()records no recovery intent. - While close owns the Socket, no new DDP message or Liveness work reaches the Transport. Prevented DDP work rejects with
[ddp] connection closed before the response arrived; already written work keeps its established close-specific behavior. - Logout requested during close rejects before changing Login state, DDP subscriptions, or request queues.
- Close retains the fixed 2000 ms bound, synthesizes one code-4000 close observation when the Transport refuses or fails to answer, performs existing DDP subscription cleanup, and settles in Idle with no owned Transport or pending work.
- Socket
connectingoccurs once after an attempt successfully attaches a Transport. Socketopenoccurs once after authoritative handshake success. Driver synchronously emits exactly oneconnectedfor each Socketopen. - Socket
closeoccurs once for the currently owned Transport. Detached, stale, failed, superseded, or canceled Transports emit no later lifecycle observation through the Socket. - Socket
disconnectedis retired. DDP waits abandoned by replacement settle directly during the ownership transition. - The public root Driver contract, Client forwarding,
connect,disconnect,reopenNow,connected, the bounded liveness check, media-subscription readiness, and generic event-listener surface remain available. - Remove
checkAndReopenfrom Client, Driver,IDriver, and the consumer compile contract. Do not add a deprecated alias or compatibility flag. - Remove the unused public Socket
closeoption and its documentation. It does not control the fixed close Deadline. - Preserve app observations of
connecting,close, andconnectedthrough stream-data listeners. Driver directly exposes no new lifecycle event. - Connection-work and Socket implementation details remain private. Consumer tests may adapt their private setup without turning it into supported API.
- Rocket.Chat React Native PR 7574 at its exact head is the hard-switch compatibility baseline for the candidate SDK from current
origin/mobile. - Create a complete successor to ADR-0003 covering SDK-originated waits, errors, Deadlines, direct settlement on ownership changes, and Connection work. Mark ADR-0003 superseded.
- Create a separate complete successor to ADR-0009 covering close ownership, admission refusal, joined close callers, the fixed bound, Transport release, and final Idle outcome. Mark ADR-0009 superseded.
- Reconcile ADR-0006 with direct ownership settlement after retiring
disconnected. Audit ADR-0002 and every citation of ADR-0003 and ADR-0009. Keep ADR-0007 as history, and retain ADR-0008 and ADR-0013 unless the implementation makes their text false.
Testing Decisions
- Prefer the highest existing behavioral boundary: exercise public Driver operations and observations while allowing the Driver to construct its Socket and mocked Transport through the normal production path.
- Use the existing mocked
universal-websocket-clientTransport and its registry to drive Transport callbacks, DDP handshake responses, failures, and time deterministically. Do not assign a Transport directly onto Driver or Socket. - Test Socket directly only where internal recovery, close admission, or event behavior has no public Driver operation. Assert observable calls, errors, events, attached Transport count, written DDP messages, and timing rather than private state shape.
- Update the Driver contract tests for retained fulfillment values, exact event cardinality, disconnect joining, and removal of
checkAndReopen. - Update Socket connection tests to cover every connection-work form and operation pair: retaining, scheduling, consuming, ordinary joining, forced joining, one ordinary-to-forced replacement, attempt success, attempt failure, and close cancellation.
- Prove that one Socket never has more than one attached, observable Transport and never owns a Scheduled Reopen at the same time as an active attempt.
- Verify the single absolute Connection Attempt Deadline across Transport connection and DDP handshake. Cover joining with remaining time, replacement with a fresh Deadline, construction failure, write failure, reasonless Transport failure, server DDP error, and late callbacks after terminalization.
- Verify exact caller settlement values and error messages. Assert that all attached callers observe the same terminal kind and timing, without asserting Promise or Error object identity.
- Verify recovery intent and cardinality: one-shot failure schedules nothing; forced and recovery failure schedule exactly one Reopen; repeated recovery requests do not reset the schedule; success, supersession, and close do not create unintended schedules.
- Verify close and Driver disconnect races, including synchronous Transport close callbacks, refusal to close, an unanswered close through the fixed Deadline, concurrent callers, active ordinary and forced attempts, a Scheduled Reopen, new work during close, logout admission, and late callbacks after detach.
- Verify DDP work before and during ownership changes: work waiting to write, queued work, already written work, Liveness work, and the existing DDP subscription bookkeeping behavior.
- Verify lifecycle event order and cardinality for
connecting, Socketopen, Driverconnected, and Socketclose; verify thatdisconnectedno longer drives settlement or appears as a Socket lifecycle event. - Update the consumer compile contract to pin all retained public signatures and prove the absence of
checkAndReopenand the unusedcloseoption. - Preserve all pinning assertions unrelated to the intentional behavior changes, including DDP, DDP subscription, liveness, configuration, teardown, and event coverage.
- Run the SDK gate: lint, every TypeScript program, and the complete Jest suite.
- Validate Rocket.Chat React Native PR 7574 against the exact candidate SDK commit from a clean dependency state, without a module declaration shim. Run its real-SDK coverage for connect, Socket recovery, foreground resume, Room DDP subscriptions, VoIP readiness, forced-attempt failure in both foreground and VoIP postures, and fire-and-forget disconnect ownership.
- Keep the React Native checks as release evidence on PR 7574 rather than permanent cross-repository SDK CI.
Out of Scope
- Coordination across Socket instances.
- A redesign of Rocket.Chat React Native recovery behavior.
- A redesign of DDP subscription recovery or media resubscription.
- New public lifecycle events or public connection-state internals.
- A compatibility mode, deprecated alias, or gradual migration path for
checkAndReopenor the unused close option. - Promise reference identity, Error reference identity, listener order within one emission, or Promise microtask ordering as supported behavior.
- Any behavior change outside the joining, supersession, Deadline, failure, close, retry, and lifecycle decisions resolved by #399.
Further Notes
- This specification synthesizes the completed decisions in #399 and its child tickets #400, #401, #402, #403, #404, #405, and #411.
- The corrected resolution in #402 is authoritative: internal
reopen()remains delayed recovery and publicreopenNow()remains immediate forced recovery. - The consuming-app baseline is Rocket.Chat React Native PR 7574 at its exact head when implementation verification begins.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing the Socket lifecycle and the Driver, Client, and IDriver entry points, including connect(), disconnect(), reopenNow(), liveness, and the consumer compile contract. Compare the existing behavior with the decisions in this issue; done means coordinated connection and close ownership, correct outcomes and lifecycle events, removal of checkAndReopen and the unused close option, and preserved unrelated behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100