fix(loro-websocket): settle reconnect-adopter promises on destroy to avoid unhandled rejections - #63
Open
naomiaro wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
LoroWebsocketClientcan produce unhandled promise rejections whendestroy()is called while a reconnect attempt (or the initial connect, ora 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()isasync; on its normal path it just returns the sharedconnectedPromise(return this.connectedPromise;). Becauseconnect()is itself async, its own returned promise is a separate object that
adopts/chains to
connectedPromise— it is not the same promise instancethat
ensureConnectedPromise()protects withthis.connectedPromise.catch(() => {}).Three call sites invoke
connect()as a bare, unreferenced statement(
void this.connect();):scheduleReconnect()'s retry timer — the main source, since everyautomatic reconnect attempt during a disconnect episode creates its own
adopter promise this way.
sendJoinPayload()'s fallback connect.When
destroy()later rejects the sharedconnectedPromise(
this.rejectConnected?.(new Error("Destroyed"))), every accumulatedadopter 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 atclose()/destroy(),which does not protect the separate adopter promises.
Fix
Attach a no-op
.catch(() => {})at each of the three bareconnect()call sites instead of discarding the promise with
void. This does notchange observable behavior for callers — connection failures are already
surfaced through
waitConnected()(which awaits the shared promise) andthrough
onError/emitErrorinconnect()'s own error path. It onlyensures 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.tsthat brings a clientto
Connected, forces an unexpected close (triggeringscheduleReconnect()), 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 aprocess.on("unhandledRejection", ...)listener and asserts nothing wasreported. Confirmed the test fails with
Error: Destroyed(the exactdestroy()rejection reason) before the fix, and passes after.🤖 Generated with Claude Code