Skip to content

fix(loro-websocket): settle reconnect-adopter promises on destroy to avoid unhandled rejections - #63

Open
naomiaro wants to merge 1 commit into
loro-dev:mainfrom
naomiaro:fix/reconnect-adopter-unhandled-rejection
Open

fix(loro-websocket): settle reconnect-adopter promises on destroy to avoid unhandled rejections#63
naomiaro wants to merge 1 commit into
loro-dev:mainfrom
naomiaro:fix/reconnect-adopter-unhandled-rejection

Conversation

@naomiaro

Copy link
Copy Markdown

Problem

LoroWebsocketClient can produce unhandled promise rejections when
destroy() is called while a reconnect attempt (or the initial connect, or
a deferred join-triggered connect) is still in flight. In a long-running
Node process or test suite that exercises real disconnects, this can
surface as process-level "Unhandled Rejection" warnings/failures even
though every public API call the caller made behaved correctly.

Root cause

connect() is async; on its normal path it just returns the shared
connectedPromise (return this.connectedPromise;). Because connect()
is itself async, its own returned promise is a separate object that
adopts/chains to connectedPromise — it is not the same promise instance
that ensureConnectedPromise() protects with
this.connectedPromise.catch(() => {}).

Three call sites invoke connect() as a bare, unreferenced statement
(void this.connect();):

  • scheduleReconnect()'s retry timer — the main source, since every
    automatic reconnect attempt during a disconnect episode creates its own
    adopter promise this way.
  • The constructor's initial connect.
  • sendJoinPayload()'s fallback connect.

When destroy() later rejects the shared connectedPromise
(this.rejectConnected?.(new Error("Destroyed"))), every accumulated
adopter promise from those bare calls rejects too, with no handler
anywhere.

Verified this is still present in released 0.6.2 and at current HEAD: 0.6.2
added a redundant .catch() on the shared promise at close()/destroy(),
which does not protect the separate adopter promises.

Fix

Attach a no-op .catch(() => {}) at each of the three bare connect()
call sites instead of discarding the promise with void. This does not
change observable behavior for callers — connection failures are already
surfaced through waitConnected() (which awaits the shared promise) and
through onError/emitError in connect()'s own error path. It only
ensures the adopter promise created at each call site settles cleanly
instead of becoming an independent unhandled-rejection source.

Test

Added a regression test in src/client/index.test.ts that brings a client
to Connected, forces an unexpected close (triggering
scheduleReconnect()), waits for the reconnect timer to create a new,
never-opened socket (an in-flight reconnect attempt), then calls
destroy() while that attempt is still unresolved. The test registers a
process.on("unhandledRejection", ...) listener and asserts nothing was
reported. Confirmed the test fails with Error: Destroyed (the exact
destroy() rejection reason) before the fix, and passes after.

🤖 Generated with Claude Code

…avoid unhandled rejections

scheduleReconnect()'s retry timer, the constructor's initial connect, and
sendJoinPayload() all call connect() as a bare, unreferenced call. connect()
is async and, on its normal path, just returns the shared connectedPromise -
so each such call creates its own "adopter" promise chained to that shared
promise. ensureConnectedPromise() only protects the shared promise itself
(via .catch(() => {})); it does not protect these separate adopter promises.
If destroy() rejects the shared promise while a reconnect attempt (or the
initial connect, or a deferred join-triggered connect) is still in flight,
every accumulated adopter promise rejects with no handler anywhere,
surfacing as an unhandled promise rejection in consumers.

Attach a no-op .catch() at each bare call site so these adopter promises
settle cleanly; failures are still observable via waitConnected() and
onStatusChange()/onError as before.

Adds a regression test in src/client/index.test.ts that destroys the client
mid-reconnect-episode and asserts no unhandled rejection is reported.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant