Repository navigation
RTCEngine.join() reopens an engine closed mid-join, so Room.disconnect() during connect/reconnect leaves an orphaned engine that keeps rejoining #2150
Description
Summary
If an RTCEngine is closed while RTCEngine.join() is still awaiting the signal join, the engine reopens itself when that join resolves. join() unconditionally sets _isClosed = false after client.join(...) returns, without checking whether close() ran (or the abort signal fired) in the meantime. The result is an orphaned engine: its owning Room already called close(), cleared room.engine, removed its listeners and moved to Disconnected, but the engine is open again with its signal callbacks and reconnect logic active. When that connection drops, handleDisconnect → attemptReconnect sees _isClosed === false and full-reconnects with the saved URL and token, and keeps doing so.
On LiveKit Cloud this shows up as the same identity rejoining the room with a new participant SID every ~60s, for many minutes (we observed 8–25 min). No tracks are published, each connection ends with CONNECTION_TIMEOUT, and there's ~10s between connections. Any agent in the room keeps the session alive the whole time.
Versions
livekit-client2.22.4 (current latest)- React Native via
@livekit/react-native3.0 /@livekit/components-react2.9.24, iOS and Android. The code path is platform-independent.
Root cause
RTCEngine.join() (dist livekit-client.esm.mjs, around line 25044 in 2.22.4):
const joinResponse = yield _this2.client.join(url, token, opts, abortSignal, useV0Path, offerProto);
_this2._isClosed = false; // <- runs even if close() happened while client.join was pendingThere are two ways to hit it:
Room.disconnect()duringConnecting.disconnect()aborts the controller, awaitsengine.close(), then setsthis.engine = undefined.- If the signal join is already past its last abort check,
client.joinstill resolves. The engine reopens, andRoom.attemptConnectioncontinues onthis.engine, which is nowundefined. That throwsTypeErrors such asCannot read property 'client' of undefined. - The reopened engine is never closed again.
engine.close()during an internal full reconnect.restartConnection()callsthis.join(...)with no abort signal at all, so a close that lands while a restart is in flight is undone the same way. This makes it easy to hit on poor networks: the initial connect falls back to the reconnect machinery, and a user leaving at that point orphans the engine.
Repro
const room = new Room(); room.connect(url, token);- Call
await room.disconnect()while the room is stillConnecting. Around 0.5–1.5s works; it's easier on a slow network, or by delaying the join response. - Watch the room on the server: after
disconnect()resolves, the identity keeps rejoining with new participant SIDs. On the client there are noRoomevents (the listeners were removed), but signal-connect logs keep appearing.
We reproduced it on an iOS simulator by leaving ~1s after connect() (3 rejoins over ~2 min, plus the TypeError), and on an Android emulator with a degraded network (17 signal attempts over 4+ min).
Suggested fix
- In
RTCEngine.join(): afterclient.joinresolves, bail out if the engine was closed or the attempt aborted while it was pending. For example, keep a close generation counter: increment it inclose(), capture it at the start ofjoin(), and compare after the await, alongsideabortSignal?.aborted. If it changed, send a best-effort leave, close the signal client, and throwConnectionError.cancelled(...)instead of reopening. - In
Room.attemptConnection: captureconst engine = this.engineonce and use that local reference, rather than re-readingthis.engineafter awaits, so a concurrentdisconnect()can't causeundefineddereferences. In catch paths, onlyrecreateEngine()ifthis.engine === engine.
We tested a patch with the attemptConnection part: it removes the initial-connect variant in our repro. The join() guard is what covers the reconnect variant too.
Workaround (for anyone else hitting this)
Deferring room.disconnect() while room.state === Connecting, until ConnectionStateChanged reaches Connected (then disconnect) or Disconnected, avoids variant 1. Variant 2 can't be fully worked around from the app.
Activity
jensellfors commentedon Oct 14, 2026
More data on variant 2 (
close()during an internal reconnect):room.disconnect()while the room wasReconnecting.signal connectingand full-reconnected with the saved token. That recreated the deleted room on the server (new room SID, new participant SID).In the same run, every case of
disconnect()whileConnectingwas clean once we deferred the disconnect until the connect settled (6/6). That points at thejoin()reopen in the restart path as what's left.lukasIO commentedon Oct 16, 2026
thanks for the report!
this is mostly addressed in #2095, will follow up with a separate follow up for the TypeError access on undefined #2151
jensellfors commentedon Oct 16, 2026
Thanks @lukasIO, that was quick! We read through both PRs. #2095's cancel checks after the signal join response and through
restartConnectioncover both ways we were hitting this, and #2151 takes care of theTypeErrorwe saw.Some data from our side, in case it helps: we worked around it in our app (deferring
disconnect()until the connect settles) and also revoke the participant's token on the server when a session ends. Together that took rooms recreated by orphaned engines from 133 to 0 over the last day, and Android full reconnects from ~13% of calls to ~2%. We'll bumplivekit-clientand test once these ship. Could you ping this issue when a release includes them?